Commit Graph
137 Commits
Author SHA1 Message Date
yhh 8a6a4f20d6 feat: modernize studio interface and session recovery 2026-09-20 07:20:45 -07:00
esengine fea552a914 feat(events): which model wrote a frame is a fact the frame carries
A two-model turn put two producers on one stream and said nothing about
which was which. The kernel knew — the planner's sink has labelled its
events all along — but eventwire had no field for it, so the label died at
the transport. Everything downstream was left with the same guess: read
the last phase marker before the frame. That is an adjacency, not a fact,
and the record does not even keep phase frames, so a rebuilt transcript
could not make the guess at all.

Producers now name themselves. An agent is built for a role and labels the
frames it settles; a forwarder that re-parents another agent's work
relabels on the way through, because in the parent's stream a sub-agent's
call is the sub-agent's whatever role the child plays in its own. The
field crosses eventwire, so it is on the frames the record keeps, and a
card rebuilt from those frames carries it.

Streaming deltas stay unlabelled, and finding out why was the useful part
of this cut. Coalescing merges only deltas that carry nothing but text —
it decides that with a zero-probe, so any new field on a delta silently
turns the whole optimisation off, which is exactly what labelling every
frame did: forty provider chunks reached the frontend as forty events. A
delta is a fragment of a message whose settled frame names its producer,
so naming the fragment says nothing new and costs the stream. The boot
effect test is what caught it, at the boundary it was written for.

That leaves one asymmetry already in the tree: the planner's own text
deltas have carried a source since before this change, so they have never
coalesced. Not touched here — it is the coordinator's direct emissions,
not this seam, and the card takes its producer from the settled frame
either way.

The destructive control runs both ends. Strip the field from the transport
and a real two-model turn fails on the frames themselves; strip it from
the frames and the cards go unattributed rather than falling back to the
nearest phase marker — which would look right in the fixture and be wrong
the moment a frame arrives out of the order the markers suggest.

Tool cards do not carry it yet. The fact is on the frame they fold from,
so that is a projection when episode membership needs it, not another
fact to establish.
2026-09-07 10:32:08 -07:00
esengine 1c7cb8d680 feat(evidence): what a report may close is the host's grant, not its own label
review_report's `kind` carried two facts at once: which review this was, and
which obligation it closed. The first is the worker's to state — it is the
payload it was asked for. The second never was, and reading it out of the same
field made the label the capability: a run contracted for one kind could submit
the other, and every gate downstream accepted it, because by the time a gate
looked there was nothing left but the word the model had written.

So the permission is issued before the run and stamped from outside it.
ReviewAuthority says which obligations a report from this execution may close;
the host binds it to the report tool at mount time, the receipt records it off
the mounted instrument, and ReceiptProves asks that question instead of reading
the payload. The kind stays, as the delivery fact it always was: a submission
naming a kind other than the one assigned is refused at the tool, before a
receipt exists, rather than downstream where a gate has to unpick it.

Authority is separate from Delivery all the way up — on the skill, on the
profile, in the spec — because a worker can owe a report it may prove nothing
with, and one did. Both come from the worker's own declaration, so no entry
point widens either by asking differently, and the built-in security reviewer
declares only the security obligation: whether a security review also answers
for an ordinary one is undecided, and an undecided grant is not asserted.

Two consequences are deliberate. The report tool leaves subRegistryFor and is
mounted where the execution id exists, since a permission issued to a run that
does not exist yet is not one — there is now no reachable state in which the
instrument is present unbound. And read_only_skill, which runs outside the
delegation lifecycle and has no execution to name, gets a grant with no
authority: the verdict is still owed, a block from it still stops delivery, but
evidence nothing can be joined back to does not close an obligation.

The gate says which of these happened. ReviewProofGap separates "no report",
"submitted by a run the host cannot name" and "submitted by a run granted no
such authority", and the refusal names the one that occurred. Without that the
model reads a demand for a review it believes it just delivered, and the rule
it invents to explain the contradiction outlives the turn.

Reporting is accepted once rather than called once: a refused attempt records
nothing, so a run that got the schema or the kind wrong can still correct
itself, while one that already has an accepted verdict cannot file a second.
2026-09-05 04:39:48 -07:00
esengine d19db0eaa8 feat(skill): a worker declares what it owes when it finishes
10a found the typed-review requirement had no owner: neither the skill nor the
profile could express it, and what decided it was a switch on the worker's name
consulted by one compiler. That made two independent errors at once — a reviewer
renamed lost its obligation, and an ordinary worker that borrowed the name
gained one — and left task(profile=review) with nothing to inherit.

So the fix is a declaration, not parity. DeliveryContract lives on the
definition, the built-in reviewers declare their own verdict there, and it
projects into the execution spec the same way a tool ceiling does. Both entry
points read only that projection. The name switch is deleted rather than moved:
putting it in the task compiler too would have made the accident look like a
design.

task(profile=review) therefore changes behaviour and is meant to. The same
reviewer owed different things depending on which button started it, and nothing
about the product justified that — only the compilation path did. Moving the
rule from wording to structure fixes the false negative and the false positive
together; the parity is a consequence.

The contract is declared by built-in definitions and deliberately not parsed
from user frontmatter. Where the fact lives and who may assert it are different
questions, and if a security verdict ever counts as verification evidence, a
line of YAML should not be enough to claim it. That one stays open on purpose.

The guard is the falsifier inverted. What proved the defect — a renamed reviewer
losing its obligation, an impostor gaining one — now proves the fix, through both
surfaces, and the built-ins are read from the store the way a session reads them
so a definition that dropped its declaration fails loudly.

Two files were extracted rather than widening a budget: the contract to its own
file, and the frontmatter key vocabulary out of a 1400-line loader.
2026-09-05 01:15:23 -07:00
esengine 9e49590cf3 feat(agent): execution identity stops being the parent call's
9a showed that a host-started execution had no name that could outlive the
process. The store's ref does not exist while the scheduler is holding work
back; the synthetic slash id may never be persisted, because a reader finding it
would take it for a call the model made; and the parent call must stay empty,
because there is none. Three candidates, none of them able to be the answer, so
the answer is a fourth thing.

SubagentMeta now carries ExecutionID beside ParentToolCallID. They are different
questions — which execution this is, and which provider-visible call it descends
from — and they were one field only because every durable execution so far came
from a tool call. A model-delegated run still has them equal. A host-started run
has the first and not the second: an execution the host mints before the work is
opened, a root node with no spawn edge above it, and an empty lineage.

That distinction also removed a conflation in the opener. Descending from a call
and having somewhere to draw are separate facts, and treating them as one made a
run outside any run loop unopenable. It is durable work; it gets an execution.
The picture goes wherever a sink exists.

Reads are lenient, writes are strict. A record naming its own execution is
joined on it; one written before that did is aliased to its parent call only
where the journal opened an execution by exactly that id, because the journal is
what confirms the older writer used one id for both — a parent id alone proves
only what it meant by lineage. A parent naming no execution stays unknown and is
joined on by nothing: a single task once stored the call while the journal opened
the node, and a prefix rule would have joined those two wrong. Six shapes are
pinned as a table. New writes refuse an unnamed execution outright, since that is
the one record no later reader can place.

Nothing historical is rewritten, and this does not promise it will be. The safe
legacy subset resolves losslessly on read; the unsafe one — a host-started record
from before this, which never had an execution — cannot be recovered by any rule
that is not inventing history. Resolving on read forever costs a map lookup and
claims nothing.

9a's four arms rerun exact: what was record-only and LOST-SILENT is now an
execution the journal opened, a root node on the graph, and a restart that can
place the child. Both oracles still hold in every arm — no persisted lineage
names a host event id, and every host-started parent is empty. The frozen matrix
gains the host row and joins on the execution rather than the parent, and across
60 arms nothing is violated.
2026-09-05 00:35:58 -07:00
esengine 189d106fb9 fix(boot): a subagent skill stops owning an execution loop of its own
8d-2 classified the normal skill runner as a shared delegated execution: it asks
the same scheduler, runs a real child, keeps transcripts and supports
continuation. What it did not do was leave a trace of any of it — an executing
skill was named by no graph, no journal and no store record — and a stop that
reached its child was filed as a failure. Both followed from the same thing: it
had built a second execution owner beside the one every other delegation uses.

So the fix is not to give it a lifecycle. It is to stop it having a loop. A
skill now compiles to a ProfileExecSpec and hands it to RunProfileSpec, which
already owns admission, the child's own run, and how an execution ended. The
runner keeps what only it knows — the body, the workspace facts a child cannot
discover for free, the tool ceiling, the whole-workspace claim a writer skill
carries, the typed verdict a reviewer owes — and nothing about how an execution
is admitted, carried out or ended.

Three semantics had to survive the move, and each has a test rather than a
promise: the prefix is the body plus the workspace facts, not the body alone; a
writer without declared paths still claims the whole workspace so it serializes
against fleet and task writers; and a reviewer still owes a typed report, which
the spec now carries as a worker property because it follows from which profile
this is and never from what one caller asked. That last one is deliberately not
derived from the worker's name: doing so would have handed the same contract to
task(profile=review), which does not have it today, and a convergence must not
change a delivery contract on the way past.

The cancellation defect is fixed by deletion. The legacy runner classified every
error as failed; RunProfileSpec already had the one classifier that tells a
deadline from a cancellation from a failure, so removing the duplicate makes
skill-cancel read cancelled where it read failed. Nothing was written to address
the symptom.

Lineage keeps its existing contract. A host-started run carries a synthetic call
id so the child's UI can nest under it, and that id must not be persisted as
though the model had made the call — so the spec says the run is top-level and
it opens no delegation and is stored under no parent. Whether a slash-invoked
skill should instead join its own host-emitted dispatch is a question this cut
does not answer; it leaves that path recording nothing, as before.

