Commit Graph
106 Commits
Author SHA1 Message Date
yhh 8a6a4f20d6 feat: modernize studio interface and session recovery 2026-09-20 07:20:45 -07:00
esengine 15efab5476 feat(turn): control owns the authored turn boundary across planner and executor runs
A turn had as many starts as it had model runs. The coordinator opened one
before planning, the executor opened another before working, and a plan
approved mid-turn opened a third — so "a turn began" meant "an agent run
began", and the only thing telling the two apart was whether the event
happened to carry an identity.

The route that made this worse than untidy is production-reachable. When
the planner concludes no changes are needed, no executor ever runs: the
user's message is written straight into the transcript by the coordinator,
and the turn's only start was the bare one from before planning. So a
durable authored message existed that the live stream had never named —
the row got no rewind entry until the session was reopened and the
transcript rebuilt. Two truths about the same message, which is the thing
this series exists to remove.

Control now opens the boundary. It already had both numbers before any
work could produce the message: the index it opens the checkpoint at and
marks the turn in flight with, and the raw authored text it puts in the
context itself. It publishes them as one TurnStarted before the runner is
called, and hands the runner the identity to land on. Four seams take it:
the orchestrated turn, the headless one, a synthetic continuation (which
carries ownership with no identity, because it belongs to a turn already
announced), and the slash-invoked subagent skill — that last one was a
fourth start emitter and a third place that appended the user's message,
found while doing the work rather than in the preflight that preceded it.

Identity is the host's; representation stays with whoever writes the
message. A plan-and-execute turn still persists the handoff as Content
while the user's own words stay in RawContent, and that distinction is
worth keeping. What the seam takes away is the freedom to disagree about
which message the turn was about: the raw authored text comes from the
published identity, and the landing is checked against it. The check
detects and cannot repair — the name is already out — so a break is an
operator notice with a code, and the durable index stays the authority a
reload reads.

persistExecutorNoOp lands through that seam now rather than composing its
own message beside it. It had been setting RawContent empty whenever
nothing wrapped the input, which left the classification reading a
different field on that path than on every other; it agreed with the
others by coincidence, and the coincidence is now a contract.

Suppression is declared, not observed. A run reads a host boundary out of
its context; nothing infers that it has been announced because an event
already reached the sink. A standalone Agent.Run is still its own
lifecycle owner and still announces — that contract is not collateral to
a desktop fix.

The coordinator's filter on planner lifecycle events is NOT dead and was
kept. Under a host boundary the planner announces nothing, but standalone
it still opens a turn over its own session, and that name would reach the
parent as a second answer to which message the turn is about. A test runs
both ways rather than a grep deciding it.

Six production routes are now a matrix rather than an observation:
executor-only, plan-and-execute, conclude-no-changes, an approval the user
denied, planner failure falling back to the executor, and the subagent
skill. Each asserts one named start, no bare one, one end, that the start
precedes every visible piece of work, and that the message it named
classifies as the authored turn it said. Each also asserts whether the
executor ran, so a route that quietly took another branch cannot satisfy
the matrix. The negative control holds the other end: an approved plan's
execution opens neither a start nor a done of its own.

Two deliberate behavior changes come with the ownership move. The text
renderer resets its per-stream state on a phase marker instead of on a
second turn start, so the executor's thinking still gets its own header
now that planner and executor are one turn; a byte-exact dual-model
fixture pins that boundary. And telemetry's turn count changes meaning: it
counted starts, so a two-model turn counted twice and an approved plan
counted again. It now counts authored turns, which is what the metric was
always named after.

Not in this cut: which items belong to a turn, and where an execution
episode begins inside one.
2026-09-07 09:41:16 -07:00
esengine d9e9e4328f feat(turn): live turn start names the authored message it is about
turn_started carried nothing, so a client that mints its own user row had
no way to learn which message the kernel had just written for it. The row
and the snapshot taken for it were paired by position and confirmed by
prompt text — which is why two identical prompts could only be told apart
by luck, and why a steer sitting in the transcript shifted every pairing
after it.

The event now carries the message's provenance: AuthoredTurn, the number
the conversation gives the turn this message opens, and MsgIndex, the
session index it takes. They are two namespaces and neither is the
checkpoint's Turn, which numbers snapshots — the first authored turn is 1
while the first checkpoint turn is 0, and TestCheckpointTurnIsNotTheAuthored-
Turn reads both off one real run so an implementation that sourced one from
the other cannot pass.

One rule answers both projections. ClassifyTurn is what the display-index
sidecar already counted a transcript with, now named and exported, and the
live path asks it the same question about the message it is about to
append. The message is composed before the turn is announced so the
announcement can classify what actually goes out rather than a stand-in
for it, and the prior count is re-derived from the transcript rather than
tracked beside it — a second counter is a second thing to be wrong. A turn
that opens no authored message (a steer, a host-injected turn) names none
rather than minting a number.

The join then goes structural on both sides. checkpoint.Meta carries the
boundary it was opened at, /checkpoints and /history publish it, and
pairCheckpoints is a lookup on that index — no position, no prompt
equality. A row that names no message gets no rewind entry at all, which
is the right refusal: a rewind cannot be undone, and an entry against the
wrong turn throws away work nobody asked to lose.

The client joins by causation, not by adjacency: a send is remembered
until the kernel names a message for it, in the order the sends were made,
and a name that no longer names a row — taken back, or replaced by a
rebuild — is dropped rather than handed to the next turn. Guidance queued
mid-turn never joins that line, because it starts no turn of its own.

Six proofs, at the three boundaries: the agent alone names its turn (that
layer cannot import control, so no checkpoint state can reach the number);
the live number and the durable display index agree about the same message;
a steer and a synthetic turn each mint nothing and leave the number for the
next real turn; the checkpoint boundary equals the index the event named;
and at the HTTP boundary two identical prompts pair with their own
snapshots.

Not in this cut: the other eleven item kinds still carry no turn. Which
items belong to a turn, and where an execution episode begins inside one,
is a separate question — this only establishes the boundary it would be
asked about.
2026-09-07 08:19:18 -07:00
yhh 584bf5cdb1 fix(agent): derive the host's step identity, stop storing it
The gate found two notes and one of them was stale. A fold froze the note into
its body; the next fold carried that body's remainder forward, which is what a
body suffix is for — historical material with no canonical counterpart. A step
identity is not that. It is what the host holds now, and a frozen copy outlives
the state it names: the model was shown ids the host had already dropped, and a
restart reproduced them exactly.

So the note is derived for each request and stored nowhere. Owed is recomputed
from the ids the host holds against the history that request actually carries,
which is the judgement withTodoIdentityProjection already made — only against
the wrong thing, at the wrong time, with the answer kept.

todoIdentityShown is deleted rather than redefined. It recorded that some
earlier request showed the ids, which cannot say whether this one can: the same
mistake as validating a projection by a transcript version, a scalar witness
standing in for a fact about content. With the derivation in place there is
nothing for it to answer, and nothing to persist across a restart either.

Both storage paths are gone with it. The compaction producers no longer carry
host state into a body, and the run loop no longer writes the note into the
canonical transcript. What replaces them is one seam:

    modelVisibleHistory()    frozen body + canonical tail
    modelVisibleMessages()   that, plus what the host derives for this request