Two gates: a skill must leave the records every delegation leaves — an opening,
a slot grant, a settlement, and a child joined to that execution — and a
host-started one must be filed under no call, including when a synthetic one
exists. Reintroducing the private loop as a mutation turns the first red.

The probe's own criteria are corrected where they were written for the world
before this. A refused item's negative control is about the store alone: an
opening is the orchestration recording that work entered it, which a refused
item did, and demanding journal silence would forbid the record that tells a
restart it was held back rather than lost. The identity row now checks the join
rather than one spelling of it, because a record's parent is the execution's own
identity and comparing it to the bare call id fails the chain that makes them
joinable.

Across 54 arms nothing else moves and nothing is violated. The read-only entry
point is untouched: every one of its rows reads exactly as before, and whether
an ephemeral run that holds a scheduler slot should be drawn stays open.
2026-09-04 23:21:29 -07:00
esengine 85640c4a90 fix(agent): the store admits every execution that really started
8d-1 measured one store answering differently about executions it holds the same
evidence for. A fan-out item whose slot the scheduler had granted, whose child
had reached the provider, and whose start the journal had recorded was absent
from the sub-agent store entirely — live and after a restart — while a lone
delegation in the same death, the same store and the same join read running and
then interrupted. The store is what list_subagents, read_subagent_result and
adopt_ref address, so that item was work nobody could ask the host about.

The cause was where the store's record hung, not what the fan-out is. A lone
delegation marked it from the lifecycle that opened it; an item whose group did
the opening had no lifecycle, so it reached the same runner and none of the same
record. The boundary now belongs to the run rather than to whoever opened it:
every delegated execution crosses beginExecution once it holds a slot and has a
transcript, and there it is recorded, then drawn, and only then may act.

Nothing else moves. The group still owns dependency, adoption, skipping and
settlement; an item the scheduler refused is owed no record and still gets none,
which is the proxy this line of work removed and must not reintroduce. What did
have to move is the picture: the slot grant used to draw a fan-out item running,
which put the drawing ahead of the record, so both entry points' start hooks now
write the journal and nothing more. The item's node travels with the sink it was
drawn on, because a fan-out item's own sink forwards tool events and drops graph
deltas — the picture is reachable only through the group's.

A refused record stops the run at both entry points, and that is now asserted
rather than reasoned about: the child must not act, nothing may be drawn running,
the slot must come back, and the journal keeps the start it honestly wrote. Two
mutations were run against it — the boundary skipping the store for an item whose
node someone else drew, and drawing before recording — and each turns the suite
red, the first only on the fan-out side.

The active-store arm's two cells flip as specified: an executing fan-out item
reads running then interrupted where it read no record twice, list_subagents
shows it, and the refused item stays absent. Across all 47 arms nothing else
changes but the two fan-out arms whose store row now sees the child that was
executing, and parallel_tasks converges on the same contract.
2026-09-04 22:34:13 -07:00
esengine a50ca8075e fix(agent): a delegation the scheduler never admitted owes the store nothing
8c asked one question: on each side of the slot grant, who owns the ending. The
answer the code gave was wrong on the near side. A backgrounded delegation
killed while it was still queued wrote a failed child record — and the store
outranks the lifecycle on a rebuild, so a cancellation before anything ran came
back as a failure that never happened. The journal said it plainly at the same
time: opened, queued on slots, settled, never started.

The cause was not vocabulary. A background run has its transcript before it has
a slot, so "a run object exists" had come to stand in for "a child executed",
and every early exit on that path wrote a terminal for it: a refused admission,
a writer registration that failed, a store that would not mark it running. None
of those is an execution outcome. They now return their error and leave the
store alone, which the journal's opened-queued-settled already accounts for.

Four arms hold the boundary from both sides. Backgrounded, a delegation killed
while queued now leaves no child record and rebuilds as cancelled; one killed
while running keeps its own cancelled terminal and rebuilds exactly. The rows
carry a cross-authority check that refuses the combination neither layer may
produce — a child record for work the journal proves never started — because a
state comparison shows that only as "cancelled came back failed".

The two foreground arms do not establish. Stopped through the turn's own cancel,
the turn ends — running=false, no cancel pending — while the delegation is still
started and never settles: its child keeps waiting on a context the cancel did
not reach. That is a separate question from ownership and is left open rather
than guessed at; the arms report it as their premise failing, with the reading
that produced it. Cancellation at the delegation's own context settles and
records correctly, which a unit test now holds.
2026-09-04 20:48:44 -07:00
esengine 9b80052f95 feat(agent): a lone delegation is recorded before it is drawn, and drawn as what it is
8a and 8a.1 measured two failures with one cause. A single task reached the
same runner a fan-out item does and none of the same records — nothing on disk
for a restart to read — and it drew itself running before it had asked the
scheduler for anything, so a delegation the ceiling was holding back appeared,
to the graph and to the parent, as work in progress. Both came from the same
place: states were being inferred from where control flow stood rather than
projected from what the scheduler and the child's own ending had proven.

The lifecycle is now one thing that decides nothing. It opens after every
deterministic refusal has answered — a prompt that would not settle or a
runtime that would not resolve is not work orchestration ever held — and writes
the opening from the same delta the graph is drawn from, journal first. The
node opens pending. Queued and its cause come only from the scheduler's first
refusal; running only from the slot grant, and behind it the store's own record,
so nothing shows a run executing that the store has never heard of.

Two proxies are gone. The foreground no longer announces running before
acquiring a slot, and a background run no longer reports queued because a job
was registered: that word now means the scheduler refused, and an immediate
grant goes straight to running. What tells a caller its work was handed to the
background is the tool result, which says so in words.

A backgrounded delegation is on the graph like any other. Hiding it was the
lexical lifetime of the old opener, not a semantic: run_in_background decides
whether the parent waits, not whether the delegation exists. Ownership moves
with the handoff, exactly as the progress tracker's already does — the caller
settles what it still owns, the job settles what it took.

The store's parent is now the execution's own node. That join is what a rebuild
reads, and with the journal keyed by node and the store by call, a completed
lone delegation rebuilt as skipped: the outcome could not be found. The two
spellings are one identity again.

read_only_task keeps its promise: ephemeral entry points write no journal, so a
research child stays out of the history a restart rebuilds from, while its
caller still sees what it delegated.

Six mutations were run against the new tests: publishing before recording,
opening the node running, marking the store running before the slot, settling a
handed-off run at the outer return, telling the parent running before the slot,
and recording an ephemeral delegation. Each turns the suite red. The five
process-boundary arms now read pending→queued(slots)→before-start and
pending→running→during-execution on both entry points, and a completed
delegation rebuilds exactly.

The boot-wired skill runner prepares its own transcript rather than going
through this path, so it inherits none of it yet; that is 8d's question, not a
gap this closes quietly.
2026-09-04 20:27:31 -07:00
esengine 01536b0ce0 fix(agent): a run the caller stopped is not a run that failed
The run graph tells cancellation from failure. The sub-agent store, which
owns what happened to a child, did not: every error path saved a child as
failed and SubagentStatus had no cancelled to save. The probe measured the
result as graph cancelled=2 completed=1 failed=1 against store completed=1
failed=3, with the journal — which records orchestration, not outcome —
unable to help. The distinction was not un-persisted, it was unkept.

The store now keeps it. A cancelled run persists everything a failed one
does; a transcript is as worth reading when work is cut short as when it
goes wrong, and only what it says happened differs. One status covers both
ways a run is stopped, because the graph maps a cancelled context and an
expired deadline onto one state; which of them it was rides beside it, so
the two owners share a vocabulary instead of splitting one.

Only the two seams where a child actually ran may read a context error as a
terminal outcome. The other four SaveFailed callers — a failed writer
registration, a panic, a refused slot, a transcript that would not save —
are host failures whose chains can carry a cancellation without the work
having been stopped. They keep calling SaveFailed, which takes no error and
therefore has nothing to classify: the negative control holds by shape
rather than by judgement.

Old sidecars are not migrated. A historical status=failed was written by a
store that never knew this distinction, and the same error text can come
from paths that were not cancellations; reclassifying on it would invent
provenance nobody recorded.

The probe's judgement is unchanged and flips on its own, VIOLATED to holds.
Its journal row is part of that gate: teaching the journal about
cancellation would have made the comparison green while breaking the
boundary it exists to protect.
2026-09-04 09:17:47 -07:00
yhh 0a1231529c fix(graph): a lone sub-agent is a delegation the run graph draws
publishGraph had two callers, fleet and parallel_tasks, so every other
delegation — a task, a run_skill, a review capability — published no node at
all. A sub-agent ran eight steps in its own context and the graph tab answered
"这一轮没有派出并行的子代理", which reads as nothing having been delegated.
The graph was a fan-out visualiser; a person opening it is asking what this run
handed work to.

The hook goes on RunProfileSpec, where task.go already states the invariant
every entry point relies on: each one compiles to a spec and runs through it,
so a boundary added there cannot be missed by one caller. The two fan-outs mark
their items with withDeclaredGraphNode — only they can say it, having chosen
the node's id and kind, and without it the shared runner would redraw a fleet
worker as a group of one. A background run draws nothing: it settles after a
handoff this return never sees.

lanesOf needed no change — the group, the parentId and the spawn edge are the
shape it already reads.

Two extractions keep task.go and RunProfileSpec inside their budgets rather
than widening them: subSink/subSinkFor move to nested_sink.go, which is what
they are about, and the spec's prompt validation becomes settleSpecPrompts.
Both hand budget back.
2026-09-03 00:05:40 -07:00
esengine 27b463f593 feat(agent): count how often a child closes the way it was asked to
The typed handoff exists for read-only children now, and nothing insists on it.
So this measures the natural rate first: a nudge built on a compliance number
nobody collected would be a guess with a number on it.