A fold plans on the history. The request carries both. So the note can never
become digest input, kept history, a body suffix, coverage, or sidecar
material, and admission still prices it — a note that cannot be folded is not
free, and counting only the foldable part would make every fold look cheaper
than the request it produces.

The gate now holds, and not vacuously: with the ids folded out of history the
request carries exactly one note, after the live tail, naming the ids the host
holds now — and the same on the far side of a process boundary, derived rather
than restored.

Two things this surfaced. The loop fixture ended a round on "the last message is
a tool result", which a tail note makes false; it now looks past host state,
because that note says nothing about whose turn it is. And the gate itself
demanded a note unconditionally, which would have passed a migration that
appended one to every request — it now reports whether the history still showed
the ids, and expects no note when it did.
2026-09-03 22:34:45 -07:00
yhh 1f48317164 feat(cache): say whether the body a request carried is the one it sent before
PrefixShape hashes the system prompt and the tool schemas. The conversation is
not in it, so a full miss on a prefix the host itself hashed as unchanged had
no attribution at all: the diagnostic could not tell a dropped cache from a
message the host rewrote behind the boundary. session_types.ts already claimed
the hash "tells changed from the endpoint dropped it" — with the body
unobserved, it could not.

BodyChain hashes the provider-visible messages cumulatively, one entry per
message, so two requests compare at the length of the shorter and a rewrite
anywhere in the carried region moves every entry after it. It is taken from the
request that goes out, not from the conversation: a local-only message never
reaches the provider, and reporting a change to one as a cache change is the
mistake CompareShape's own comment already warns about.

A rewrite the session declared is already named through contentReasons. One it
did not declare gets "body_unreported" — the case this observation exists for,
because nothing else can see it.

Measured over 21 sessions before this: 118 full misses on an unchanged prefix,
10.6M prompt tokens re-paid, with cacheDiagnostics reporting prefixChanged
false on every one and nothing able to say who dropped them.

The desktop panel gets the second pill beside "前缀未变", so the answer reaches
the person who can act on it. event.go was at its 800-line ceiling and
session.ts at exactly 800, so CacheDiagnostics moves to its own file and the
metrics fold joins the usage helpers it belongs with — both lower a budget
rather than widen one.
2026-09-03 00:05:21 -07:00
esengine 0382481ea9 refactor(agent): lift two host-message steps out of runToolLoop
The boundary classification and the identity re-projection both landed
inside the loop body and pushed it past its size limit. Neither is loop
control: one settles what changed the conversation since the last request,
the other states a fact about the response that just ended.
2026-08-30 17:32:33 -07:00
esengine 3105838461 fix(agent): project host-owned step ids where the model can read them
complete_step calls step_id PREFERRED, and the host has held one for every
item since plan projection. The model was never shown one. todoState never
rides in the prompt, todo_write answered with counts alone, and all three
list renderers printed bare ordinals — so the only place an id ever appeared
was the model's own todo_write arguments. A fold takes that out of view and
the id is gone from the conversation while the host still holds it.

What the model then cites is the ordinal it was shown, which is exactly the
citation that goes stale when a replan inserts a step above it.

evidence.TodoCitation is now the single rendering, used by todo_write's
result, complete_step's inventory, and both incomplete-todo prompts.

The fold carries the ids in its own output rather than leaving them to the
round after it. The request a fold feeds is frozen before that round exists,
so a sign-off sent in it could still only cite an ordinal — the same failure
this change exists to remove, reproduced once per compaction. A fold whose
kept tail still shows the ids appends nothing. Rewind and snip go through
Session.Rewrite instead and are re-projected at the next turn tail.

Compaction never calls Session.Rewrite — it installs a projection and leaves
the canonical transcript alone — so a rewrite-reason trigger would have
tested green against a signal the real fold path never emits.

Not covered, and separate: rebuildTodoState derives canonical todos from the
transcript's todo_write calls, so a resume after a fold that dropped them
starts with no canonical list at all. The identity does not survive there to
be projected.
2026-08-30 08:07:42 -07:00
esengine 7d4d582e53 fix(agent): classify the response boundary before committing a turn
A response cut at the output limit reached the model as "invalid arguments
for bash; it requires "command"". The host knew the cause — finish_reason
was length — but finishReasonMessage only ever emitted it to the sink, so
the model saw a downstream symptom and attributed it to its own formatting.
The lesson a real session drew from this was to stop batching independent
calls, which is the opposite of what the tree asks for and costs a round
per call.

classifyResponseBoundary runs before the assistant turn reaches the
conversation. A truncation can only have cut the last call, so that is the
only one it drops, and only when its arguments do not parse: earlier calls
were emitted whole and still execute, and a cut that landed after the last
call closed drops nothing. The dropped call neither runs nor enters history,
and the reason rides the turn tail as a host fact.

stopUnexecutedBoundaryCalls is not this: it fires on the round budget and
never reads the usage it is handed, and by the time it runs the assistant
turn is already committed.

malformedArgumentsDetail now quotes the bytes the parser stopped on with a
caret under them. An offset into a payload the model cannot see is what got
'>' blamed for a missing delimiter.
2026-08-30 07:59:54 -07:00
esengine c963ebc84c feat(plan): make the plan lifecycle a host-owned state with authority epochs
The plan workflow was an atomic bool carrying two questions at once — is the
user in the workflow, and what may run right now. A bool cannot say
AwaitingApproval, and it cannot say that approving grants capability to the
work that follows rather than to whatever was already in flight: flipping it
admitted every tool call the model had streamed while planning, retroactively.

planmode.Runtime holds one canonical State{Phase, Epoch}. Active is derived, not
stored, so there is no second field to keep in step. Every accepted transition
raises the epoch — Submit and Revise included, because each changes which
authority is current, and deciding case by case which one "really" counts is how
stale work finds a way back in.

Transitions compare against the state the caller last saw. An approval answered
after a revise finds Planning@N+1 where it expected AwaitingApproval@N and does
nothing, so a late click is harmless rather than merely unlikely.

Each provider round is stamped with the state it was sampled under, before
sampling rather than at execution: what matters is the authority the model
produced the call under, which the state at execution time cannot report. A call
whose origin epoch is no longer current is refused as stale, and told so, since
the reason is what sends the model to the current state instead of a retry. The
admission the gate records carries its epoch too, so the execution assertion
checks freshness — it still re-classifies nothing.

ResolvePlanDecision becomes the transition surface it already described:
revise and exit move the lifecycle before the waiting turn is answered, while
start stays with that turn, which holds the state it submitted and is the only
party that can tell an approval from a late one.

status.plan stays a bool. It is now a projection of the lifecycle for wire and
UI compatibility — true while the marker rides the turn, false once a plan is
approved, exactly as the flag behaved. Nothing in the kernel reads it.
2026-08-28 23:23:35 -07:00
esengine 8ae4fc4c29 feat(jobs): a finished background job wakes the session that owns it
The execution layer was already asynchronous; the agent's control flow was
not. A background job ran to completion, queued a sentence, and then waited
for the user to say something before the model could learn it had finished.
The only way for a turn to act on its own background work was to poll it with
wait, which is what #7948 reports.