It observes and decides nothing. No prompt byte moves, no notice is emitted, no
parent handoff changes, and the provider surface is untouched.

The denominator is the instruction, not a guess at one. ExpectCompletionReport
is set exactly where the contract is appended, never inferred from a registry
holding the tool — the same distinction this codebase keeps having to relearn:
a count is not its evidence.

Four things are counted per child run, one sample each, from a single defer so
a salvage, a review failure, a provider error and a clean answer are all
counted the same way:

  adoption      attempts, accepted, malformed. Attempted-and-refused is a schema
                problem; never-attempted is a protocol one, and one number
                cannot say which.
  protocol      report round against final round, and tool calls after it. The
                contract asks for the report as the closing call, and
                "submitted eventually" is different behaviour.
  adjudication  claimed status beside the host's, and how many claims it
                lowered. A child that reports readily and is corrected every
                time is not a compliance problem.
  exit          completed, error, cancelled, no_answer. A provider failure that
                produced no report never had the chance to comply and leaves
                the denominator.

Split by entrance and depth, because an aggregate hides the entrance that is
failing. Content-free throughout: counts, enums and rounds, never a prompt, a
path or a summary.

Verified by making each fail: stop separating malformed from accepted and the
schema-vs-protocol distinction disappears; classify a provider error as
no_answer and a failed run becomes a compliance failure. The cancellation test
carries a wrapped sentinel and a lookalike string, because the classifier reads
errors.Is and a message that merely says "context canceled" must not pass —
repolint's error-text rule caught the first draft doing exactly that.

Options moved to agent_options.go: it is 172 lines of a god object, and this is
the third field appended to it this round. That extraction carries ten essay
findings forward, declared with -allow-widen; agent.go drops from 1597 to 1424.
runReadOnlySubSession stays in task.go — the spawn-boundary guard is right that
its list must not grow for a file-size reason — and the framing both runners
apply is shared instead.
2026-08-28 21:26:04 -07:00
esengine 27f6e2d15c feat(agent): a read-only sub-agent reports the way a writer does
A writer child ends by calling complete_subtask, and the host adjudicates every
citation against what it observed the child actually do. A read-only child
ended in prose. Its receipts still reached the parent ledger, so the host knew
which files were read and which commands ran — but the semantic half arrived as
an unverifiable sentence, and nothing connected the two. With one researcher
that is invisible. Across a fleet of eight it is the parent's whole picture.

Strict children now get the same contract and the same tool. complete_subtask
is already ReadOnly, so it survives the strict construction filter unchanged,
and the parent's existing adjudication path needs nothing new: read-only runs
through RunSubAgentWithSession like every other child.

The order is the point, and it has a name now. readOnlySubRegistry builds the
tools, proves the child can investigate something, and only then attaches the
way to report. Attached first, complete_subtask would make an empty registry
non-empty — a child with no research tools at all would pass the capability
check by holding the tool it files findings with, and then report on work it
could not do.

The contract's wording asked for "the paths you changed", which a child that
only looked at things cannot cite. It asks for changed or inspected.

No provider surface moves: the tool joins a child's registry, not the parent's.
2026-08-28 20:59:43 -07:00
yhh 4d320f7cd7 fix(agent): a change stays in review's scope until a review has covered it
The review gate read one index for two questions — what the change set is,
and how fresh a review of it must be — so a later harmless write moved the
window past everything before it. A sensitive edit followed by a README write
scored Low and dropped the whole structured-review demand.

UnreviewedMutationBaseline now answers the first question and the latest
mutation still answers the second. It advances per review kind, so an accepted
review does not discharge security_review's obligation with it.

Alongside, the parts of that gate that promised more than they did:

- A blocking verdict is honored at every role setting, and review subagents
  submit the typed report everywhere. A verdict a caller must act on carries
  an identity, not a sentence. The nudge runs everywhere too; the hard failure
  stays in Delivery, since outside it no report is no block either way.
- Medium no longer demands a structured review "or" host-proven
  self-inspection. That was no alternative — the reader there is the author —
  so the cheap side was always taken and the demand never bound. The real
  requirement inside it moves to the self-inspection gate and grows teeth:
  inspect every changed path, fresh per path, not one of them.
- warn findings were collected into a slice nothing read. They now reach the
  user, and they are collected from any review the turn ran rather than only
  from one the gate asked for.
- verificationGapResult.advisory carried no text and reached nobody while two
  comments called it reported. The waiver it stood for is right and stays; the
  flag wearing a message's type is gone.

The review and security-review bodies stop hardcoding git: fileutil owns one
declared store table, environment reads it, and a skill subagent's prompt now
carries the workspace fact its parent always had. They also drop the ~12-call
self-cap, which was well under the host's real budget, and split the burden of
proof — adversarial about what must be read, conservative about what may be
reported. A pass is a negative claim and owes what any other does.

complete_step: manual evidence no longer stands alone in a turn that already
has receipts. The evidence ablation arm reaches the tool schema, so the two
arms stop paying the same prompt and the A/B can answer for the contract
rather than only for the gate.
2026-08-26 05:55:04 -07:00
yhh 523791aff3 feat(agent): a contended slot goes to what is holding up the most work
The fleet plan proved a dependency DAG and published it, and the scheduler that
decides which item actually runs never saw it: an AcquireRequest carried a write
claim and a label and no position in the graph at all. So a slot went to whoever
queued first, which is how the head of the longest chain ends up behind leaves
that unblock nothing — and that wait lands on the run's makespan one for one.
It is the loss fanoutMetricsOf already prices as wall above the critical path:
measured every run, acted on by nothing.

The plan now measures each item's rank, the longest chain of dependents below
it, and carries it to the acquire, where the pump drains waiters heaviest first
and keeps arrival order within equal weight. That covers every grant after the
session's slots are full, which on a fan-out wider than the ceiling is nearly
all of them.

It does not cover the first fill, and sorting the launch loop does not either.
Which of a burst of goroutines reaches the scheduler first is the Go runtime's
decision: launching heaviest-first captured 41.8% of the ideal first-fill weight
against declaration order's 51.9% at a ceiling of two, and tied at one and three
— never better, sometimes worse, because the runtime favours the goroutine
created last. The loop is left alone and says why.

A run also queues before it builds rather than after. It used to construct the
whole child — registry, runtime, and the transcript that holds a store lease —
and only then join the line, so a fan-out wider than the session ceiling held
one open per item it had not started, and every millisecond of that setup was
billed to a concurrency ceiling that had not caused it. Foreground runs hold the
slot from before the transcript exists; background ones still return a job id
first and queue inside the job. Deriving the request moves to slot.go, the file
that owns the acquire.
2026-08-26 01:10:00 -07:00
esengine e4a6dee1a3 refactor(kernel): delete the exported functions nothing references
Two fixes and one sweep, all from the same audit.

The process history catalog was never closed. boot's cleanup releases LSP,
plugin hosts, the runtime scope and session temp; the catalog's own
Flush/Close had lived in the retired shell and were left to two test files.
It is process-wide, shared by every pane and workspace, so it closes at the
process boundary next to the usage catalogs, not with any one controller.
Queued index writes are drained first — the reopen after an unclean exit
re-scans every root instead.

The sweep removes 57 exported functions with no reference anywhere in the
tree, tests included, plus the private helpers that fell dead behind them.
Iterated to a fixpoint: deleting a function exposes the next one, and both
the orphan rule and golangci's unused ran again after each round until
neither reported anything.

The rule learned two things from being wrong here. Methods are now out of
scope: chatTUI.Init is bubbletea's tea.Model, no file in the tree names it,
and proving that needs type information an AST pass does not have — the
first attempt at this sweep deleted it. And an orphan tests still reference
is reported differently from one nothing references, because they need
opposite fixes: repair.PrepareFileUpdate is a cross-package fixture eight
test files build state with, and deleting it was the second thing that
attempt got wrong. Only the unambiguous half is deleted here; the 124
remaining stay recorded.
2026-08-25 18:12:18 -07:00
yhh 1511b331eb feat(agent): a run graph node says when it ran, and what it was waiting for
The graph declared StartedAt and EndedAt and no producer ever filled one,
so every consumer's timing fallback was dead code and a run's own order
survived only as the order its deltas happened to be published in.

Waiting for a slot and holding one were the same word besides. A fan-out
the session concurrency ceiling was throttling drew identically to one
where every item started at once — and which of the two it is decides
whether raising the ceiling would buy anything.

- agentgraph gains StateQueued and Node.QueuedAt. Terminal() had to learn
  the new state: a queued node reported as settled leaves a reader no
  longer waiting for the answer it is still going to get.
- SchedulerPolicy.OnStart fires once the slot is held, which is the only
  moment the wait can be read from. acquireSlot moves to its own file and
  becomes the single place that reports it, for both the foreground and
  the background path.
- fleet and parallel_tasks publish queued, then running, then settled.
  An item now settles the moment it settles rather than when its group
  ends: a long fan-out used to draw every finished worker as still
  running until its slowest sibling returned, which is the whole time
  anyone is watching it.
2026-08-25 04:15:06 -07:00
yhh 5ec7467396 fix(agent): a delegated run asks the person waiting for it, instead of itself
Reported as Studio never raising the ask card: the model kept answering with
"ask 无交互响应(fallback)" and telling the user to go fix it. It was reading
its own tool result back. A subagent's ask found no asker and returned the
headless fallback - the text written for autonomous runs, which says in as many
words that no interactive user answered.

The root executor has an asker: EnableInteractiveApproval wires the controller
in as one, and every interactive frontend calls it. A child never did.
newAgentServices binds each collaborator from Options and there is no Asker
among them, so SetAsker is the only way in and only the controller ever calls
it - on the executor and the planner it holds, never on anything they spawn.
The gap was silent because the fallback is a plausible answer rather than an
error.

The call context already carries the right answer and nobody was reading it.
RunSubAgentWithSession is the one construction boundary every child goes
through, and by the time it runs, the context says whether a question can be
put to anyone: a delegated run has a parent blocked on it and a person waiting,
so exactly one question can be outstanding; fleet items and parallel tasks
build their context with the asker dropped on purpose, because a question from
one of many has no reader who knows which asked it; a background job runs under
a job context with no call on it at all. Inheriting from the context therefore
needs no flag and no policy - the three cases already differ where it matters.

Both halves are tests now, because "silently answers itself" is what this class
of bug looks like and neither half fails loudly. The construction moved to its
own file rather than growing task.go, which is 1200 lines past its budget.
2026-08-25 02:18:20 -07:00
yhh e0e7c0aada feat(agent): the run graph is a fact the kernel publishes, not a shape a frontend infers
The structure was being re-derived in five places and survived in none. A
fleet's dependency DAG lived and died inside one Execute call — the dispatch it
emitted carried a prompt and a profile, so depends_on never left the process at
all. The persisted sidecar recorded a parent link and nothing else. The event
stream implied the rest through id prefixes. By the time any of it reached a
reader it was a flat list of children, which is why a panel could count
delegates and never say which of them was waiting on which.

internal/agentgraph is the vocabulary, and it is a leaf: nodes, and edges that
carry an identity rather than leaving a reader to match words. depends and
context are two edges on purpose. The measurement arm orders its endpoints
while delivering nothing between them, so a single edge type would describe an
ablated run and a real one identically — the same reason an error a caller must
tell apart carries a sentinel rather than a sentence.

The other half of that rule was already being broken on disk.
InheritedContext.Upstream was a bool: it recorded that some dependency's answer
opened a run, and a reader asking which one had nowhere to look. It names them
now, and a sidecar written while it was a flag still decodes to "there were
sources, unnamed" rather than to nothing.

fleet and parallel_tasks publish what preflight already proved, before any of
it runs — an item that never executes emits no tool events to infer a state
from, and an adopted answer emits none at all. That collapses three copies of
the same five-state vocabulary into one and two identical group classifiers
into one, so a group's progress card and its graph node can no longer disagree
about how the fan-out ended.

A node is the call it names. Timing and output stay on the tool stream and a
frontend joins them by id, rather than the kernel publishing the same clock
twice under a second naming scheme. internal/boot asserts that at the frontend
sink through the real Build assembly: a scripted two-item fleet with a
dependency in it, and the edge has to arrive.
2026-08-25 00:01:34 -07:00
yhh 6eedacc95f revert(agent): fleet for_each removed, three fixtures said no
Built, shipped behind the surface, and measured against the call it replaces —
the same graph with one task handling every subject. Three fixtures, twenty-eight
graded runs against a real provider, every run of both arms solved, and mapping
spent 70%, 85% and 98% more tokens for it (exact permutation p <= 0.03 each).

The last fixture was written to be the case mapping should win: ten modules of
near-identical code, a hundred and sixty lines each, and an answer that is the
sum of ten separate derivations so a slip anywhere is a wrong answer. It did not
win there either, and one run solved it by grepping the constants out rather than
reading anything — which is the reason. A mapped child pays a full sub-agent's
fixed cost per subject, while a single child with a tool belt never has to hold
the subjects at all: it reads them one at a time, and when the facts are
extractable it extracts. "One context cannot hold N subjects" assumes an
ingestion an agent does not have to perform.

So the graph vocabulary goes back to id and depends_on, and SPEC 3.14 records the
exclusion as measured rather than as taste.

Two things survive the removal. The edge dedupe in newFleetPlan stays: a task
named twice in one depends_on was already counted twice before for_each existed,
and the item would have waited for a result nothing publishes. So does
unresolvedDepCounts, which adoption needs — an adopted node enters resolved and
its dependents have to be ready on the first pass.

benchmarks/fleet-fanout goes with it. The history holds both if a case that
genuinely needs per-subject isolation ever turns up.
2026-08-24 02:35:51 -07:00
yhh d484e88ce5 feat(agent): a fleet node's width may be dynamic, its shape may not
for_each names the item whose submitted subjects this item maps over: its prompt
becomes a template run once per subject, and it reports one bounded aggregate
that its dependents open with over the ordinary dependency edge.

The mapped children are never vertices. The node stays one, so the reachability,
write claims, and skip rules preflight proved are the ones that ran — adding N
vertices mid-run would invalidate the precomputed reachability that
skipDependents and the concurrent-claim check both read.

Four things are fixed before anything starts, and that is what keeps for_each
from being the first keyword of a workflow language: the template, the width
ceiling (max_items, defaulted to 8 and hard-capped at 32), the read-only grant —
which removes the write-claim analysis the host could not have done for an
unknown number of writers — and the source. The source supplies only which
subjects, through submit_items rather than prose, because reading a list out of
an answer is the host guessing at a judgement the structure does not carry. The
caller owns the topology, the worker owns the data: the same split the
dependency edge makes.

submit_items reaches only a source's registry, so a capability nothing in a run
can use never enters its prompt. A source that finishes without submitting fails
the mapped node rather than quietly mapping nothing, while an empty submission is
a real answer that completes it. A source may not be adopted, having never run to
submit anything, nor itself mapped, since it answers with an aggregate rather
than a list.

SPEC 3.14 said the graph vocabulary had no dynamic fan-out. It now says no
computed edges, which is the invariant that actually mattered.

Execute's preflight moved into preflight(), which returns a proven fleetCall,
and the ctx-gated tool attach sits beside the other ctx-keyed host framing in the
two sub-session entries; that is what paid for the new lines without widening the
repolint baseline.
2026-08-24 01:25:18 -07:00
yhh 01ee3830b1 feat(agent): a fleet dependency edge carries the answer, not only the order
depends_on ordered its endpoints and delivered nothing, so a research →
implement pair paid for the same research twice. Three changes that compose:

- The edge carries the dependency's answer. It travels as
  ContextRequest.Upstream, never inside TaskSpec.Objective, because the
  objective must stay the pristine text delivery classification judges —
  ClassifierTaskText is captured before any host framing. One 8 KiB budget is
  shared across a node's dependencies, so a wide fan-in cannot flood the
  opening turn and no dependency is starved out of it.

- list_subagents enumerates the children a conversation owns. A reference is
  the only address a child answer has, and a background fleet that a restart
  repaired to interrupted never delivered the aggregate carrying those
  references, leaving its completed children unreachable.

- adopt_ref stands a node down to an already-completed child's answer instead
  of running it. It is proven in preflight through ReadFinalAnswer, so what may
  be adopted cannot drift from what read_subagent_result may read. Nothing
  runs, so the node claims no write_paths and cannot collide with a live
  writer; prompt and every execution field are refused on it rather than
  silently ignored.

Whether an adopted answer is still valid is not readable from structure, so the
host does not decide it: adoption is addressed by reference, and the caller that
listed the reference judges freshness. That is also why a plan fingerprint does
not quietly reuse matching children — reuse the caller cannot see is reuse it
cannot correct.

A child now inherits something, which is exactly what InheritedContext exists to
keep a decision rather than an accident, so the capsule records it and SPEC 3.13
says so.

--ablate upstream leaves the edge ordering its endpoints while delivering
nothing, which is what lets the payload be measured apart from the order.
Against the real provider on benchmarks/upstream-edge/fleet-edge-wide, n=6 per
arm, removing the payload cost +27.6% tokens (exact permutation p=0.011) and
+59% sub-agent tool calls for the same answer, 6/6 solved either way. On a fact
one grep away it buys nothing: the edge pays in proportion to what the
upstream's work cost against how small its result is.

boot.go's delegation registrations moved to delegation_tools.go and Execute's
spec loop to buildItemSpecs; that, plus folding two defaulting branches into
firstNonEmpty, is what paid for the new lines without widening the repolint
baseline.
2026-08-24 01:03:50 -07:00
esengine b93b80d05c fix(agent): a dispatched sub-agent arrives with its name and its count
Every dispatcher is hidden behind use_capability, so the tool name on a
dispatch is the proxy's. The profile that says who ran was resolved only for
directly-called tools, and emitResolvedToolDispatch never carried one — so
every sub-agent the model actually spawned reached a frontend anonymous, with
no way to attribute a step or count the contexts opened.

Profile now describes the delegation rather than just its model: Name says who
runs, Count says how many. A task without a model override is still a
delegation and says so; fleet and parallel_tasks report the size of the batch,
which is the number nobody could previously see. The resolution behind the
proxy reads the real target with the real inner arguments, so a proxied
dispatch and a direct one carry the same identity.

All of it sits in delegation_capability.go beside the alias contract: what a
dispatcher answers to and what it reports are one subject.
2026-08-21 09:36:49 -07:00
esengine 6e0b886446 feat(agent): a way to ask when the static tables run out of answers
shellsafe enumerates 145 commands and will never enumerate them all: a new
formatter, linter, or query tool arrives every week, and each one that falls
through reads as "might write". A read-only command classified that way blocks
work — a real run spent seventeen consecutive rounds trying to see one failing
test, rewriting the filter each time a pipe segment tripped the mixed-command
contract.

So the tables get an escalation path rather than more entries. They stay the
fast path and answer everything they know; when they come up short and the host
is about to refuse, one classification call decides, and the verdict is cached
so a segment is asked about once. triage_model points those calls at a cheap
tier — empty falls back to subagent_model, then the main model, so it works
before anyone configures anything.

The escalation cannot widen what actually runs. The OS sandbox still bounds
every write, the verdict is refused unless the reply is exactly READONLY, and
anything unestablished — no provider, a timeout, a hedged answer — lands on the
refusing side. Delivery never escalates: there a mixed command is refused for
what it does to the receipt, not for what it might write.