Nothing here builds a second runtime for that. The session inbox already is
one: durable, idempotent, crash-recoverable, with a level-triggered dispatcher
that admits work only when the session is idle, and a steer path that consumes
guidance at the run loop's model-round boundary. What was missing was one wire
between the job table and it.

So jobs publishes a CompletionEvent -- an identity, not a sentence:
completion/<job-id>/terminal-v1 is derived from the job, so a delivery path
that runs twice can tell a replay from a second completion. The controller
takes it and translates it into a host continuation on the queue it already
owns. Nothing under the frontend learns what an LLM is: returning nil from the
observer means the event reached durable storage, never that a model read it.

The observer fires after close(j.done), not with the note. recordCompletion
stays where it was for the reason its comment gives -- an observed terminal
status has to imply the note is queued -- but a wake-up is different in kind:
it can start a turn whose first question is about this job, and a turn that
can still observe Running has been told a lie.

Which delivery a completion gets is never decided by reading whether a turn is
running. The turn can end in the gap between that read and the enqueue. The
steer attempt itself reports which world it is in: taken means the live turn
has it, refused converts the item to a follow-up and kicks the dispatcher.

Both notification paths survive, and exactly one delivers. A completion the
observer took is acknowledged out of the manager's own queue; one it could not
take stays there and rides the next real user turn. A session with no durable
inbox (headless run, e2ebench) degrades to the old path rather than failing.

Host attribution now travels with the item instead of with the call site.
PromptOrigin lives in the manifest, not the envelope -- one place to read, and
nothing for UpdateItem to fork when it rewrites a blob. A host item steers
through SteerHostItem, runs synthetic when it degrades to a follow-up, keeps
its attribution when an abnormal exit records it unapplied, and is named as a
background follow-on in the queue panel rather than as a line the user typed.

Invariants locked by tests, each verified by making it fail:
  - the observer runs before terminal publication -> status reads "running"
  - the host dispatch is dropped            -> the model is told the user spoke
  - the legacy note is not acknowledged     -> the next turn hears it twice
Plus an end-to-end over the real jobs.Manager and Controller: a background
job finishing is enough to give the session a turn, with nothing left queued.

Known gaps, both deliberately out of scope:
  - A host follow-up persists as a user-role message, so previews, titles and
    user-turn counts still read it as the user's. Fixing that wants a
    structured marker on provider.Message, which also retires the 17-prefix
    SyntheticUserPrefixes table -- a change of its own.
  - The jobs -> inbox handoff is not yet crash-safe: a process that dies
    between terminal metadata and the enqueue loses the wake-up. The
    deterministic event id makes redelivery free, so reconciliation at bind is
    a separate, small change.