Two things this cost to learn, both from running it rather than reading it:

- A one-word answer needs thinking turned off. Under a tight token cap a
  thinking model spends the whole budget reasoning and returns nothing, so four
  classifications in a row silently failed.
- The classifier gets its own provider instance from boot. Borrowing the turn's
  own would put extra calls into a stream the run is mid-conversation on — which
  is exactly what hung the agent tests against a scripted provider, for the full
  ten-minute default timeout.

repolint grows -only, which reports the budgets of named files instead of the
whole tree's recorded debt. A change could not tell its own violations from
inherited ones; the model in one run tried to build a git worktree to compare
against, and the sandbox refused it. Repo-wide ceilings still report, because a
file can push the tree past one without exceeding its own budget.

Carries the rest of the in-progress work in the same snapshot.
2026-08-16 03:15:01 -07:00
esengine d70d9d4686 perf(studio): opening a conversation no longer waits on a wallet read
Switching sessions felt slow, and the length of the transcript was not why:
a 12MB record reads back in 8ms. What the reader waited on was /status, which
queried the provider's wallet endpoint on every call — 87-167ms, uncached —
while the pane held the transcript behind a Promise.all with it.

Three layers, measured at each end:

- billing.Cache serves one wallet answer for 30s, lets exactly one caller
  refresh it, and keeps a good reading standing in for five minutes when the
  endpoint starts failing: unreachable is not empty. billing.Store shares one
  cache per endpoint and credential, because opening a conversation builds a
  whole runtime and a per-runtime cache is cold on exactly the read a switch
  waits on. control.Options collapses its three balance fields into that one
  cache, which is also what keeps boot.build under its ratchet.
- The pane reads the record and the numbers over it separately. __restore no
  longer carries the cache totals; __totals brings them when they arrive, so
  they are still read back from the kernel rather than restarted at zero.
- fromHistory drops two O(n²) shapes — a linear search per tool result, and a
  full copy of the folded list per item. 3200 turns: 121.6ms to 2.9ms.

/status 100ms to 15ms; transcript on screen 139-219ms to 48-125ms, and no
longer a function of how long the conversation is. perf/switch.mjs is the
bench that holds it, with PERF_STATUS_MS to separate the two waits.

Carries the rest of the in-progress studio work in the same snapshot.
2026-08-15 22:23:55 -07:00
esengine e660218d6b refactor(compaction): retire what the multi-threshold scheme left behind, and hand its numbers over
Compaction is not the class of strategy the last few commits removed. The
context window is a physical constraint, not a judgment about how the model
should work, and every rejection in acceptCheckpointCandidate is already
grounded in a measured token count against the user's trigger or the provider's
ceiling. What did not survive review is the residue of the retired
multi-threshold scheme, and the numbers inside it that were never anyone's to
choose but the host's.

The residue:

  - compaction.consecutive was incremented in one place and reset in four, and
    read in none. It only ever existed to be cleared.
  - soft_compact_ratio / tool_result_snip_ratio / compact_force_ratio are
    forced to zero at config load, then threaded from boot through agent
    Options and a twelve-parameter task constructor to fields nothing reads.
  - SnipStaleToolResults and PruneStaleToolResults were exported no-ops with no
    callers, and PruneStats existed to be their return value.
  - ContextReport still declared SoftThreshold / SnipThreshold / ForceThreshold,
    which nothing populated — and /context both printed them and branched on
    them. "LatestPrompt >= ForceThreshold" against a permanent zero is always
    true, so every session, including an empty one, reported "next: at the force
    threshold". It now names the fold trigger and the hard ceiling, which are
    the two thresholds that exist.

The numbers, now under [agent] and all off by default:

    user_turn_keep_tokens      your own turns kept verbatim through a fold
    first_turn_pin_tokens      first turn pinned into the cached prefix
    checkpoint_ceiling_ratio   how small a fold's result must be to be accepted

compact_ratio loses its 0.65–0.85 range. Compacting early to keep prompts small
and compacting late to keep history whole are both real preferences; only a
value that is not a fraction of the window is wrong. The desktop bridge's copy
of the range goes with it.

The user-turn retention budget was min(8192, 5% of window), so it stopped
growing at a 164K window: someone running a million-token model kept the same
8192 tokens of their own words as someone running 128K. It is now the window
share alone, with the fixed count kept only as the fallback when no window is
known. The separate 1500-token per-turn ceiling is gone — the remaining budget
is the only gate, and [[keep]] is still the way to insist on a specific turn.

Because the budget is the user's, /context reports what the last fold did with
it: turns kept, turns dropped and roughly what they cost, and the budget that
decided. noticeDroppedUserTurns already said it once, at the fold; this is what
can still answer afterwards.

Cache-impact: none - no prompt, tool schema, or memory text changes. Fold
behavior is identical for an unconfigured agent: every new setting defaults to
the value it replaced, and the retention budget's window share is unchanged at
every window where the old fixed count was not already the binding limit.
Cache-guard: go test ./internal/boot/ ./internal/agent/ (prefix_shape golden
unchanged; TestUserTurnKeepBudgetScalesWithTheWindow and
TestUnsetCompactionBudgetsKeepTheDefaults pin the defaults)
Documentation-impact: none - docs/*.md documents neither the retired thresholds
nor the fold budgets; the [agent] keys are self-describing in the rendered
config.
2026-08-14 20:03:00 -07:00
esengine 8da0a3de8b feat(kernel): give image routing a model of its own
An attachment the main model cannot read is already handed down to whatever
sub-agent runs next, and that sub-agent's model was chosen for something
else — usually cost. A cheap worker is text-only, so the provider serializer
drops the image and the turn answers as though nothing was attached. The
composer has been promising a vision-capable reader since it shipped; the
only way to deliver one was to hand-edit subagent_model and give up the
cheap worker.

agent.vision_model names that reader. The routing travels in the turn
context beside the image candidates, because it answers the question they
raise: the parent could not read this, so who can. A sub-agent about to
receive candidates it would drop is moved onto that model; one that already
reads images, or a turn carrying no attachment at all, is left alone — a
review sub-agent spawned in a turn that happened to have a screenshot keeps
the model it was chosen for.

/roles gains the assignment and the band's 看图 slot stops reporting a
borrowed subagent, so what the settings pane shows is now a switch rather
than a readout.

config.roleModelRefs is the one list of model refs a role can name. The
fallback repair and the referenced-provider scan both walk it, and they have
to walk the same one: a role missing from either keeps alive a ref no
provider resolves.

Cache-impact: none - the system-prompt prefix is untouched. Vision routing
only rewrites a per-turn context value and a sub-agent's model ref, and the
new config key renders into user TOML, never into the prompt.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
System-prompt-review: esengine - config gains agent.vision_model; it is read
at sub-agent spawn time and rendered into user TOML, and reaches no
prompt-bearing surface.
Documentation-impact: none - no docs/*.md describes image routing or the
sub-agent model fields beyond SUBAGENT_PROFILES, whose precedence rules are
unchanged.
2026-08-14 19:45:25 -07:00
YHH 33fca85bc5 refactor(agent): make the turn a value the tool path is handed (#8588)
The turn was two types: perTurnState, embedded in Agent and zeroed by
assignment, and runLoopState, a genuine local threaded through the loop. Both
described one Agent.Run. They are now one turnRuntime, and beginRunTurn returns
a pointer to the agent's copy instead of opening a second object — the loop
fields used to be built into a fresh literal that discarded everything the
classification above had just computed onto perTurnState.

executeBatch, executeOne, resolveToolPolicy and applyDeliveryPolicyGates take
*turnRuntime. Reading it off the receiver compiled and behaved identically, and
would have kept doing so right up until a turn was not the agent's current one.

Two more fields moved after checking their write sites:

  - classifierTaskText is written only by New, so it is agentConfig, and the
    existing immutability guard now covers it.
  - preserveEvidenceOnce, deliveryRecoveryPending and forkRestore are armed by
    someone outside the Run — a sub-agent spawner, the turn that just failed
    readiness, the fork capture — and consumed by the next one. They are
    pendingTurn. Putting them in turnRuntime would have been wrong in a way
    that compiles: beginRunTurn's replacement assignment would wipe state armed
    before the turn existed.

capabilityLedger and capabilityGate stay on Agent for the same reason. Their
reset lives in internal/control, which calls SeedCapabilityRoute before a Run;
a controller cannot seed a value the Run has not created yet.

turnruntime_test.go guards all three properties: the type stays free of mutexes
and atomics so the single assignment keeps compiling, the tool path reads its
parameter rather than a.turn, and beginRunTurn's replacement literal stays
empty.

repolint: struct-state for agent.go 10 -> 3, repo total 122 -> 115. Verified
key-by-key that no baseline entry increases.

Part of #8523.
2026-08-12 18:57:48 +08:00
YHH ea9ee16e4e refactor(agent): extract the task lifetime into taskRuntime (#8541)
Eight fields on Agent outlive a single Run but are narrower than a session:
they belong to one delivery scope. resetTurnEvidence only runs when that scope
changes, which is what made them a lifetime of their own rather than
session state — recorded until now in comments rather than in a type.

Group them into taskRuntime and reach them as a.task.<field>. Reset conditions
are unchanged: the ledger group, the checkpoint, and the repeat-failure records
each keep their own condition in beginRunTurn, so restartLedger carries scope,
checkpoint, and failure records forward exactly as before. The gain is that the
carry-over is one named list in a single assignment instead of an emergent
property of which fields each call site happened not to mention.

prepareRepeatFailureScope moves to taskRuntime.prepareScope verbatim.

The mechanical rename went through gopls so internal/control's own
deliveryCheckpoint and taskBudget — different types, same names — were left
alone by construction.

The repolint baseline goes down: struct-state 20 -> 18 for agent.go, essay
81 -> 76, file-size 2276 -> 2251. No entry increases.

Part of #8523.
2026-08-12 15:32:43 +08:00
SivanCola 5e03e03eaf feat(agent): light/balanced/delivery role settings with unified tools / 角色设定与统一工具面
Problem:
Work modes (economy/full/delivery) mixed token surface trimming, delivery
gates, and UI labels. Economy forced connect_tool_source schema growth;
switching modes rebuilt controllers and busted prompt-cache prefixes.
Skill profiles could hide tools from the model surface.

Fix:
- Introduce AgentPreset (light|balanced|delivery) + TaskPolicy host floors
  for plan route, verification, and independent review.
- Share one provider-visible core surface across presets; optional tools
  dispatch only through use_capability (retire connect_tool_source boot).
- Switch role settings in place without controller rebuild (CLI /preset,
  desktop, ACP agent_preset; legacy work_mode/tokenMode dual-write one version).
- Emit turn_phase and completion_summary events; wire desktop/CLI UI.
- Keep skill profiles diagnostic-only; dual-write agent_preset on tabs/branch meta.
- Docs, i18n, golden surfaces, and regression matrix updated.

Verification:
- go test ./internal/boot ./internal/agent ./internal/cli ./internal/acp
  ./internal/control ./internal/skill ./internal/agentpreset ./internal/taskpolicy
- desktop role-setting smoke tests
2026-08-11 09:25:13 +08:00
SivanCola 397ed30ec8 feat(agent): content-driven context maintenance (single compact_ratio) / 内容驱动上下文维护(单一 compact_ratio) (#8244)
* feat(agent): content-driven context maintenance with single compact_ratio

Replace multi-threshold prune/snip/native auto maintenance with one
content-driven checkpoint path (stable prefix + one summary + recent
tail) gated solely by compact_ratio (default 0.85). First-visible tool
results are bounded at creation; prune/snip APIs remain no-ops for
call-site compatibility while legacy config/sidecar fields load and
migrate away.

Also align max_output_tokens auto ladder (0=auto; 128K explicit only),
desktop/settings/docs, and force CompactNow planning so mid-size
strict-alternating sessions (e.g. guardian) still reduce tokens.

Verification: go test ./...; desktop go test ./...; frontend test:all;
cache-guard; race on core packages; live seed→resume→continue smoke
(version=1, one summary, ~$0.0012).

* fix(agent): make context projection durable on Windows and clear CI gates

Problem:
- SaveCompactionState fsynced the parent directory after rename. Windows
  returns ERROR_ACCESS_DENIED for directory Sync, so every projection
  install failed and Windows smoke tests went red.
- gofmt, repolint, frontend AgentView fixtures, and required cache/docs
  PR-body lines also blocked CI for #8244.

Root cause:
- Custom atomic write path reimplemented durability with a directory Sync
  that is not portable, instead of using the shared Windows-safe helper.
- Residual format/debt and a missing coldResumePrune fixture field.

Fix:
- Persist sidecars through fileutil.AtomicWriteFile (temp + file fsync +
  replace) without directory Sync.
- gofmt touched Go files; shorten essay comments; extract fold helpers;
  carry residual structural debt in the repolint baseline after reducing
  new essay debt; restore coldResumePrune on settings fixtures.

Verification:
- gofmt clean; go run ./tools/repolint clean
- go test ./internal/agent/ ./internal/config/ ./internal/provider/...
  ./internal/guardian/ ./internal/cli/
- pnpm settings-refresh-snapshot: 91 passed

* chore: nudge PR head refresh after Windows durability fix

Empty commit so GitHub re-resolves the fork head for #8244 after the
merge of main-v2 and the Windows sidecar write fix.

* fix(agent): close #8244 review blockers for content-driven maintenance

Problem:
Review of PR #8244 requested changes for CI failures, threshold under-count,
legacy native lineage drop, non-atomic CAS/write, residual cold-resume UI, and
duplicate context_maintenance notices.

Root cause:
- Threshold used message-only estimates without tools/role projection.
- Sidecar lineage required exact PromptCacheKey and marked restored before
  covered-hash validation.
- Checkpoint install checked transcript outside the install lock and used
  non-strict atomic write with EXDEV copy fallback.
- coldResumePrune UI/API remained after behavior removal; locale gzip grew.
- Maintenance notices ignored operationId on reconnect/replay.
- golangci reported unused prune/summary helpers and minor modernize issues.

Fix:
- Estimate compact trigger from final request shape (messages + tools +
  provider role projection).
- Normalize legacy |context-editing-native-* keys; restore only if projection
  still validates; persist normalized key without version bump/events.
- CAS install under compactionMu; SaveCompactionState uses AtomicWriteFileStrict.
- Remove coldResumePrune settings UI/Wails field/locales; keep config migration.
- Dedupe context_maintenance notices by operationId (bounded FIFO) + tests.
- Delete unused prune/summary helpers; clear golangci; trim locale strings for
  bundle budget; strip SPEC trailing space.

Verification:
- make lint (golangci + repolint) clean
- go test agent/config/control/guardian/provider
- pnpm frontend build (locale budgets pass)
- context-maintenance-notice + settings-refresh-snapshot tests

* fix(desktop): gofmt reasoning_display fixture after cold-prune removal

Desktop CI failed gofmt on reasoning_display_app.go after ColdResumePrune
was dropped from the AgentView fixture literal.

* fix(agent): unblock context-maintenance merge blockers

Problem: PR #8244 still had three merge blockers: emit under
compactionMu (deadlock risk), restore rewriting exact-key
sidecars, and initial raw bundle over budget.

Root cause: commitSummaryProjection held the lock through
synchronous sink.Emit; LoadProjectionSidecar persisted whenever
the key already matched; ContextPanel/TranscriptSelectionMenu
were eagerly imported into the initial chunk.

Fix:
- Unlock before emitContextMaintenance; reentrant sink test
- needsNormalization only for legacy native lineage rewrite;
  log persist errors instead of ignoring them
- Lazy-load ContextPanel and TranscriptSelectionMenu
- Document pre-interceptor threshold estimate + overflow recovery
- CLI/GOAL docs: compact_ratio default 85%

Verification:
- go test ./internal/agent/ (+ race focused tests)
- make lint (repolint baseline essay bump)
- pnpm build: initial raw 2236.8 / 2240 KiB
- context-maintenance-notice test ok

* fix(agent): close remaining context-maintenance plan gaps

Problem: After the merge blockers landed, plan gaps remained: no
parent-dir fsync on strict checkpoint writes, new writers still
mirrored last_*/blocked_* fields, dead CompactionCapabler shell,
and the pre-interceptor threshold contract lacked a regression test.

Root cause: AtomicWriteFileStrict only fsynced the temp file;
LastReceipt was authoritative but writers still dual-wrote mirrors;
provider.CompactionCapabler had no production callers; Prepare never
invoked interceptors by design without an explicit test.

Fix:
- Strict atomic write: fsync parent directory after rename (Windows/
  unsupported FS soft-fail)
- SaveCompactionState / commit / blocked paths: LastReceipt only;
  strip last_trigger/last_mode/token mirrors and top-level blocked_*
- Restore receipt-only blocked sidecars on load
- ContextReport/CLI/status prefer LastReceipt with legacy fallback
- Delete unused CompactionCapabler/NativeCompaction shell
- TestPrepareThresholdSkipsExtensionInterceptors

Verification:
- go test ./internal/agent ./internal/fileutil ./internal/provider
- go test -race focused packages
- make lint (repolint clean)

* docs: drop removed context_editing example from zh GUIDE

Problem: GUIDE.zh-CN still suggested context_editing = "native"
after native Anthropic context editing was removed and startup
migration deletes that key.

Root cause: bilingual GUIDE config sample was not fully synced
with the single compact_ratio local-summary contract.

Fix: remove the obsolete config line from docs/GUIDE.zh-CN.md.
English GUIDE already had no such example.

Verification: grep docs for remaining context_editing user examples
(only SPEC migration notes remain, which correctly describe removal).

* fix(fileutil): never fail strict write after successful rename

Problem: AtomicWriteFileStrict returned parent-dir fsync errors after
rename had already published the new file. commitSummaryProjection
and blocked-receipt writers treated that as "not committed" and
rolled memory back, forking disk (new generation) from process
memory (old generation) and allowing paid re-summary loops.

Root cause: post-publish power-loss durability was modeled as a
hard failure even though the atomic publish already completed.

Fix:
- After successful rename, parent-dir fsync is best-effort only
- Returned error still means "destination not published"
- syncParentDir also covers relative parents (".")
- Agent tests: applied and blocked receipts survive injected
  post-publish dir-sync faults without generation fork / re-summary
- PR body: document AtomicWriteFileStrict + best-effort dir fsync

Verification:
- go test ./internal/agent ./internal/fileutil
- make lint
2026-08-11 01:30:26 +08:00
esengine d97bf94e75 fix(agent): a tool's own refusal reports as blocked, not as a completed call
A read-only subagent that rejected its own bash command returned the
refusal as ordinary output with a nil error. The agent had no way to see
it: the call was recorded as a success, frontends drew a green check on a
command that never ran, and the storm breaker — which counts blocked
outcomes to detect a model bouncing off the same host refusal — was blind
to it.

The host already has one shape for a refusal, toolOutcome{blocked, errMsg},
produced by the permission, plan-mode, and hook gates. What was missing was
a way for a tool enforcing its own policy to reach it. tool.Blocked carries
that signal out of Execute; executeOne turns it into the standard blocked
outcome, with the not-run shell metadata a blocked bash card already
renders elsewhere.

Both subagent bash wrappers now report through it, so the read-only command
policy and the background-execution rule refuse the same way. The review
and desktop-try registries share the wrapper, so their contract assertions
move with it.