Cache-impact: none -- the system-prompt prefix is untouched; a continuation
rides the turn tail like every other host notice.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
Documentation-impact: none -- no docs/*.md describes the completion note path.
Repolint: no budget widened; agent.go 1617->1597, controller.go 5056->5041,
jobs.go 1174->1141 as the moved code lands where it belongs.
2026-08-28 07:11:16 -07:00
yhh e44b3871d2 fix(agent): a turn's check baseline is captured before its tools run, not by them
checkContract carried a lazy write: the first caller to ask what the task owed
captured the baseline the task is held to. Every parallel tool call asks as it
finishes, so that first ask arrives on several goroutines at once — -race
reports the read at final_readiness.go against the write beside it, through
obligations -> finishToolExecution -> executeOne -> runParallel.

Two baselines could overwrite each other, and the baseline is what decides
whether the turn's delivery claim stands.

The capture moves to beginRunTurn, where nothing else is running, and the ask
becomes a pure read. That is also the stronger reading of its own rule: the
baseline must come from the pre-image, before any call could rewrite the
declaration it is taken from. Guarding the write instead would have kept a
mutable field on a path that has no reason to write at all.

TestCancelInsideLargeParallelBatchStopsSchedulingNewTools and
TestBuildRegistersUsableHistoryAndMemoryRetrievalTools both go green under
-race; both failed on it before.
2026-08-28 01:51:32 -07:00
yhh 03a2c8c412 feat(evidence): the host owns provenance, and actions cannot vouch for themselves
Five holes let a turn answer the gate that was auditing it. Each is closed by
the same move: the fact comes from what the host observed, never from what
execution was free to rewrite.

- A `go test ./...` quoted inside a here-doc body satisfied a declared project
  check that never ran. commandSegments split the raw text on separators, so the
  body's lines became segments. SplitOutsideHereDoc drops the body and keeps the
  commands beside it.
- A here-doc cost the whole command its decomposition, so a turn that ran its
  tests next to one read as having skipped them, and `python - <<PY` walked past
  the Delivery gate that refuses `python -c`. The shell tables now read the same
  fact permission already reads.
- `cd backend && go test ./...` shared an identity with the root run, so a green
  root suite cleared a red subdirectory one. Canonicalisation now drops spelling
  and plumbing, never the context a segment before the check decides.
- An unclassified receipt took the pipeline's exit status for the check's, so
  `go test ./... | tail` could clear the failure it was hiding.
- missingMutation was wired end to end and never incremented. An unprovable
  change is now a debt only observation settles; a check that runs afterwards
  speaks for the state, not for the effect.

Obligations are derived by diffing the ledger, never announced: nothing can emit
or erase its own debt, and the delta reaches the model with the result that made
it. Delivery stops refusing opaque interpreters — what the host cannot audit is
recorded unproven and owed, not blocked at the door.

Criteria the task began under are the host's. Declared checks keep a captured
baseline the current declaration cannot retract; test criteria are captured from
the pre-image before any call that could overwrite them, and evaluated through
`go test -overlay` against the code that replaced them. A rewritten test passing
answers for itself, not for the bytes it replaced.

Also: the credentials error names the file it reads instead of an env var it
never consults, the MCP rail shows the way out instead of the endpoint's words,
and run --metrics carries the turn's own verdict beside its outcome.
2026-08-28 00:30:37 -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
esengine 432353ef87 refactor(agent): the loose flags on Agent group by how long they live
Agent carried 15 scalar fields against a ceiling of 12. The ceiling exists
because independent flags multiply into states no type records as legal, and
the fix it blocks is adding one more bool — so two groups come out by lifetime,
which is what the rule asks for.

steerInbox is the queue and the two flags around it. They already shared a
mutex and a lifetime — opened on the way into Run, closed on the way out — and
now share a type, so the transitions are its methods instead of seven call
sites reaching for the lock. take() hands the entry back rather than loading
its body under the lock, which is the one behaviour change and the right way
round: no inbox lock is held across a read.

agentRole is what an agent is when it is built and stays: a planner that may
not execute, an executor told to reach for its tools, a caller that needs
visible final text. That fixed lifetime is what separates them from planMode
and the language settings they were sitting next to, which move between turns.
deliveryProfile looks like one of them and is not — beginRunTurn recomputes it
per turn — so it stays where it is.

Agent is at 12, and the reclaim takes the room with it: baseline drops
struct-state for agent.go entirely, along with the file-size and function-size
the two extractions freed. Nothing was raised. The ratchet also caught the new
file trying to carry two essay findings in, which is what it is for.
2026-08-22 22:44:25 -07:00
esengine a7e2702318 fix(control): a turn that owes todos can act on what it is told to do
The continuation demanded "finish it now" while the completion guard rejects
the one action that phrase describes: a todo_write marking an item done is
refused until a complete_step signs it off. So the model flipped a status, the
guard refused, the list stayed exactly as it was, and the next round repeated
the identical demand. Asking the run to stop changed nothing — the loop reopens
on every user turn — and the stop button was the only way out.

Both the requirement and the continuation now name the three things the guard
lets through: complete_step to close an item (it advances the list, so no
todo_write is needed), a todo_write without an item that should no longer be
done, and conclude_blocked for one that cannot be done as specified.

The stall test also moves off the category list, which stays identical while
the counts inside it change, onto the full requirement state; and a ceiling
ends a run whose gap keeps changing shape without ever shrinking, which no
stall counter can see.
2026-08-21 10:00:26 -07:00
yhh 70928f045a feat(agent): the model can see how much room it has left
We had a lot of compaction machinery and no way for the model to know it
was about to be compacted. Grepping the tree for any figure ever shown to
it returned nothing: the model was always the folded party, never an
informed one. A model that knows it is at 15% writes down the exact path
and the next step before the summary takes them; one that does not gets
folded mid-thought.

Two halves, because they answer different moments.

The push is a tagged fragment appended before the next sampling call, at
75% and again at 92% of the distance to the compaction trigger — the fold
is the event it can still act ahead of, so the figure counts down to that
rather than to the physical window. It rides the turn tail, so the prefix
stays byte-stable; it is edge-triggered, because a notice that re-fired
every step would spend the very window it reports on. The rungs re-arm
when the history is rebuilt.

It hangs in the run loop rather than in control.Compose. A goal turn runs
dozens of tool calls between user turns, and a notice that only fires on a
user turn would never reach the turn that needs it.

The pull is context_budget, for the decision that arrives between the two
rungs: a broad search or a large file the model cannot afford to read.
Measured at the boundary rather than estimated — ToolSchemaTokens in the
golden moves 4362 -> 4509, so the schema costs 147 tokens of prefix, and
SystemHash is unchanged, which is the evidence the base prompt was not
touched.

ContextGeneration lands here because the latch needs it. Compaction
installs a projection and never calls Session.Rewrite, so a latch keyed on
the rewrite counter alone never notices a fold.

Extracting toolCallContext is not drive-by: the one binding line pushed
prepareToolExecution past the 120-line limit, and the repo does not widen
a baseline to land a change. The extraction reclaims budget instead.
2026-08-21 05:54:56 -07:00
yhh f2be0e79ca fix(trajectory): a round's tokens land on the round that billed them
The pane attached a usage report to whichever model round was still open. The
kernel emits usage after StreamAttemptCommit, which is exactly when the open
round is cleared, so every report resolved to nothing and was dropped by a
branch whose two arms were the same value. A trajectory could say how long a
turn took and never what it cost.

Usage now names the attempt that billed it, from runAttempt through event.Event
to the wire, and the pane addresses rounds by that id whether or not they have
settled. A report belonging to no round (compaction, planner) gets its own row
instead of vanishing. On a retry the tokens land on the attempt that committed,
not on the one that was discarded.

Two more frames the pane knew about and never said. stream_attempt was filtered
out of the wire log, so a replayed session lost every model_round row -- the
rows carrying 99% of a turn's clock -- while the port still promised row-for-row
replay. And a discarded round now reports what interrupted it, because time
spent and thrown away is still time spent.

TurnDone has carried Cancelled since it was added and eventwire never mapped
it, so a turn the user stopped and a gateway dropping the connection both read
as one context-canceled string.

state/trajectory.ts had no tests. It has nine now.
2026-08-20 05:57:31 -07:00
esengine b3574e54ad fix(agent): the blocked turn reports too
emitTurnShadows ran only after readiness passed, so the turn stopped at
the gate published no completion summary at all — and that is the turn
whose summary matters: an unverified mutation or a rewritten check is
still outstanding there, while a turn that passed has already answered
for both.

It now reports on that path as well, and says blocked rather than
whatever the contract concluded. The contract reflects what the model
believed it finished; the gate had the last word, and the summary is
the host's account, not the model's.

Live: a delivery turn that renamed a function and skipped verification
now ends `verdict: blocked, mutations: 2, criteria_rewritten:
[TestMedianOdd]` instead of ending silently.
2026-08-19 17:50:29 -07:00
esengine 97d59406e3 refactor(agent): the host stops narrating what it could not read
When a project has not declared its checks, the host cannot tell a runner from
any other command — so a turn that verified itself through its own script came
back with an advisory saying the check went unread, and how to declare one. It
was true, and it was in the way: it fired on every turn that wrote anything in
a project with no declared checks, and the receipt beside it already said the
turn was unverified. The advice was worth saying once; nothing said it once.

The message goes. What it was protecting stays: the same branch still marks the
gap as the host's blind spot rather than the turn's, so a project whose checks
run through an unreadable script is not failed for it. That was always the load
-bearing half, and it is now the only half.

With the notice gone, everything that existed to carry it follows —
ReadinessResult.Advisory, which nothing else has ever read; the three fields on
finalReadinessCheck; the undeclared_checks code and the desktop's mapping for
it; and commandPrograms, which named a command without pasting it. Naming was
the right answer to the message being unreadable; not sending it is a better
one, and the helper has no second caller to justify keeping it.

Cache-impact: none - a removed notice and dead plumbing; no tool schema, prompt
  prefix or provider request is touched
Cache-guard: final_readiness_test.go and taskpolicy_execution_test.go pin the
  branch that still spares the turn; neither reaches a provider
Documentation-impact: none - the advisory was a runtime message the docs never
  quoted, and Reasonix host checks stays documented as the way to declare them
2026-08-19 09:56:37 -07:00
esengine 9227f65956 fix(agent): a command is named in the advisory, not pasted into it
The advisory that says a project has not declared its checks quoted whatever
had just run, with %q, in the middle of a sentence. What ran in the field was a
forty-line here-document, and the sentence carrying it was rendered into a chat
window: newlines collapsed, wrapping mid-token, the reader's own file contents
riding along inside the script body. A shell command is a thing, not a clause.

It is named now, by what it runs, read from the parsed command rather than from
its spelling: the static argv where the command is simple, a compound
statement's leaves where it is not, the reader of a here-document where neither
answers. A command none of them can read goes unnamed rather than guessed at —
"a command ran" is true, and a name assembled by cutting the string would not
reliably be. The name reaches the message as its own field, so a frontend can
render it apart from the sentence and a terminal can print it after one.

The notice carries a code as well, because the sentence was English in a window
whose interface is not. Code has always been documented as the id a frontend
localizes by; nothing rendered notices had ever read it. The desktop card now
does, with the kernel's own words as the fallback for a code nothing maps —
dropping a notice would be worse than printing one in the wrong language.

And it is stamped for the operator, like the assembly notices before it: it
describes how this project is configured, not something said in the
conversation, so it holds its own place instead of scrolling past as a turn.
The same standing fact is true again every turn, so the strip that holds it
collapses repeats rather than growing a row each time.

The receipt already said the turn was unverified; this only ever added how to
make a check count. It still does, in one line a person can read.

Cache-impact: none - notice text and a new notice code; no tool schema, prompt
  prefix or provider request is touched
Cache-guard: internal/agent/command_programs_test.go and final_readiness_test.go
  cover the naming and the advisory contract; neither reaches a provider
Documentation-impact: none - the advisory is a runtime message the docs do not
  quote; what it asks for, Reasonix host checks, is documented already
2026-08-19 09:29:21 -07:00
yhh 1d027b0709 fix(agent): a check the host cannot read is reported, not charged to the turn
Measured against the real model: a bash project whose check is ./check.sh had
its bug fixed, ran the check, watched it pass — and the run exited 1. The
classifier cannot tell a project's runner from a deploy, so it read no check,
and the turn was failed for the host's own blind spot. Anyone driving `-p` from
CI got a failure for work that was done.

Nor did the failure buy anything. The gate reads the writer from this turn, so
the next one — having written nothing — passes with no evidence at all. It
could not collect later what it refused to accept now; it only cost a turn and
a false red.

So the gap now says which kind it is. Nothing ran at all is still the turn's
own miss and still fails. A command that ran and could not be read is an
advisory: it names what went unread and how to declare it, and lets the turn
end. Delivery is untouched, and its stricter static answer still stands.

Print mode dropped every warning, having nowhere but stdout to put one, so the
advisory would have been invisible where it matters most. Warnings now go to
stderr, where the run already reports itself — stdout still carries the answer
alone, and no pipeline changes. That also restores planner fallbacks and folded
user turns to view.

Verified end to end against deepseek-v4-flash: unreadable check → exit 0 with
the warning on stderr and the fix applied; wrote a file and ran nothing → still
exit 1.
2026-08-17 21:12:36 -07:00
esengine a35603549a fix(evidence): a shell's exit status is not the verdict of the check inside it
`bash -l -i -c` sets no pipefail, so `go test ./... | head` exits 0 on a suite
that just printed three failures. Six places read that status as the check's own
answer: the host classifier, the completion report, the readiness floor,
delivery sign-off, the contract's declared checks, and the summary line. Eight
real runs produced 22 verification commands between them; only 10 carried a
status that says anything about the check.

The verdict now comes from what a zero status actually proves.
shellparse.ExitZeroImplies reads it off the AST: both sides of `&&`, only the
last stage of a pipeline, only the last of several `;` statements, neither side
of `||`, and nothing at all behind `&`. A check whose verdict the shell handed
to a later stage is recorded as inconclusive — it neither proves nor refutes, so
it counts as neither. Receipts the host never classified keep reading their own
outcome, which is all a declared command or a replayed session carries.

Two shapes this fixes, both observed live rather than imagined:

- `ls -la && go test ./... 2>&1 | head -100`, run while three tests were red,
  was recorded as a verification that passed.
- `go test -v ./... | tail -20 && git status --short`, run on a green tree
  outside a repository, was recorded as the suite failing.

Verification items key on the check they carry rather than on the wrapper around
it, so re-running one inside a different pipeline supersedes the earlier outcome
instead of opening a second item the fresh run never clears.

A check is also no longer discarded for its company. `go test ./... && gofmt -l
.` carries a readable verdict even though the host cannot prove gofmt leaves the
tree alone: the short-circuit is the same reasoning the admission gate already
applies when it blocks only the maskable mixed shapes. Not adding gofmt to a
read-only table was the point — ruff, prettier and eslint follow it, and the
table never ends. IsDeliveryVerificationCommand keeps the stricter question for
sign-off, where "only verification and read-only work" is what is being asked.

The summary line and the completion receipt now answer together: a contract the
ledger cannot fully vouch for is no longer summarized as complete beside a
receipt listing what stays unproven.

Recovery guidance rides the steer path under its own host prefix. A model told
the user interrupted starts answering someone who never spoke, and cannot tell
that from a real interruption.
2026-08-16 00:05:03 -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 c314676604 fix(agent): stop replaying a request the model simply had nothing to think about
Three defects found by running the real DeepSeek API behind a recording proxy
and reading the trajectories.

A tool-call turn with no reasoning was treated as a lost field and answered
with one exact replay of the whole frozen request. Every run triggered it,
and on a long-context turn it re-sent 29k prompt tokens to recover five
reasoning tokens. Billed thinking tokens with no text mean the value really
was lost in transit; zero billed tokens mean the model stayed silent, which
is ordinary between tool calls. The classifier now separates them, and the
exclusive reasoningObservation states replace a (missing, shouldRetry) pair
that could spell 'not missing but replay it'.

Optional sink capabilities are delivered by type assertion, so CostQuoteSink
- which forwarded Emit alone - silently dropped all ten audit channels one
link before the trajectory recorder. Trajectories recorded zero audits while
the code faithfully emitted them. capabilityContracts is now the single
authority on the set, FanOut forwards them across the split, and the three
hand-written forwarders get the RunBudget channel they had all missed.

update_goal is unreachable in a headless run, yet its 2.2k-char schema shipped
every turn and the model duly called it and was refused. Whole-session
unavailability can leave the schema without churning the cache prefix; only
per-phase visibility must not. GoalTurnsUnreachable draws that line.

A turn that took more than one request billed the sum while its context was
only the last request's, and the input column showed the sum with no hint.

Measured on the same task before and after: 12 rounds instead of 14, 51.4s
instead of 58.8s, zero wasted tokens instead of 29,085, and 14 audit records
where there had been none.
2026-08-15 07:38:00 -07:00
esengine 31d377957e feat(recovery): stop counting to a number the user never picked
Auto recovery and the guardian each stopped the turn on a count. Recovery's
was 3 failures of one operation, 6 per Episode, 3 reviewer rejects, 3 retries
of an already-stopped operation; the guardian's was 3 consecutive denials or
10 in the last 50. Both then interrupted: recovery burned a summarize-only
round and returned "Automatic retries paused. Send continue to start a fresh
attempt", the guardian injected "stop the current approach and request
explicit instructions".

Neither number came from the user, and neither measured what it claimed. Six
failures is a long debugging session in one codebase and a runaway in another.
The reviewer rejecting three proposals means the reviewer disagreed three
times, not that the work is unrecoverable. What the host actually did with
those counts was take the turn away and ask the user to type "continue" —
which is the user's decision arriving as a chore.

What survives is the part that reads the proposal in front of it: Decide still
routes an ordinary mutation to allow and an ambiguous retry of the same failed
operation to the reviewer, and the reviewer's own reject still blocks that call
with its rationale. A repeat now goes back to the reviewer instead of hitting a
ceiling. The guardian still denies, still forces deny on critical risk and on
high risk without authorization; it just no longer stops the turn on top of
that verdict.

Per-operation failure counts stay — the reviewer and the approval card read
them as evidence ("this exact call has failed N times"). They no longer arm
anything.

With this, RouteStop/RouteStopTurn, the Episode budget, StopReason,
RecoveryPauseError, and the finalization round it drove have no producer, so
they go. event.TurnOutcomeRecoveryPaused stays readable: sessions recorded
before this still carry it.

Nothing else bounds a turn by default (agent MaxSteps is 0 unless the user sets
one), so an Auto run that keeps failing now keeps consulting the reviewer until
the user interrupts it. That is the intended trade.
2026-08-15 02:07:20 -07:00
esengine 935e0eb9e5 docs(preset): say what the host does now, not what it did before two removals
Ten declaration comments still describe a three-setting product ("light|
balanced|delivery", "all three role settings") that one setting left, and two
describe an injection that no longer happens: DeliveryRuntimeMarker's doc says
the agent appends it to user turns and calls its text cache-frozen for every
live delivery session, twelve lines above the code that stopped appending it.

A comment that names a mechanism is a claim the reader cannot check without
reading the whole path. Both of these sent the reader looking for a caller
that is not there.
2026-08-15 01:36:28 -07:00
esengine 21638e356c refactor(agent): stop counting rounds, and let the executor decide when to plan
Two ladders came down.

The planner gate was scoring task text out of twelve keyword tables — work,
high-risk, cross-surface, ambiguous, atomic — and buying a second model round
from vocabulary. "migrate" was enough. So was a message over 240 runes, or two
file paths in one sentence. What the tables were reaching for is a judgment the
executor is better placed to make: it reads the same words with the repository
in front of it, and lays out steps with todo_write when a task needs them,
where the delivery gates and the contract can both see them. The gate now
answers only "did the user ask for a plan?" — plan mode, a synthetic turn, a
slash command, or a directive the user stated outright ("先规划再执行", "wait
for my approval"). taskpolicy loses the surface-shape signals that fed it for
the same reason: rune counts and path counts described the sentence, not the
work. Risk stays a host signal, and nothing derives one from wording.

The six per-round counters came down with it. A turn was being watched by the
zero-gain ladder (2/4/6), the storm breaker (3 identical failures, 3 all-blocked
rounds), the repeat-failure and repeat-success blocks (2 each), and the todo
stall checkpoints (8/16) — all reading the same receipts, all reaching for the
same "it looks stuck" from a different angle, and each one able to redirect the
model or refuse its call. run_budget.go already says why this was the wrong
axis: rounds are a poor proxy, and the failures worth stopping are reported in
hours and tokens. Those budgets remain, off by default and user-owned. Stopping
a task stays the user's call.

Removing them retires the loop-guard pass in final readiness too — an escape
hatch whose only job was to release a model the guards had cornered.

The outcome scorer survives as an instrument (internal/agent/outcome_shadow.go):
it still decomposes each round for the bench, and still decides nothing.

Net -3250 lines.

Cache-impact: none - the system-prompt prefix is untouched; the per-turn
<execution-policy> block keeps its shape and only ever carried preset-derived
values, which are unchanged for every preset.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
Documentation-impact: none - no docs/*.md describes the planner scoring or the
round counters; user-facing copy for plan mode and task budgets is unchanged.
2026-08-14 18:48:36 -07:00
esengine b071fef950 refactor(agent): let receipts decide, and retire the word lists that guessed
A turn's obligations were being read off its wording. taskintent held ~1200
lines of CN/EN keyword tables, hand-rolled negation windows, and clause
splitting to answer "is this a mutation request?", and final readiness then
demanded a state change from any sentence that scored one. Its own doc.go
called the vocabulary a liability; boundary_test.go existed to stop it growing.

Readiness now reads the ledger: a turn that changed nothing owes nothing, and
one that did owes the verification, review, and sign-off it already owed. The
contract's intent label follows the same rule -- taskcontract.Kind is observed
from receipts after the fact instead of predicted from text -- and the atomic
contract shape is triggered by a mutation receipt rather than by a verb.

taskpolicy keeps the constraints a user stated outright ("don't modify", "only
run go test"): those restrict, and the user meant them. What goes is the risk
guessing, where "migrate" or "credential" bought a full plan and a forced
review from a topic word rather than from what the turn touches. Risk now
arrives only as host-computed signals.

Also removes machinery that decided nothing in production: the EBM and
reasoning-governor experiments (env-gated, off), the fork-bundle capture that
served them, and the shadow delegation-admission verdict. The per-round
contract replay went with them -- it rebuilt the whole contract from every
receipt on every tool round, quadratic in a long turn, for an audit channel
only the bench implements.

Net -3382 lines. Behavior change: a Delivery turn that claims work without
calling a tool is no longer refused. Catching that belongs to the model's own
claim (update_goal / complete_step), which internal/completion already checks
against the ledger, not to a keyword table.

Cache-impact: none - no tool schema, prompt, or memory text changes; the
per-turn <execution-policy> block rides the turn tail as before and its
route/risk values are derived from the same frozen preset.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
Documentation-impact: none - taskintent and the two experiments were never
user-facing; no docs/*.md describes the removed gates.
2026-08-14 18:24:46 -07:00
YHH 23bdf08130 refactor(agent): separate the collaborators from the state (#8595)
Nineteen fields on Agent were who it talks to, interleaved with what it
remembers. They are now agentServices, reached as a.svc.

This stage is deliberately not framed as a lifetime, because it is not one.
Thirteen of the nineteen are rebound at runtime through the Set* seams the
controller uses between turns, and fork wraps prov mid-run. The axis here is
collaborators versus state, and the doc comment says so rather than implying a
process scope the code does not have.

It pays down no ratchet either: every one of them is an interface or a pointer,
and struct-state counts scalars. What it buys is that Agent's remaining fields
are recognisable as state at a glance — after the four stages it is
agentConfig, svc, sess, task, turn, pending, and a short tail of flags.

New would have crossed the 120-line function limit carrying the literal, so the
binding moves to newAgentServices: adding a collaborator now touches one place
instead of two.

repolint: no entry increases; agent.go essay 62 -> 55 and file-size 2155 ->
2061 as the field docs move to services.go, which adds no findings of its own.

Part of #8523.
2026-08-12 19:59:31 +08: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 c498209054 refactor(agent): type the session lifetime, and the one that outlives it (#8564)
SetSession reset fifteen fields one by one and carried a comment explaining
which of them was deliberately spared. Those fields are now sessionRuntime and
that seam is a.sess.reset(s).

Three corrections came out of checking each candidate against its write sites
rather than its name:

  - workspaceID is written only by New, so it belongs in agentConfig. The
    existing immutability guard now covers it, which matters because it is a
    prompt-cache lineage component: a mid-life change would silently rekey the
    cache.
  - activeTurnCreatedAt is cleared by a defer in Run, so it is turn state and
    stays put for the turn stage.
  - pendingResolveAt was the spared field, and the reason it is spared is that
    it answers to the provider configuration rather than to any conversation.
    It is now its own type, unwrittenResolve, sitting beside the warn state it
    belongs with. missingReasoningWatch is session state again, with no
    exception to explain.

outputBudgetState stops being embedded and becomes a named field that owns
lastUsage, so restarting it is one visible r.output.reset() instead of an
Agent method reaching across two layers.

sessionRuntime holds three mutexes and five atomics, so the whole-value
assignment taskRuntime uses is illegal here — vet's copylocks would reject it.
The "no field is forgotten" property is therefore enforced by
sessionstate_test.go: both lifetime lists are checked against the struct, and
reset's body is read back from source to confirm it touches everything the
reset list claims. A field added to sessionRuntime fails the test until someone
states which side it is on.

Behaviour is unchanged. lastPrefixShape and haveLastPrefixShape survive a
conversation swap exactly as before; that is recorded on the carry-over list
rather than changed here.

repolint: struct-state for agent.go 18 -> 10, repo total 130 -> 122; essay,
file-size, complexity and function-size all fall. No baseline entry increases.

Part of #8523.
2026-08-12 18:26:39 +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
YHH d56096520a refactor(agent): group the recovery gate's identity into one value (#8453)
recoveryAgentID, recoveryTaskID and recoveryRunSeq are read as a unit: both
call sites build a RecoveryObservation whose first three fields are exactly
these, and Agent already exposes them through a setter named
SetRecoveryIdentity. The concept had a name everywhere except in the struct.

recoveryIdentity gives it one, next to the gate it describes. The counter stays
an atomic inside the value, so the identity is assigned field by field rather
than wholesale — an atomic cannot be copied, and nothing here wants to reset it
anyway.

Third bucket under the struct-state ratchet: Agent 52 -> 49 scalars, baseline
tightened to match (agent.go 40 -> 37, total 152 -> 149, file-size 2317 -> 2301
for the 16 lines this removes).
2026-08-12 09:59:35 +08:00
SivanCola f40777ee76 merge: integrate execution-role runtime with continuous Goal
Problem: main-v2 advanced with the execution-role and completion-summary stack while PR #8373 was in CI, leaving the Goal runtime branch conflicted.

Root cause: both changes touch the Desktop and ACP runtime DTO owners and their repolint baselines.

Fix: merge main-v2, preserve the upstream execution-role behavior, retain the additive Goal workDurationMs mappings, and combine the exact repolint baselines.

Verification: git diff --check; go run ./tools/repolint
2026-08-11 21:37:52 +08:00
SivanCola 1713e5a131 Merge latest main-v2 diagnostics and goal budgets
# Conflicts:
#	desktop/frontend/src/locales/zh.ts
#	tools/repolint/baseline.json
2026-08-11 18:22:42 +08:00
SivanCola 6005e9a23d merge: integrate opt-in Goal spend budget from main-v2
Preserve continuous Goal semantics and cumulative runtime statistics while accepting the newly merged, user-configured token budget. Keep structural no-progress guards corrective-only and retain downgrade-safe sidecar migration.
2026-08-11 18:16:03 +08:00
YHH e6d542bc94 feat(goal): replace the guessed turn quota with an opt-in token budget (#8382)
A Goal was bounded by 10/20/40 turns, picked by classifying its own text as
simple, write, or research. That is two guesses stacked: guess how big the
task is, then stop it on the guess. It was also the only axis, so a loop that
was slow and expensive never came close to it while a fast empty one hit it
for no reason.

Codex, which this feature follows, bounds /goal on a token budget that is
optional, and stops with a summary into a budget_limited state rather than
ending the goal. It has no iteration cap. This matches that: cumulative
tokens, off unless [agent].goal_token_budget is set, one summary and a
resumable budget_spend pause on reaching it.

Tokens are the axis that generalizes. A slow expensive loop accumulates them
and so does a fast empty one, since every iteration pays for at least one
prompt; wall clock catches only the first, and money is not portable across
models. #7725 removed token limits for a different reason — it enforced a
class-derived ceiling at every provider boundary, conflating cumulative
autonomous-work safety with per-request context size. This is the cumulative
check at the FSM's continuation decision, which is where the loop actually
decides to keep going.

Resume grants the budget again instead of returning into an exhausted one;
openai/codex#34215 is the same situation without that.

What still stops a Goal, all of it reading content rather than counting:
completion, a blocked report, a failed or uncertain evaluator, the structural
no-progress loop. TurnsLimit stays in the sidecar and the runtime view for
compatibility and is never set; GoalRuntimeView reports the real TokensLimit
again, which #7725 had pinned to zero.

Tests that used the quota as a termination condition now configure a budget
and bill usage against it. Several of them looped forever without that, which
is the shape of the design: an unattended loop with nothing configured has no
host-owned stop, by choice.
2026-08-11 17:54:24 +08:00
SivanCola 0e1932d128 Merge main-v2 into execution settings
Problem:
The upstream main-v2 branch advanced while the execution-setting changes were under CI, leaving PR #8275 behind the live base and conflicting in the generated repolint baseline.

Root cause:
Both branches updated files covered by the baseline, so Git could not choose combined finding counts.

Fix:
Merge the three latest upstream fixes and regenerate the repolint baseline from the combined source tree.

Verification:
- go test ./...
- cd desktop && go test ./...
- go vet ./...
- go test -race ./internal/acp ./internal/control ./internal/agent
- go run ./tools/repolint
- cd desktop/frontend && pnpm test:all
- cd desktop/frontend && pnpm build
2026-08-11 17:40:34 +08:00
SivanCola 2d6d2f2afa merge: reconcile continuous Goal runtime with main-v2
Integrate the newly landed no-Todo-stall and no-Goal-round-ceiling changes from #8347 and #8357 without rewriting the published PR history.

Preserve this PR's broader continuous-runtime behavior: Goal Todo stalls still trigger a second-stage replan and epoch reset, while goal_stuck finalization remains removed.

Verified with full Go tests, agent/control race tests, go vet, repolint, Desktop tests, frontend typecheck/test/build, and cache guards.
2026-08-11 17:38:54 +08:00
YHH 792f5df047 fix(agent): remove the todo-stall pause (#8347)
A stalled todo could end a run: 8 rounds without a new completion, read,
command or mutation got a reassessment nudge, 16 got a resumable pause. The
pause goes; the nudge stays.

It was never the mechanism that caught the stall it claimed. The
zero-evidence ladder escalates on the same receipts and reaches its stop at
6, and the storm breaker reaches its own at 3 — writing the Goal test for
this proved it, since Goal never reached 16 at all: the ladder's structural
pause always fired first. So under Goal it was dead code, and in ordinary
chat it was the last remaining way for the host to end a turn the user never
asked it to end.

What it measured was also narrower than it read. Any first-time successful
read, command or mutation renews the streak, so 16 rounds meant 16 rounds
producing literally nothing new — a state the two guards above already
handle, twice over and far sooner.

todoStallPause and maxTodoStallRounds are gone with it, along with the
todo_stall pause class, its RunPauseInfo mapping, the planner's
non-finalization message for it, and the Goal FSM's stop cause.
2026-08-11 17:24:08 +08:00
SivanCola 1bf365af6e fix(goal): make execution continuous by default
Problem:
Goal mode could pause at host-owned numeric ceilings even while useful work was still progressing.

Root cause:
Default round, turn, wall-time, and stall thresholds were wired as terminal runtime boundaries and persisted as blocked Goal state.

Fix:
Remove host-owned Goal execution ceilings, preserve only user-selected resource limits, convert numeric stall guards into replanning redirects, migrate legacy numeric pauses, and expose cumulative work duration as observational runtime data.

Verification:
- go test ./...
- go test -race ./internal/agent ./internal/control
- go vet ./...
- cd desktop && go test ./...
- cd desktop/frontend && pnpm typecheck && pnpm test:all && pnpm build
- scripts/cache-guard.sh
- scripts/check-cache-impact.sh
- go run ./tools/repolint
2026-08-11 16:02:29 +08:00
SivanCola ec2da98bda Merge remote-tracking branch 'origin/main-v2' into fix/pr-8275-agent-role-settings
# Conflicts:
#	internal/agent/run_loop.go
2026-08-11 14:06:35 +08:00
SivanCola 175455df43 Keep transient routing out of completion receipts / 避免内部能力路由污染完成回执 (#8317)
* fix(agent): keep transient routing out of completion receipts

Problem:
Ordinary turns could surface an unverified completion receipt whose acceptance criterion exposed the internal capability-route block and invented a required-change check.

Root cause:
The agent kept the provider-composed turn as the completion contract input even though the controller already supplied authenticated raw user text separately.

Fix:
Use the raw user input for live and final completion contracts while preserving the composed provider prompt. Add regression coverage for mutation receipts and advisory turns containing capability routing with delivery-like wording.

Verification:
- go test ./internal/agent ./internal/completion ./internal/taskcontract
- go test ./...
- cd desktop && go test ./...
- git diff --check

* fix(agent): preserve trusted task text in completion contracts

Problem:
The first completion-receipt fix used raw user input unconditionally, which did not preserve Goal scope task text or pristine sub-agent classifier text. Its explanatory comment also exceeded repository lint budgets.

Root cause:
Completion contracts and delivery classification selected their task source independently even though both require the same host-trusted text.

Fix:
Store the already-selected trusted classification input as the per-turn contract input. Add regressions for Goal continuations and host-wrapped sub-agents, and remove the lint-expanding comment.

Verification:
- go run ./tools/repolint
- go test -race ./internal/agent -run TestCompletionContract -count=1
- go test ./internal/agent ./internal/completion ./internal/taskcontract
- go vet ./...
- go test ./...
- cd desktop && go test ./...
- git diff --check

* chore(ci): refresh PR metadata checks

Problem:
The documentation-impact field was added after the pull_request_target event was created, and rerunning that workflow reused the old event payload.

Root cause:
GitHub Actions reruns preserve the original pull request metadata snapshot.

Fix:
Create a new synchronize event so metadata guards evaluate the corrected PR body without rewriting published history.

Verification:
- PR body contains Documentation-impact
- local code and test gates passed at b0fb0fbcd
2026-08-11 14:03:53 +08:00
SivanCola 97738e0a9b Merge remote-tracking branch 'origin/main-v2' into fix/pr-8275-agent-role-settings
# Conflicts:
#	internal/boot/effect_test.go
#	tools/repolint/baseline.json
2026-08-11 13:35:05 +08:00
YHH f1b5997b15 feat(agent): gate a task on spend; drop the chat round ceiling (#8320)
* feat(agent): gate a task on spend; drop the chat round ceiling

The gate moves to the axes a runaway is actually reported in. A task that
reaches its wall-clock or cost budget gets one tool-free summary and a
resumable pause — the same landing the round ceiling produced, now triggered
by what the work costs instead of by a count.

Ordinary chat loses its round ceiling entirely rather than getting a bigger
one. Rounds carry no information the spend axes lack: a turn that reaches a
high count without crossing either is one whose rounds are individually
cheap and fast, which is the case least worth interrupting. The only failure
rounds could catch alone is a loop that is both free and instant, and wall
clock catches that one too, in the right unit. Goal keeps its 16-round
boundary — an FSM yield point, not a runaway guard — and an explicit
--max-steps still owns either turn.

Wall clock is the only axis with a default, deliberately: half an hour means
the same thing on every model, an amount of money does not. A cost default
loose enough for DeepSeek would land a frontier model within a couple of
answers, so cost stays off until someone who knows what they are paying sets
[agent].task_cost_budget. Cost also only judges what it can price — an
unpriced model never crosses that axis rather than reading as free — and
wall clock still bounds a free or local model's loop.

Landing is now one path. armFinalizationRound owns arming the grace round,
injecting the finalization request, and emitting the notice; the spend gate
and an explicit max_steps both go through it, and gracePause names which one
stopped the Run. The grace round, not the wording, is what refuses tools.

toolOutcome moved to execute_batch.go, where it is used, to keep agent.go
inside its budget. The remaining baseline diff is 5 lines in config.go for
the two keys and 1 in boot.go for passing them; both files are far past the
800-line ceiling already and the added code is at its minimum shape.

* docs(guide): document the task spend budget

Both guides get the two keys, why only wall clock has a default (money is
not portable across models), the unpriced-model rule, and the fact that
ordinary chat no longer has a round ceiling at all.
2026-08-11 13:32:28 +08:00
SivanCola 0edff6d99a Merge latest main-v2 into agent role settings
Problem:
main-v2 advanced again while the final lint cleanup was being pushed, leaving the pull request without a mergeable synthetic ref.

Resolution:
Merge the latest target tip and validate the combined role-policy and turn-spend changes before rerunning CI.

Verification:
focused Go tests, golangci-lint, repolint, and frontend bundle checks rerun after conflict resolution

# Conflicts:
#	tools/repolint/baseline.json
2026-08-11 12:40:59 +08:00
YHH dbf0dd873a feat(agent): observe what a turn actually spends (#8309)
Rounds are the axis we bound and the one nobody reports a failure in. The
turn behind #8228 was four hours, 524 requests and 194M tokens; not one of
those numbers is a round count, and the same hundred rounds cost minutes or
hours depending on what each one read and how long the model thought.

This adds the axis that is missing: tokens, requests, wall clock and money,
accumulated per turn and reported after every round to sinks that opt into
event.RunBudgetSink. Cost goes through provider.Pricing, so a cache hit
prices at a fiftieth of a miss instead of reading as a full-price prompt —
on a cache-first agent, anything else would misreport the bill by an order
of magnitude.

Shadow only. Nothing reads a verdict from it and no threshold exists yet:
the distribution these samples record is what a threshold should be chosen
from, and picking one first would repeat how 100 was picked.

Rounds still count when a round's usage never arrived, so the axis never
reads cheaper than the turn was.

run_loop.go was 3 lines over the file-size ceiling with this, so the
sampling-usage accounting it already carried — estimate, merge, finalize,
emit — moved to run_usage.go as its own concern. That drops run_loop.go
under 800 lines and retires its file-size debt; the baseline diff carries
the two findings that moved with the extraction and records one fewer
finding overall.
2026-08-11 12:31:25 +08:00
SivanCola daec1e0c03 fix(agent): enforce role policy end to end
Problem:
Role settings derived policy fields that were not consistently shared by planner, capability routing, execution, and final readiness. ACP prompt admission could race an in-place role switch, and desktop branch metadata could restore a stale canonical preset. CI also exposed stale role-copy assertions, locale bundle overages, and an outdated repolint baseline.

Root cause:
Each layer independently inferred role behavior, prompt admission did not participate in the state-change lock, and session persistence updated only the legacy tokenMode field.

Fix:
Freeze one TaskPolicy per turn and enforce its route, capability, mutation, external-action, verification allowlist, explore-subagent, and post-write verification constraints. Serialize ACP admission with role changes, dual-write desktop role metadata, keep planner/router runtimes role-neutral for in-place switching, and repair lint/bundle/test contracts.

Verification:
go test ./...
cd desktop && go test ./...
go test -race ./internal/acp ./internal/control ./internal/agent
go vet ./...
go run ./tools/repolint
cd desktop/frontend && pnpm build && pnpm test:all
2026-08-11 12:20:08 +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