Extracting blockedToolOutcome/shellPreflightExecution and the evidence
receipts keeps execute_one.go inside its ratchet budget.
2026-08-10 09:18:37 -07:00
SivanCola 64fc5ecf59 refactor(goal): split boundary helpers for lint ratchet
Problem: the Goal boundary implementation grew several grandfathered files beyond the repository's repolint ratchet.

Root cause: run-boundary state, sidecar compatibility helpers, evidence novelty logic, and their tests were added directly to legacy owner files.

Fix: extract the new logic into focused same-package files, reduce the affected owner functions, and keep the existing repolint baseline unchanged.

Verification: go run ./tools/repolint; go test ./...; go test -race ./internal/agent ./internal/control; go vet ./...; desktop go test ./...; frontend pnpm build.
2026-08-10 17:38:05 +08:00
SivanColaandWill bfdef5dfdc fix(goal): replace text-derived stalls with run boundaries
Problem:
Goal mode could pause useful research after a text-derived 4/6/10 no-progress streak, while the outer 10/20/40 continuation budget did not bound actual model rounds inside one Run.

Root cause:
Cross-turn novelty was acting as both progress telemetry and a termination proxy. The agent exposed no typed host-owned Run boundary, so the controller could not distinguish default Goal budgets, explicit max_steps, or structural stuck conditions.

Fix:
Add a default 16-round Goal Run budget with one tool-free finalization response, typed resumable pause metadata, inherited child budgets, and Goal-only structural stuck detection. Keep cross-turn novelty observational, migrate legacy no_progress pauses, preserve unknown sidecar fields, and expose requestsUsed across CLI, ACP, and Desktop surfaces.

Verification:
- go test ./...
- go test -race ./internal/agent ./internal/control
- go vet ./...
- cd desktop && go test ./...
- cd desktop/frontend && pnpm build
- live DeepSeek, LongCat, and GLM normal/stuck/16-round-resume matrix

Co-authored-by: Will <[email protected]>
2026-08-10 17:13:11 +08:00
YHH 84c257e56b feat(bench): measure delegation independence and anchor resistance (#8154)
Reasonix isolates a child's session but not its conclusions. The context
capsule records, field by field, what a child inherited from its parent
and every field is false — yet the one channel that actually carries
information, the parent's free-text prompt, is not modelled at all. Two
host-recorded instruments close that gap.

Evidence origin splits what a delegation's own text pointed at into
directory scope, which is the unavoidable cost of handing work off, and
named files, which say where the answer is. The child is credited only
for paths no named file already covered, so a scope hint never erases
its work. The two are counted apart because one number reports the cheap
act as the expensive one — the combined form read 0-3% independence
where the split reads 94-100%.

The -anchor arm hands the agent a hypothesis before it has read
anything: the task's real cause, a plausible wrong one, or none. Tasks
without an authored seed are skipped rather than run as the control, so
a seeded denominator never absorbs a control run.

Measured over 210 runs across two corpora (deepseek-flash, 5 reps each),
anchor resistance is 1.00 on both — including one whose answers no
command can check — while a wrong hypothesis costs ~2x wall and ~1.35x
tokens to unwind. Children discovered 94-100% of their own evidence:
this parent hands scope, not answers.

Also guards two failures found while running it: the bench read metrics
tags the agent had stopped emitting, and a failed grader's output never
reached the record that had to explain the failure.
2026-08-10 14:08:43 +08:00
esengine 1d0c0e7db2 Merge origin/main-v2 into refactor/subagent-spec-split
main-v2 had moved 84 commits ahead. Everything auto-merged except
tools/repolint/baseline.json, which is generated: resolved by taking upstream's
and regenerating, rather than hand-merging JSON.

Regenerating surfaced one real violation instead of a carry-forward:
internal/event/event.go had grown past its budget, so the delegation audit sink
and its dispatcher move into audit_forwarder.go where the rest of that concern
already lives. The only baseline entry above upstream is plugin_entry.go's
essay, which is the field comments carried through the earlier extraction.
2026-08-09 18:19:47 -07:00
esengine e451757bdb refactor(event): forward optional sink capabilities by embedding, not by hand
Nine optional capabilities, repeated at every wrapper in the chain, silently
lost three audits in this branch alone: control's goal-usage tee, the stats
recorder, and the nesting sink fleet items run under. Each time the data simply
stopped, each time with the tests green, and each time the fix was to write the
same method one more place.

event.AuditForwarder holds the nine forwards once. A pure forwarder embeds it
and is complete by construction, so the next capability added there reaches
every embedder without editing them. goalUsageTee, notify.Sink, telemetry's
sink and the nesting sink now embed it, deleting roughly a hundred lines of
identical methods; the tee had been forwarding eight of nine, and the nesting
sink three of nine, before this.

Wrappers that do real work per channel — the coalescer flushes first, stats and
trajectory record, run metrics consumes — keep their own methods. Embedding is
for the ones that only pass things through.

The guard now asserts the forwarder itself covers every capability, which is
the property that makes the wrapper-level guard mostly redundant rather than
load-bearing. Verified end to end against the provider: a delegated run still
reports its child.
2026-08-09 17:57:52 -07:00
esengine 034dc7e1e7 feat(bench): delegation-shaped tasks, and count the fleet children they revealed
Four tasks: two whose structure rewards delegation with a neutral prompt, and
two forced twins that differ only in telling the agent to delegate. Same
workdir, same checks, so the pair prices delegation on identical work instead of
comparing different work. Both seeds were confirmed to fail unfixed and pass
fixed before either was used.

The existing subagent-delegation task mandates delegation in its prompt, which
tests the plumbing rather than the value: a single-agent arm cannot comply, so
it loses by the rules rather than on the merits. The neutral tasks exist so an
arm that declines to delegate is only penalised by the clock.

Running them found the instrument under-counting. A fleet item runs under a
sink built by subSinkFor, which was a bare event.FuncSink; optional sink
capabilities are forwarded by method, so every delegation audit a fleet or
parallel_tasks child produced was swallowed at the nesting boundary. The forced
fleet task burned 3.8x the tokens of the neutral one and still reported zero
child runs. subSinkFor now returns a type that forwards the audits, and the
same task reports 3 child runs doing 71% of the tool calls.

This is the third instance of one contract implemented at many wrappers and
missed at one. The guard added earlier covers the session chain but not this
nesting sink, which is a fair argument that the pattern wants a shared embedded
forwarder rather than more guards.
2026-08-09 17:03:48 -07:00
SivanCola 87b7d130f3 fix(context): make maintenance projection-idempotent
Problem:
Long sessions could repeatedly snip the same canonical tool results, retry failed summaries, and report misleading context state.

Root cause:
Preflight and post-turn compaction evolved separate views, while pruning rebuilt from canonical history instead of the installed projection. Failed view fingerprints and maintenance outcomes were not durable.

Fix:
Make ContextManager the single request-time owner, evolve projection-only views with CAS and v3 receipts, persist blocked fingerprints, add Anthropic native tool clearing capability gates, and expose current context composition separately from summary-call cost.

Verification:
- go test ./...
- go test -race ./internal/agent ./internal/provider/anthropic ./internal/control
- go vet ./...
- ./scripts/cache-guard.sh
- cd desktop/frontend && pnpm typecheck
- focused Desktop and frontend context contract tests
- go run ./tools/repolint
2026-08-10 05:14:03 +08:00
esengine 73fee3aa0a feat(cli): record per-run delegation counters so arms can be compared
Eight commits of orchestration machinery have landed on this branch without
anything measuring whether delegation pays. More agents always cost more
tokens, and the extra tokens alone can look like an improvement, so the
comparison has to hold the model fixed and read host-recorded facts.

Each finished child now emits a DelegationAudit through the same optional-sink
path the readiness audit uses, and run metrics fold it into counters: how many
children ran and at what depth, how many mutations they made, how many files
two children both touched, how many runs ended in a checkable claim versus
prose, how many claims the host refused to back, and how many writes escaped a
declared scope.

false_completions is the counter that matters. It comes from the completion
adjudication, so it measures claims the host refused rather than a reviewer's
opinion — the difference between "the fleet finished faster" and "the fleet
said it finished".

Emitting from one place is only possible because child construction converged
on RunProfileSpec; before that this would have needed the same emission in
five paths, and would have been missed in some.

Two things were deliberately not added rather than approximated. The audit
records the adjudicated status and the count of refused criteria, not an
inferred "claimed" status the host never independently observed. And the
counter for child tool calls is the existing SubagentToolCalls, counted from
tool events, instead of a second field counting receipts under the same name.

Task success rate and rework-after-handoff stay unmeasured: they need a task
corpus with checkers and whole-run mutation ordering, which belong to the
harness driving the arms, not to the instrument recording one run. SPEC 3.17
states what is and is not measured, and that --ablate subagent is the existing
control axis for the single-agent arm.
2026-08-09 10:01:12 -07:00
esengine a6804d3afa refactor(agent): fold read_only_task into the one child-construction path
Five entry points were building sub-agents outside TaskTool.RunProfileSpec:
read_only_task, boot's two skill runners, reasonix review, and desktop preview.
Each re-resolved tools, depth, and permissions on its own, which is how a
boundary added in one place gets missed in another — the same shape as the
preview path that once built unconfined file tools and the profile editor that
once dropped read-only on save. It already bit this branch: boot's writer skill
runner receives complete_subtask in its registry but not the closing contract,
because the contract is appended on the TaskTool path it bypasses.

read_only_task, the one fork inside package agent, now compiles to a spec and
runs through RunProfileSpec like every other in-package entry point. Its promise
of no durable side effects is preserved as ContextRequest.Ephemeral — a field on
the spec rather than a second construction path — so the convergence carries no
behaviour change, which is the only safe way to do this kind of refactor.

spawn_boundary_test.go enumerates the files still calling the low-level runners
and fails on any new one, with the fix spelled out in the failure. The three
remaining callers are recorded as known debt rather than precedent; folding them
in touches boot, cli, and the desktop module and belongs in its own change.

SPEC 3.15 states the rule: many APIs, one execution primitive.
2026-08-09 09:42:03 -07:00
esengine 85cacad3b1 feat(agent): record an explicit context capsule for every sub-agent run
What a child sees was previously answerable only by reading the delegation
path. Investigating it turned up a fact worth stating outright: a child sees no
standing instructions at all. Its system prompt is DefaultTaskSystemPrompt or
the profile body and nothing is composed into it, the memory queue is disabled
so it cannot even record memory, and the parent conversation, Goal, planner
output, and sibling results are never passed. Isolation is total, not partial.

Each run now records a ContextCapsule in its transcript sidecar: workspace,
system-prompt source and hash, resolved tool scope and schema hash, model and
effort, parent session and tool-call id, any resumed transcript, and an
inherited block whose fields are all false. capsuleHash is its stable identity,
so "why did this reviewer not see that constraint" is answered from the record
and two runs that behaved differently can be diffed. The capsule holds
references and digests only — copying parent context would cost tokens and
break the child prefix, which is the thing isolation was protecting.

The spec member that says what a child should start from is renamed
ContextRequest, freeing ContextCapsule for the resolved manifest. metaFromSpec
moves next to the capsule it now builds.

SPEC 3.13 tabulates what is and is not inherited. The non-inheritance is
asserted in a test, so a future change that starts inheriting something has to
flip the flag and update the table in the same commit.
2026-08-09 09:18:17 -07:00
esengine 27ba95010f feat(agent): close sub-agent runs with a host-adjudicated completion claim
A delegated run used to end in prose, which is not an interface a parent can
verify. Writer sub-agents now carry complete_subtask: they submit a status, a
summary, the acceptance criteria they were held to with the command or paths
proving each, and whatever they left unresolved.

The submitted status is a claim, not a verdict. The host checks every citation
against its own ledger before the parent sees it — a verification criterion
must name a command the host recorded as run, diff/files must name paths it
observed touched, and a manual note never backs itself. Unbacked criteria are
lowered to unsatisfied, a report holding one cannot stay complete, and each
downgrade is printed with its reason. The host only ever lowers.

The mechanism mirrors review_report exactly: a subagent-only tool whose call
args are the receipt, adjudicated from the same evidence ledger, so the two
completion contracts stay one design rather than two. complete_subtask joins
complete_step and todo_write in the receipts the parent ledger does not
inherit, since the claim belongs to the child's run.

Sub-agent answers are now composed as adjudicated claim, child prose, host
receipts. SPEC 3.11 documents the contract; the write-claim section moves to
3.12.

Cache note: writer sub-agent registries gain one tool and their task prompt
gains the closing contract, so child prefixes change once. The parent prefix
is untouched.
2026-08-09 09:03:25 -07:00
esengine 16b6cbd9c0 feat(agent): host-authored subagent receipts and a typed delegation spec
Three related changes to the sub-agent delegation path.

ProfileExecSpec splits into TaskSpec / WorkerSpec / CapabilityGrant /
ContextCapsule / SchedulerPolicy, so a new field lands in the member that
decides its value rather than whichever one is nearest the call site. The
CapabilityGrant doc states the ceiling-intersection invariant that only
IntersectToolLists implemented before. No behaviour change; tool schemas and
the provider-visible prefix are untouched.

evidence.Receipt now carries the exit status the shell runner actually
observed and the host's verification classification, which were captured in
tool.ShellExecution but dropped before reaching the ledger. Every sub-agent
answer ends with a host-recorded attestation of what it changed and which
commands it ran, attached at RunSubAgentWithSession so task, read_only_task,
fleet, parallel_tasks, background jobs and the boot skill runners all inherit
it. Only mutations and verification/failed commands are rendered, so read-only
research children stay exactly as cheap as before.

A declared write_paths claim is audited after the run: mutations the host
recorded outside the claim are reported to the parent instead of passing
silently. SPEC 3.11 documents every enforcement surface and states plainly
that an omitted claim is a scheduling boundary only, and that declaring paths
costs bash where the OS sandbox cannot enforce write roots. Adversarial tests
pin symlink escape, parent traversal, move_file destinations, unbindable
writers, and the deliberate absence of intra-workspace enforcement for
undeclared writers.

evidence.Receipt moves to its own file: evidence.go had no headroom under the
repolint ceiling, and at 2896 lines the split was overdue regardless.
2026-08-09 08:47:05 -07:00
SivanCola 3d011f9e73 merge: integrate latest main-v2
Problem:
The PR branch was behind the current main-v2 base and GitHub reported merge conflicts.

Root cause:
Main-v2 advanced with contextual tool isolation after the previous integration merge.

Fix:
Merge the latest base, preserve both contextual tool identity and subagent image-candidate propagation, and regenerate the repolint baseline from the combined tree.

Verification:
- go test ./internal/agent ./internal/control ./internal/boot ./internal/jobs ./internal/memory ./internal/tool/...
- go test ./internal/boot -run TestBuildDeepSeekTextParentCanUseImageReturningMCPAndVisionSubagent -count=1 -v
- go run ./tools/repolint
- git diff --cached --check
2026-08-09 10:23:01 +08:00
SivanCola 0ef9de3029 merge: integrate latest main-v2
Bring the DeepSeek provider branch onto origin/main-v2 at 118d10c44. Resolve the repolint baseline conflict by regenerating the baseline against the merged tree. Preserve the provider migration, image-routing, settings, and runtime-rebuild behavior while adopting the current goal, session durability, submission-correlation, and desktop runtime changes from main-v2.

Verification: git diff --cached --check; focused DeepSeek provider upgrade and removal tests; focused SettingsPanel refresh snapshot test.
2026-08-09 10:00:15 +08:00
SivanCola a6238139c4 fix(goal): harden contextual tool and migration boundaries
Problem:
Context-dependent tools could be called outside their owning Goal, Plan, Jobs, or child-agent context, and failed legacy AutoResearch migration lost its retry identity after restart.

Root cause:
Availability was enforced by scattered execution checks while provider-visible registration, host metadata, mixed batches, and inherited child context used different state. The Goal sidecar writer also fenced every new write without preserving a pending legacy task id.

Fix:
Add a shared ContextualTool execution contract with one-repair run-loop handling, isolate child Goal/Jobs/memory state, project contextual schemas only for host metadata, and retain pending legacy task ids until a Goal-only sidecar is durably committed.

Verification:
go test ./... -count=1
cd desktop && go test ./... -count=1
go test -race ./internal/control ./internal/agent ./internal/tool ./internal/tool/builtin ./internal/jobs ./internal/memory -count=1
go vet ./...
golangci-lint run --timeout=5m
scripts/cache-guard.sh
git diff --check
2026-08-09 09:10:28 +08:00
SivanCola f2929dc4cd fix: preserve the static provider tool contract
Problem: The PR made provider-visible schemas conditional on Goal, Plan, and Jobs context, removing update_goal from ordinary and economy requests and violating the required byte-stable tool contract.

Root cause: A broad contextual-tool mechanism was added while hardening Goal recorder isolation, coupling an execution boundary to provider schema selection.

Fix: Restore static Schemas() requests and the main-v2 tool order, remove contextual schema APIs and phase-specific visibility, and keep only execution-time Goal recorder isolation for planners and child agents. Correct the changelog and cache-impact coverage.

Verification: go test ./internal/agent ./internal/boot ./internal/control ./internal/tool ./internal/tool/builtin -count=1; go run ./tools/repolint; git diff --exit-code origin/main-v2 -- internal/agent/extensions.go internal/agent/run_loop.go internal/agent/sampling_request.go internal/tool/tool.go internal/tool/builtin/bgjobs.go internal/tool/builtin/completestep.go internal/tool/builtin/updategoal.go internal/jobs/jobs.go internal/memory/queue.go; git diff --check
2026-08-09 05:49:54 +08:00
SivanCola e08ffbf4a2 refactor: isolate contextual workflow surfaces
Problem: The Goal/runtime fixes added lines and complexity to several files already at their repolint debt ceilings, causing the repository gate to fail after merging the latest baseline.

Root cause: Contextual tool visibility, legacy durability helpers, and their tests were implemented inline in large owner files instead of dedicated modules.

Fix: Extract workflow context, planner registry, subagent identity, Goal durability, Jobs context, CLI Goal handling, and focused tests into scoped files. Keep provider behavior unchanged and add the new schema-bearing files to cache-impact coverage.

Verification: go test ./internal/agent ./internal/boot ./internal/control ./internal/jobs ./internal/tool/builtin -count=1; go run ./tools/repolint; git diff --check
2026-08-09 05:32:30 +08:00
SivanCola bf47eedce3 fix(goal): restore contextual tool and migration contracts
Problem:
A concurrent branch reconciliation kept static provider schemas and moved legacy recovery state outside the Goal machine, diverging from the approved #7959 behavior after the latest-base merge.

Root cause:
The merge resolved overlapping Goal, Jobs, child-context, and tool-registry owner files in favor of an alternative execution-only isolation design.

Fix:
Restore ContextualTool provider visibility, bounded mixed-call repair, transactional retryable legacy migration, downgrade fencing, contextual metadata, and child Goal/Jobs/memory isolation on top of the merged base. Keep the portable Windows archive-read fixture.

Verification:
go test ./... -count=1
go test -race ./internal/control ./internal/agent ./internal/jobs ./internal/tool ./internal/tool/builtin ./internal/memory ./internal/autoresearch -count=1
go vet ./...
golangci-lint run --timeout=5m
cd desktop && go test ./... -count=1
scripts/cache-guard.sh
scripts/check-cache-impact.sh
go run ./tools/repolint
git diff --check
2026-08-09 04:20:57 +08:00