Skip to content

v0.11.1 — delegation delivery closes the loop, codex identity survives resume - #207

Merged
firstintent merged 51 commits into
mainfrom
dev
Sep 11, 2026
Merged

v0.11.1 — delegation delivery closes the loop, codex identity survives resume#207
firstintent merged 51 commits into
mainfrom
dev

Conversation

@firstintent

Copy link
Copy Markdown
Owner

Ship gate for the post-v0.11.0 cycle: 50 commits, 56 files, +19480/-2468.

Main line

Left OPEN for owner close (code-resolved, per ship-gate convention)

Version lockstep

Workspace Cargo.toml 0.11.0 → 0.11.1; @ccteam/ccteam-ui version + ccteam.engine + the four @ccteam/engine-* optionalDependencies + lockfile + PACKAGE_VERSION/ENGINE_VERSION (asserted equal by the plugin's own tests); embedded ccteam-ui.tgz repacked, two-path reproducible (plugin source unchanged this cycle).

Local gate (clean checkout; CI is the arbiter)

fmt clean · clippy 0 warnings · test-baseline 2393/0 (up from 2269 at v0.11.0) · full test --workspace --exclude ccteam-web 3111/0 · SPA 735/735 · plugin 266/266. test-web 384/3 — the pre-existing pty_ws_test ws_* trio (local issue #17, terminal protocol frozen-maintenance, untouched this cycle) — not a regression.

Docs (docs/orchestration*.md, docs/mcp*.md, docs/usage*.md) were already kept current per-commit through the cycle; no README rewrite needed (reliability-only cycle, no new user-facing capability).

🤖 Generated with Claude Code

randd1024 and others added 30 commits September 5, 2026 09:16
…lently — issue #7

An idle-released child is out of the live session map, and the watch was armed
through a live-only resolver. So every follow-up dispatch to a child that had
gone quiet for an hour registered no watch at all, wrote no `delegation.json`,
and returned an `Ok(false)` the only production caller discarded. The task then
went out, ran, finished — and its completion reached nobody: no
`delegation_completed`, no notification, no error. The at-least-once contract
could not help either, because the durable record was never written.

Measured on s1617's mm-track children: s1639 (released 06:39Z, dispatched
07:59Z, boundary 08:02Z) and s1621 (released 22:35Z, dispatched 07:36Z,
boundary 08:03Z) were both silent, while every sibling that was live at
dispatch notified normally. A daemon restart makes it every child at once,
which is how it got noticed.

A watch is a disk fact: all it needs is the child's project, which `meta.json`
still carries. The live thread is `submit`'s precondition, never the watch's —
the dispatch resumes the child a moment later. So resolve live-or-disk, let the
generation fence read "cold" as "there was no body to replace", and turn a
watch that cannot be armed into a failed dispatch rather than one whose answer
can never be delivered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eading precision — issue #8

Three fields of a caller's context that bought it nothing.

The roster default was ten, and a planner reading twenty-five rows spent 38%
of the response's metadata bytes on the titles of historical sessions it had
no decision to make about. Five is what the question actually asks — who is
working for me right now — and `total` + `truncated` have always said when
the cap bit, so a wider view stays one `n` away.

`cost_usd` went out at full f64 precision (`326.49616805000005` — measured,
not invented): twenty characters to say `326.5`. `tokens_total` went out as
nine raw digits, when the figure is read as a size and `context_pct` is what
a caller steers on. Both now leave at the precision they are read at, on the
shared session row, so the transcript branch and the roster branch agree.

One test asserted the roster default where it had explicitly asked for ten
rows — the two numbers happened to be equal. It says ten now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… context once — issue #9

The task text is the one thing a dispatch must carry, and carrying it inline
spends it TWICE in the caller's own context: once building it, once as this
argument — for a parent that never reads it back. Measured on a planner:
199.7 KB across 43 dispatches, none of it re-read, all of it resident in the
most expensive context on the team.

`task_file` takes an absolute path and folds its contents into `task` as the
first thing `run_agent` does, so validation, policy facts, spawn-and-dispatch
and dispatch-to-sid all see one `task` and a new branch gets it for free. The
same bytes become the same verbatim user turn down the same path; the daemon
reads them as the uid the caller could already read them as, so this adds no
reach. Both-or-neither, a relative path, an unreadable file, a blank one and
anything past 256 KiB are refusals, never a quiet fallback.

The path resolves on the DAEMON's filesystem. For a project bound to a
satellite that is the right end — content crosses the wire, a path never
would — but a caller whose file lives elsewhere keeps using `task`.

The 5000 B tools/list gate is NOT raised: the new parameter's 119 B come out
of prose that repeated the schema next to it — a file's type is what its path
says, and a retry key is already named one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…head of its narration — GitHub #196 (docs-local/issues/#199)

Since #192 a stream-json answer is every text block of the turn in stream
order, so the notification excerpt (70/30 head/tail) opened on "Reading the
tests…" and cut the receipt the worker wrote last. Measured on excore:
s871's two `brief` notifications carried the narration's head and ~135
chars of the receipt's tail; s908 never went through `brief` at all (its
parent received the whole 1,067-char answer under `final`) — the report's
"head-only" premise was a simulation, not what the bridge delivered.

- adapter: `TurnCompleted.conclusion` — the text after the last tool call
  (Claude: `result.result`, else the last stream block), carried only when
  the answer holds more than it. `RecoveredTurn.conclusion` on the restart
  path; `TurnRecord.conclusion` in turns.jsonl (absent unless it differs).
- pump / recovery / reconcile stamp it on the final answer row and on the
  delegation signal, so live, inline and recovered notifications select
  the same text.
- delegation: one `answer_excerpt` for notifications, inline `result_text`
  and `agent_read` rows — the whole answer when it fits; else the
  conclusion behind one `…[+N chars: agent_read{…}]…` marker standing in
  for the narration it skips (head/tail-cut only when the conclusion alone
  overruns, still carrying the whole-read recipe); else the head/tail
  preview as before. `agent_read` rows use the row's conclusion to steer
  their own cut and never carry it on the wire.
- No `<report>` marker protocol: receipt format belongs to the calling
  workflow; the bridge keeps the structure the vendor already supplies.
  codex already mirrors each `agent_message` as its own row (the boundary
  signal's text IS the last one); ACP / pi turns stay a single block and
  keep the head/tail preview.
- docs/orchestration*.md: the excerpt sentence now describes the rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Consume vendor settings snapshots for opaque model IDs and nullable effort.
Apply state once before event fanout, and preserve terminal model attribution
through IM replies and durable turns. Resume from vendor-resolved settings
without restoring stale creation-time picks or overwriting newer observations.

Requirement: docs-local/issues/#200.

Validation: make check, make perf-gate, and the new adapter/gateway regressions pass.
make test retains four inbound_wiring suffix assertion failures reproduced
unchanged in a separate origin/dev worktree at 35b1ad4; no test was disabled.
Independent review: s919 approved after lifecycle and attribution follow-ups.
`/model` only queued an override for the next turn/start, and since
819b2d2 the tracker behind /status follows codex's own settings
snapshots — so on a fresh or idle session the pick was invisible until
the next turn ran (owner re-report on s930, turn 0, daemon already at
819b2d2: not a stale binary).

Codex 0.153.4 exposes thread/settings/update (probed against the real
app-server in an isolated HOME): it answers {} and emits the
thread/settings/updated snapshot the dispatcher already consumes, and an
omitted effort keeps the thread's current one. An idle thread now takes
that path after ensure_thread_loaded, and the receipt drops "applies
next turn". A busy thread keeps the queued override (codex would
snapshot the new model under a turn still running on the old one), as
does a failing RPC (older codex / transport death). The override stays
set on success too: resending it on turn/start is idempotent.

Tests: scripted peer can attach follow-up notifications to a response;
two new regressions (idle pick via args + picker with status following;
RPC failure falls back to next turn) were red before the change; the
busy-thread test now asserts no settings RPC and the next-turn receipt.

Requirement: docs-local/issues/#200 (补充分诊).

Validation: cargo fmt --check, make check (rustc 1.98.1), cargo test -p
ccteam-harness (37 targets, 873 passed, 0 failed), cargo test -p
ccteam-im --lib (746 passed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…197 (A)

#196 added `TurnCompleted.conclusion` and `turn_terminal_accounting` was
written `conclusion: None` — a value pattern, not a binding. A Claude turn
carries a conclusion whenever it narrated before its receipt, i.e. nearly
every working turn, so the arm stopped matching and the pump silently
skipped ALL FIVE things that hang off it: the `chat_turn_completed` ledger
row, the kind:turn experience row, the usage on the turn's final
transcript row, the boundary status usage and the meta tokens/cost fold.

Measured on the live excore ledger: s934's narrated turn is missing while
its two bare turns are present; s933 and s936 have no ledger row at all.

Both arms now bind only the three accounting fields and ignore the rest —
whether a turn is billed is a property of its boundary, never of its text,
so a field added to this event tomorrow cannot un-bill a turn either.

The regression reached production because the test double never modelled
the shape: FakeAdapter hardcoded `conclusion: None`, so every pump test
stayed green. It can now emit a turn's conclusion (`with_turn_conclusion`)
and an answerless successful turn (`with_empty_answer`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…GitHub #197 (H1)

H1 confirmed, and wider than reported: EVERY codex session's ledger rows
carried zero usage, not just s932. The live wire spells `tokenUsage.last`
in camelCase (`inputTokens`/`cachedInputTokens`/`outputTokens`/
`reasoningOutputTokens`), `UnifiedTokenUsage` spells its fields snake_case
and defaults every one of them with no `deny_unknown_fields` — so
`from_value` SUCCEEDED and returned all zeros. A silent mis-key, never an
error.

`CodexUsageBucket` now parses exactly the one shape codex emits: camelCase,
every field required, no defaults and no aliases. That strictness IS the
fix's second half — a bucket that is not that shape yields `None` and warns
once through `warn_unknown_vendor_token`, leaving the turn's usage UNKNOWN
instead of billing a confident zero. A zero prices to $0 and quietly drags
the 24h budget ledger down; unknown renders as unknown.

Codex nests its buckets (`cachedInputTokens ⊆ inputTokens`,
`reasoningOutputTokens ⊆ outputTokens`) while `estimate_cost` ADDS its four
and the canonical shape follows Anthropic's already-disjoint reporting, so
the subsets are subtracted out at the adapter — where a vendor shape becomes
canonical. Passing the nested numbers through would bill the cached prompt
at both the input and the cached rate and the hidden CoT at twice output.

Measured, not assumed: across all 53,106 usage buckets on this daemon's
ledgers the shape is camelCase with all six keys, without one exception; no
bucket has cached > input or reasoning > output; and the normalisation's
`total()` reproduces the wire's own `totalTokens` in 3,845 of 3,845 rows.

`last` is the most recent REQUEST's usage, so the reader is now named
`codex_last_request_usage_from_notification` — a turn is many requests (147
of 156 observed turns took more than one, one of them 1,368), which makes a
codex turn's billed figure a LOWER BOUND. The turn's true cost is a `total`
delta, but the baseline is not knowable from this stream: a released or
resumed session hands the dispatcher its first notification mid-thread,
where a stale or absent baseline would over-bill by hundreds of millions of
tokens. Deferred to docs-local/issues/#202, with the caveat stated in
docs/orchestration{,-cn}.md so a parent reads a codex cost as "at least
this much". Zero was silent; a lower bound is honest.

The fixture in `terminal_error_uses_preceding_token_usage_and_thread_model`
spelled a shape codex never sends; it now carries the live one, and its
numbers the disjoint semantics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nows whose it is — GitHub #197 (B/C/F)

One child held ONE watch, so a second dispatch to a busy child silently
took over the first one's parent, notify mode and title, and whichever
turn ended next was reported under the newest task's name. Every dispatch
answered a flat `pending`, whether it had started running or was third in
a queue. A truncated excerpt pointed at `n:1` — "the newest turn" — which
stops being that answer the moment the child finishes anything else. The
three failures measured on s932→s933/s936 and s1688→s1689 are one root:
delivery state and request identity were invisible to the parent.

`delegation.json` now holds MANY requests per child (`schema: 2`; an older
shape is logged and ignored, never migrated). A request is minted and made
durable BEFORE the vendor is written to, carrying its own parent, notify
mode, title, routing and lifecycle: accepted → queued(position) | submitted
→ executing → answered | failed. It is resolved ONLY by the execution turn
it is bound to — never by recency, timestamp or queue position.

That binding needed one id space where there were two. stream-json handed
the caller a `turn-<nanos>` receipt while its event stream reported `sj-N`,
so nothing downstream could say which submission a `TurnCompleted` belonged
to (the gateway's own turn-origin bookkeeping never matched either). The
adapter now reserves the EXECUTION turn id a delivered line will open —
minted at park time for a queued line and persisted with it, so a restart
rebinds by identity — and the translator reports that same id. A queued
receipt names the turn its task will run in, minutes before it starts.

`agent` answers with `request_id` + what the adapter DID (`started` /
`injected` / `queued`, with a 1-based `queue_position` when the adapter can
see its own FIFO) and a `delivery` block keeping four facts apart: accepted
by ccteam, retained in a queue, written to the harness, observed executing.
A stdin flush is not proof the model read anything, so `executing` stays
`unknown` until a turn is observed to open. `agent_read{sid}` carries a
bounded `requests` list with the same fields; `agent{wait}` waits for its
OWN request and returns the row that resolved it, so A finishing can never
come back as the answer to B.

Notification contract: an omitted `notify` inherits this parent's most
recent outstanding request on this child (explicit wins, no precedent =
brief), the header names the answered request and its title and reports
`remaining_queued`, and the completed-turn ordinal comes from execution
boundaries instead of accepted user rows — `turn 2` used to ride the answer
to turn 1. `agent_read` gains an exact `turn:<turn_id>` selector and every
excerpt's full-read recipe emits it, so an old excerpt still reads its own
answer after later turns finish.

Reliability is unchanged where it was already right: append-before-notify,
atomic durable writes, idempotency, at-least-once with per-turn dedup, and
an inline waiter still takes its own completion instead of being pushed a
second copy — now per REQUEST, so a parent blocked on B is still told
about A.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aces — GitHub #197 (B/C/F)

Nine deterministic tests, each red on the pre-fix behaviour (verified by
reverting exactly the line it guards, then restoring):

- queued dispatches report their own request_id and 1-based position
  (was: three flat `pending` replies with nothing to tell them apart)
- a boundary notifies only the request it answered, with that request's
  own title and `N still queued` (was: the newest dispatch's title)
- a queued task's flush binds it, and ITS completion is what reports
- `agent{wait}` on B never returns A's answer (was: the first boundary
  on that child ended every wait on it)
- an omitted `notify` inherits this parent's precedent; explicit wins
- titles are never overwritten across requests
- an old excerpt's read recipe still reads its own answer after a newer
  turn completes (was: `n:1`, "the newest turn")
- the completed-turn ordinal counts boundaries, not accepted messages
- restart between bind and notify resolves by identity; restart between
  accept and bind leaves delivery `unknown`, never a confident `false`
- two parents on one child each get their own notification

The doubles had to become honest first. `FakeAdapter` and the MCP
`StubAdapter` both handed out ONE turn id per thread and completed every
submission the moment it was accepted, so no test could have three tasks
outstanding at once — which is precisely the state every failure in #197
was measured in. Both now own a real FIFO: one turn runs, the rest wait
with 1-based positions, and the test decides when a boundary lands.

The tool descriptions carry the new facts, and the byte gates move with a
stated reason (5000→5200 B full face, 2200→2340 B leaf) — the same way the
budget moved when `status` joined the read face. ~190 B against an
orchestrator that re-sent one instruction three times and then stopped a
400k-context child it believed was ignoring it.

docs/mcp{,-cn}.md now spell the request lifecycle, the four delivery
facts, `turn:<id>` and notify inheritance; docs/orchestration{,-cn}.md say
in plain language that a busy colleague tells you it is busy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…window does — GitHub #197 (C)

The wait re-checked its own request only when a turn-boundary EVENT woke
it. The notifier resolves the request off the pump a moment after that
event, so a wait that arrived first saw "not answered yet", went back to
waiting for another boundary that would never come, and sat out its whole
window for an answer already on disk — `agent{wait:20}` measured at 20.2 s
for a task that finished in 0.1 s. It only returned the answer at all
through the same-instant race-saver (the suppressed-boundary mark).

It now polls its own request on a 100 ms tick: the boundary event is a
hint, the request's state is the fact. Same test, 0.16 s.

Two tests were sleeping a guessed amount where they should watch for the
thing they assert on — a fixed 25 ms for a ledger append that runs off the
caller's thread, and an immediate read of a `delegation.json` written after
the notification submit. Both now poll, and 10 consecutive full runs of the
crate are clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… — GitHub #197 (B)

The status tap wrote a parked line to the CLI and then deleted it from
`deferred-input.json`. Between that delete and the turn actually opening,
the line existed nowhere but the child's stdin buffer — and the child is
the daemon's own subprocess, so a daemon that died in that window took a
dispatched task with it. No turn, no answer, and nothing left on disk for
the next life to replay. The dispatcher's request stayed `queued`
forever.

The mirror now has two sections. `in_flight` is the ONE line handed to
the CLI whose turn nobody has observed ending, written BEFORE the bytes
go out; `parked` is what waits behind it. The boundary that consumes the
line retires it. A crash anywhere in between replays exactly once, and
the transcript decides which: a row carrying that execution-turn id means
the model saw the line, so it is spent; no row means it died in the
buffer, so it goes back to the front of the queue in order, under the
same turn id its dispatcher's request is bound to.

`schema: 2` — the bare array of the previous shape no longer parses and
is logged and discarded, as an unreadable mirror always was (pre-1.0: no
migration; replaying lines whose turn identity is unknown would hand a
dispatcher an answer it could never correlate).

The fake CLI gains one knob (`FAKE_SJ_SWALLOW_AFTER_TURNS`) that accepts
a line and never runs it — the crash window, made deterministic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…197 (C)

`route_acp_turn` pushes the message onto the session's own FIFO, so it
knows the position — and then dropped it, leaving every ACP vendor to
answer `queued` with no number. A dispatcher told only that much re-sent
the same instruction three times.

The position rides on `AcpTurnRoute::Queue`, which is the single decision
point all four ACP vendors match on, so grok, opencode, kimi and dsh all
report it and a fifth vendor gets it without being asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(B)

`notified_turns` remembered every turn a child had ever answered, in a
file rewritten on each dispatch, so a long-lived child paid a little more
for every task it had already finished.

It now keeps the newest 200 and drops the oldest. Dropping one is safe:
the set exists to stop a restart reconcile from re-delivering a
completion, and the reconcile only signals an execution turn some
OUTSTANDING request is bound to — a notified turn's request is terminal
by definition. The bound is far larger than any plausible crash-window
backlog, so the guard keeps working where it actually guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dy — GitHub #197 (B/C/F)

Five defects the stage-2 review found, all in the same path and all the
same shape: the request store could be read or written by somebody who
was not holding it still.

**One claim per child, and every writer takes it.** `accept` held a
single-flight claim and released it before the submit; `bind`, the
mark-executing pulse, the notifier's plan and its commit took none at
all. So two `agent` calls on one child interleaved their read-modify-
writes and the loser's binding vanished — the very overwrite (R3) this
work exists to close — and two `atomic_write_durable` calls on one path
raced their shared `.tmp` file into a failed rename (visible as a flaky
`agent failed: rename …delegation.json.tmp` at baseline). The claim is
now a value, `DelegationStoreClaim`, that every mutator takes and reads
the child sid off, so there is no signature left that mutates a child
nobody claimed. A dispatch holds it from accept through submit to bind,
and the notifier takes it before it plans: a child that answers inside
the submit call — the fast boundary that used to find the request still
`Accepted` and lose the completion until a restart — now waits for the
binding.

**A boundary nothing is bound to answers nobody.** When no request was
bound to the turn that ended, the code resolved every request in state
`Submitted` — whatever turn they were actually bound to. A human IM turn,
a recovered turn or an unrelated injected message completing therefore
closed queued work with an answer that was never its own. It now resolves
nothing and says so once, with the child sid and the exec turn id; the
requests keep waiting with delivery `unknown`. Honest consequence: on the
frozen `terminal` protocol, whose send-keys `turn-<nanos>` receipt shares
no id space with its hook-minted events, a dispatch can never correlate
and stays `unknown` rather than being resolved by guesswork.

The startup reconcile follows from that: it emitted ONE folded signal per
child, so a daemon that missed three turns stranded every request but the
last. It now emits one signal per execution turn an outstanding request
is bound to, and none for turns that can resolve nothing.

**A request the store lost is unknown, not answered.** A wait whose
request had left the store (an `agent_stop`, an unreachable parent)
treated it as done and then read the transcript tail — handing back the
newest row, which is a SIBLING's answer. A named request now has no
substitute: the row it was resolved against or nothing, and the reply
says `state:"unknown"`, `answered:false`. The newest-row fallback stays
only where there is no request identity to confuse it with (an admin
caller waiting on the child's next boundary).

**One function decides notify.** Inheritance was applied to the recorded
request but not to the response's notification route, and not at all on
the peer path — where an omitted `notify` silenced a follow-up to a peer
the caller had already subscribed to with an explicit `final`. Explicit >
this parent's most recent outstanding request on this child > default,
decided once in `NotifyRequest::effective` and used by both. The peer rule
now silences a FIRST contact, which is what it was for.

Tests, each red on the behaviour it replaces: a boundary nothing is bound
to notifies nobody and leaves the request `submitted`; twelve rounds of
two parents dispatching to one child keep both requests, parents, titles
and bindings; a child that answers inside `submit_turn` is still notified
exactly once; a wait on a lost request reports unknown and carries no
sibling text; a follow-up to a peer inherits the mode its dispatcher
chose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Hub #197

`atomic_write_durable` staged every write through `<file>.tmp`. Two
concurrent writers of the same target therefore truncated each other's
staging file, and whichever reached `rename` second failed with

    rename …/delegation.json.tmp -> …/delegation.json: No such file or
    directory (os error 2)

— a perfectly good write reported to its caller as a failure. It was seen
on `delegation.json` under two concurrent dispatches, and every other
caller of this helper (the Claude status writers, `meta.json`,
`next-sid`, the deferred-input mirror) had the same exposure. Fixing it
per call site would have meant a lock per file; it belongs here.

Each call now mints its own staging name from the pid and a
process-monotonic counter, in the same directory so the rename stays
atomic. What two writers race over is which `rename` lands last — the
semantics `rename` is chosen for. Serializing writers is still the
caller's job when the content is a read-modify-write; not corrupting
another writer's staging file is this helper's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wns it — GitHub #197

The boundary path snapshotted the queue, released the lock, and then
persisted. A park landing in that gap wrote its own snapshot first and
the stale one overwrote it — the line stayed in memory, so nothing looked
wrong until the process died, and the next life of the sid replays only
what is on disk.

The queue mutex is this session's one writer of `deferred-input.json`,
and both critical sections now say so by construction:
`park_deferred_line` and `advance_deferred_queue` each take the lock,
mutate, persist and release, and the tap's `TurnResult` arm and both park
sites go through them. The extraction is also what makes the invariant
testable: eight parkers race one boundary loop while an auditor samples
continuously, and a line the parker was told was accepted must at every
instant be either handed out or in the mirror. Pre-fix that test loses
between 3 and 20 lines per run.

A park refused by the disk keeps the policy its caller needs
(`OnUnmirrorable`): a delegation notification is taken back and written
mid-turn rather than accepted un-mirrored; a slash command stays parked,
because a late command beats one the model reads as prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… — GitHub #197 (C)

When Grok rejects an idle interject, the message is re-routed onto the
session's own FIFO — which knows the position, and then dropped it. Same
fact the other ACP vendors already report; a dispatcher told only
"queued" cannot tell "next" from "fourth".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sed at — GitHub #197 (B/C)

Four defects the second review found, all in the same window between a
turn ending and the store admitting it.

**One boundary, one notification.** The dedup key and the resolution were
written at COMMIT — after the parent had already been notified. Two
deliveries of one boundary (the startup reconcile racing the live
notifier; a retry after an aborted commit) therefore both planned against
an unresolved request and woke the parent twice for one answer. The
boundary is now spent inside `plan_delegation_delivery`, under the same
lock that read it: the dedup key goes in, the requests it resolves are
resolved, and a second delivery finds nothing to do. The commit records
only who was actually told.

**A dispatch mid-delivery no longer invalidates the delivery.** The
mirror's generation was bumped on every accept, which made it a "something
changed" counter rather than an identity — so the notifier's commit fence
aborted whenever a dispatch landed mid-delivery, leaving the request
outstanding and the turn unrecorded after the notification had gone out,
and a restart delivered it again. The generation now identifies the ENTRY:
minted when it is created, untouched by an append. Only a replaced entry
can fail the fence, which is what the fence was for.

**The binding is durable before the boundary is let through.** The
adapter's completion fence — for a vendor whose turn can complete
concurrently with its own acknowledgement — was dropped at the end of the
submit, before the delegation bind. A boundary landing in between left an
executed request unbound on disk, which no later life can repair. The
fence now rides the `TurnReceipt` to the dispatch path and is released
after the bind. And a bind that cannot be persisted is returned, not
warned about and forgotten: the mirror keeps the binding for this process,
the request carries the error, and every surface reads it `unknown` rather
than promising a correlation a restart would not honour.

**`agent_read{sid,wait}` separates what it answered from what it lost.**
`resolved_requests` meant "no longer outstanding", which lumped a request
an `agent_stop` dropped in with one the boundary answered — telling the
caller it was holding an answer that does not exist. Only `answered` and
`failed` are resolved now; dropped, interrupted and confirmed-undelivered
requests are named in `unknown_requests`.

Also: every request-store mutator now takes a `&DelegationStoreClaim` and
reads the child sid off it, including the ones that still took a bare sid
(`mark_delegation_requests_executing`, both `drop_delegation_requests`
flavours, the two test helpers). There is no signature left that writes a
child nobody claimed.

One existing assertion moved to the fact underneath it:
`agent_read_wait_disarms_…` checked that a third party's read left the
parent's request outstanding, which was a proxy for "it did not take the
parent's notification away". Resolution is now prompt enough that the
proxy is timing-dependent, so the test asserts the notification itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#197

Per-call staging names removed the shared-`.tmp` collision but left the
file behind on every path that is not a successful rename: a full disk, a
refused fsync, a rename onto a read-only directory, or a process that
died in between. With one name per call there is no later writer to
reuse it, so each leak is permanent.

The staging file is now an RAII handle that deletes itself unless the
rename claimed it, and each write first sweeps siblings of ITS OWN target
older than five minutes — one `read_dir` of the directory the write is
about to touch anyway, never another target's files. A sweep that guesses
is worse than a leftover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rwrite it — GitHub #197 (B/C)

**The plan's claim is durable now.** `plan_delegation_delivery` writes the
dedup key and resolves the requests in the MIRROR; the disk only caught up
at commit. The accept path read-modify-writes the same file, so an accept
between the two put back a store in which the boundary had never been
answered — the parent had already been told, and a restart reconcile told
it again. The plan persists under its own claim before releasing it, and
the accept's read and write now both sit inside the claim, so what reaches
the disk always contains the claim. One helper
(`persist_delegation_store`) does every mirror→disk write, so the executing
pulse and the commit cannot drift from it.

**The seal is a signature, not a habit.** `write_delegation_requests` and
`remove_delegation_requests` take a `&dyn DelegationStoreGuard` instead of
a `&str` sid, and derive the path from it: the gateway's
`DelegationStoreClaim` is the only implementor, so a write without the
claim does not compile. `plan_delegation_delivery` and
`restore_delegation_store` take the claim like every other mutator, and a
write that has to cross onto the blocking pool carries a
`DelegationWriteTicket` minted from the live claim rather than a bare
string.

**A missing row is an error that names itself.** Binding against a child
with no record, or a request that has left the store, returned `Ok(())` —
so the caller got a normal-looking dispatch and waited for a completion
nothing could match. Both are now `Err` carrying the sid and the
request_id, and the dispatch response carries the error TEXT beside its
`state:"unknown"`, the same string `agent_read` shows on the request. A
request that vanishes mid-plan is logged at error level: under the claim,
it cannot happen unless something still writes unclaimed.

**`unknown_requests` is documented where callers read.** The `agent_read`
description says it, and the byte gates move with the reason recorded in
the test (5200→5320 B full, 2340→2460 B leaf): a response field the face
never mentions is a field callers do not act on, and "came back holding
nothing" reading the same as "your task was dropped" is the confusion this
issue is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…GitHub #197

It matched any `<target>.*.tmp`, which is a licence to delete somebody
else's file that happens to sit beside ours — an editor swap, a backup, a
`.bak.tmp`. It now matches this helper's own naming exactly:
`<target>.<pid>.<seq>.tmp` with both fields numeric, and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tHub #197 (B)

The last two rounds pushed the same window in opposite directions.
Recording the answer at commit let two deliveries wake one parent twice;
recording it before delivery meant a crash in between suppressed a
completion nobody had received. Neither end of one write can be right,
because the durable claim and the durable answer are different facts.

Two phases, both under the child's claim:

(a) `plan_delegation_delivery` writes a `delivering{turn_key, exec_turn,
    request_ids, attempt}` marker and NOTHING about the requests, then
    persists it before releasing the claim. The marker blocks a second
    delivery in this life (its in-memory twin `delivering_now`), survives
    a crash so the next life knows the notification is owed, and blocks a
    concurrent accept from writing it away.
(b) the parent turn is appended and submitted.
(c) the same claim moves the boundary from `delivering` into
    `notified_turns` and marks the requests answered, in one step.

A restart with `delivering` and no `notified` entry redelivers — a
duplicate after a crash is what the contract allows; a lost completion is
not. `delivering_now` is empty by construction on startup, so a marker
read from disk is always a previous life's.

A claim that cannot be persisted now ABORTS the delivery: warn with sid +
exec turn, drop the marker, leave the requests exactly as they were, and
let the reconcile retry. Notifying on a claim no disk remembers is how one
completion became two.

And the write seal is closed: `delegation_store_io` is the only module
that can name a `DelegationStoreGuard` — the ticket type is private to it
— so `bind`, `drop`, `restore`, `accept` and the test fixtures all reach
the file through one persist path, and nothing else in the crate can reach
it at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…GitHub #197 (B)

The guard trait was public, so a downstream crate could implement its own
token and reach `delegation.json` around the one claimed persist path. It
now has a private supertrait: `DelegationWriteGuard` is the only
implementor there can ever be, and ccteam-im's sealed store-IO module is
the only place in the repo that mints one. `lib.rs` no longer re-exports a
raw write/remove pair — the claimed path is `persist_delegation_requests`
/ `delete_delegation_requests`, both of which demand the guard. A
`compile_fail` doctest holds the seal shut (it goes red the moment the
supertrait is dropped). Honest scope: this crate cannot verify a lock is
held; what the seal buys is that no foreign guard can exist, so a bypass
would have to be a deliberate, greppable `DelegationWriteGuard::for_child`.

And the docs stop promising "once" flatly. The contract the two-phase
protocol actually implements is at-most-once while the daemon lives and
at-least-once across a crash: a claimed-but-unfinished completion is
redelivered by the next life, so a crash at the wrong instant costs a
duplicate and never the answer. Both language pairs now say that, and tell
a reader to treat a repeated `request_id` + turn as the same completion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Hub #197 (D)

Routing was derived from the turn's ORIGIN and from nothing else, so an
`agent{sid,task}` could not be a steer: internal origin meant Queue, and
borrowing `TurnOrigin::User` for the channel would also push the child's
answer at whoever shares its IM thread (the v0.10.1 `has_addressee` gate).
Measured on s932→s933: a ruling sent at 17:38 reached claude at 17:52,
and the parent — told only `pending` — re-sent it twice and then stopped a
400k-context child.

Split the axis: `TurnIntent` carries who is asking AND which vendor channel
to use, with `routing_for_origin` still deciding for every caller that has
no opinion. `agent` gains `routing: "inject" | "queue"`, defaulting to
inject — the same route an IM `@handle` takes. The response's `status`
stays what the adapter DID: a harness with no injection channel degrades to
a distinct turn and says `queued`.

The trade-off is documented rather than hidden: an injected task is resolved
by the boundary of the turn it joined, and on claude that turn is shown the
line twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…197 (E)

A turn cut short left nothing behind: measured on s932→s936, a child that
had worked twenty-nine minutes and made sixty-nine tool calls read back
through `agent_read` as `turns:[]`, so its parent could not tell a stopped
session from a silent one.

`HarnessAdapter::in_flight_narration` reports the running turn's PUBLIC
narration — the text blocks a transcript would show, from the same
`extract_blocks` the turn accumulator uses, so a subagent's chatter and the
model's thinking are out by construction. The stream-json status tap keeps a
bounded TAIL of it (2000 chars, cleared at every boundary) and the cell says
when it dropped the head. `None` means "no turn, or this adapter cannot
report one" — distinct from an empty narration, which is the fact that the
turn had not said anything yet.

`retained_input` reads the parked-input mirror without the replay decision
on top: an explicit stop needs to tell "still ours, never handed over" from
"written to the CLI, no boundary observed", and a resume does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(E)

`agent_stop` answered `{stopped:true}` and DROPPED the child's requests, so
everything the stop cut short vanished with it. Measured on s932→s936: a
"do not open a public port" constraint sat undelivered in the child's queue
until the stop deleted the record of it, and the twenty-nine minutes of work
that were in flight left `turns.jsonl` empty — `agent_read` showed
`turns:[]` for a session that had made sixty-nine tool calls.

`Gateway::stop_session` now records what it ends, so EVERY entry point gets
it — the MCP tool, the IM `/stop`, the web session and project stops:

- one bounded `interrupted` row in `turns.jsonl` (the cut execution turn,
  the adapter's in-flight narration or an explicit `unknown`), and
- every outstanding request settled: bound to the cut turn → `interrupted`;
  still held in a queue file → left outstanding, because it replays; neither
  → terminal, `undelivered` or `interrupted` by whether the bytes ever left.

`agent_stop` reports that back with `undelivered` (split by whether ccteam
still holds the line and where), `interrupted{turn}` for one exact re-read,
and the resume policy stated rather than implied. A restart reconcile
refuses an `interrupted` row outright: without that guard it delivered
"s98 FAILED (turn_interrupted) · «migration»" to the parent — a task
reported as finished because the child was killed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ks — GitHub #197 (D)

Routing became a dispatcher's argument this cycle; the one submit path that
must never take one is the notifier's. Pinned with teeth: routing that door
to Inject makes it fail with `[Inject, Inject]` instead of `[Inject, Queue]`
— issue #194's double-read, back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…— GitHub #197 (E)

`agent_stop` was the only caller of the bulk drop, and it stopped dropping:
a stop settles the requests instead. Pre-1.0 there is no reason to keep a
door nobody opens, so both flavours and the `remove_blocking` they were the
last users of are gone, and every comment and doc line that still told
callers "an `agent_stop` dropped it" now names the causes that remain — a
dispatch whose submit never reached the vendor, an unreachable parent, and
a turn cut short (which is now a state, not an absence).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it ran on survives — docs-local/issues/#203

s932 was picked onto gpt-6-astra via /model, idle-released, and came back
on codex's global default with its context gone. Three layers, one fix each:

- codex `close_thread` no longer `thread/archive`s. A close is a residency
  release (idle / capacity / stop), never the end of the sid; archiving made
  every later `thread/resume` fail ("session … is archived") and forced the
  fresh `thread/start` fallback: context and settings lost on every release.
- the codex adapter now persists `status.json` like every other long-stdio
  adapter: the tracker records `(project_dir, sid, generation)` at start and
  writes the snapshot on `thread/settings/updated` and `turn/completed`, so
  the gateway's `respawn_tuning` observed-model rung finally has something to
  read for codex, and a `/model` pick outlives the thread it was made on.
  `thread_status` reports the generation stamp too.
- gateway re-keys `meta.vendor_uuid` when a re-spawn (dead-child resume or
  rebuild-from-meta) comes back on a different vendor thread, so the next
  cold resume targets the thread that actually holds the context.

Regression: scripted-peer close asserts unsubscribe + no archive; a
settings snapshot lands in status.json with the thread generation; the
gateway FakeAdapter reports a fresh vendor_uuid and both apply paths re-key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
randd1024 and others added 21 commits September 7, 2026 04:23
…— GitHub #197 (E)

Five ways a stop could still lie, all closed at the layer they live in.

The IM `/stop` ran inside `&mut Gateway`, which cannot take the stopped
child's single-flight claim — claims are taken BEFORE the gateway lock — so
its settlement never left memory: after a restart the request read
`executing` on a session whose process had been gone for hours. The command
now resolves under a short lock, drops it, and stops through the same door
`agent_stop` uses. `stop_session` hands back a `#[must_use]`
`PendingSettlement`, so "settled in memory only" is a compile error rather
than something a new stop path can forget; the one caller that has nobody to
answer (a session being replaced) discharges it through a task that takes
the claim and never the gateway lock.

The two early exits settled nothing at all. A released session ends no turn,
so it now REPORTS what it is holding and changes nothing — and `agent_read`
rows carry `retained_in`, so a reader can see its task is waiting rather
than lost. A detached body that is already gone settles the turn it was
observed running as `interrupted{reason:"body_exited"}`, with the same
record and never as a completion.

A record or store write that failed was swallowed under `stopped:true`. It
now rides back as `settle_error` → `settled:false` with the text, warned
with the sid; the process still stopped, and the receipt says only that.

Retention was attributed off `pending_turns.jsonl`, whose rows named nobody:
every unbound request was told its line was retained. The rows now carry the
`request_id` of the dispatch that enqueued them, and rows that still name
nobody are reported as `retained_unattributed:N` — never as somebody's task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(E)

The resume contract was implemented and only implied. Stated now, in the one
place a caller reads before acting and in the two guides: retained lines
(`deferred-input.json`, `pending_turns.jsonl`) are reloaded by the next life
of that same sid and replayed in order after its FIRST `result` — the
message that woke the session runs first, then the parked lines, oldest
first. Nothing is dropped, merged, deduplicated or reordered; an explicit
stop cancels nothing and ccteam never re-sends on anyone's behalf. The cut
turn itself is not replayed, and an idle release is not a stop.

The tool face grows ~80 B for it and a test holds it there: a caller that
does not know re-sends the instruction, which costs a turn on two sessions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… its MCP principal — GitHub #200 (docs-local/issues/#204)

A managed codex session (excore/s932) turned into a hand-started,
projectless caller mid-conversation: `~/.codex/config.toml` was rewritten
by a hand-run `codex`, ccteam re-spawned the shared app-server on the next
spawn (by design), and the thread's next turn re-loaded it through
`ensure_thread_loaded` with a bare `thread/resume{threadId}`. Codex applies
MCP config from the start/resume params only, so the thread fell back to
the GLOBAL `[mcp_servers.ccteam]` entry — the machine's enrollment
credential, user-scoped to a different owner — and every `agent*` call was
(correctly, fail-closed) refused as a projectless enrolled caller.

The tracker now remembers the per-thread `config.mcp_servers.ccteam` the
thread was started with, and every re-load sends it again. No ACL change.

Regression: scripted peer — start with a secret, forget the loaded set,
submit; the resume must carry the same Authorization as the start (red
before the fix).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… GitHub #197 (G)

Only claude stream-json could say what a turn in flight had said. A codex or
grok child stopped mid-turn — or merely read while it worked — reported
`narration: unknown`, which a parent cannot tell from silence.

The three protocols report a turn's text in three shapes, and folding them
with one verb is how narration gets doubled or lost:

- stream-json emits one `assistant` message per step carrying the COMPLETE
  set of its own text blocks — a cumulative snapshot, keyed by `message.id`
- codex app-server emits `item/agentMessage/delta` fragments, then an
  `item/completed` with that item's whole text
- ACP emits `agent_message_chunk` deltas, which the turn buffer already holds

So `NarrationAccumulator` has exactly two verbs — `append_delta` and
`set_snapshot` — both keyed by the vendor's own message/item identity: a
restated snapshot replaces its paragraph instead of duplicating it, a fragment
extends what it continues instead of replacing it, and the completed item's
text replaces the fragments it streamed as. It keeps the TAIL inside
`IN_FLIGHT_NARRATION_MAX_CHARS` and counts exactly what it dropped, so
`PartialNarration` now carries `omitted_chars` rather than a bare
`truncated` flag: 40 characters plus "1600 omitted" is a judgement a reader
could not make before.

Codex reads through a sync narration cell fed by the SAME sole dispatcher as
the tracker (the trait method may not await — it runs under the gateway lock),
opened by whichever of the submission and `turn/started` lands first and
idempotent in the turn id. The four ACP vendors (grok / kimi / opencode / dsh)
share one `acp::in_flight_narration`, which reports the buffer only while it
is genuinely in flight: once its boundary is seen, that text is the ANSWER,
and handing an answer back as a partial is what a partial must never do.

Private reasoning is excluded by construction on every path — thoughts never
enter the ACP buffer, and reasoning items/deltas are never fed to the codex
cell.

Red on the pre-fix default (verified by reverting exactly the capability):
the ACP fold test, the grok end-to-end and the codex end-to-end all read
`None` where a running turn was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed — GitHub #197 (G)

`agent_read{sid}` on a session with a turn in flight now answers `partial:true`
plus `in_flight{turn_id, narration, text?, truncated?, omitted_chars?,
requests?}`. Before this the only way to learn what a long-running child was
doing was to stop it: measured on s932→s936, a child twenty-nine minutes and
sixty-nine tool calls into its work read back as `turns:[]`, and its parent
stopped it to find out — losing the work.

- `partial:true` stands alone and means one thing: what follows is NOT an
  answer. It is reported even when the narration itself cannot be
  (`narration:"unknown"`), because "a turn is running" is the fact the
  caller's next move turns on.
- `turn_id` is the EXECUTION turn, and `requests` names the outstanding
  requests bound to it — whose tasks that turn is answering, and only those.
- The excerpt is the tail of PUBLIC narration only, bounded by the harness at
  2000 chars and narrowed further by `max_chars`, with `omitted_chars`
  counting everything dropped from the head. It has its OWN budget: spending
  the page's budget on mid-turn chatter would drop a finished answer's row in
  favour of a running one.
- Reading it settles nothing — `activity` stays `working`, no request is
  resolved, and the completion notification still arrives at the real
  boundary. The test proves that last part rather than asserting it.

`Gateway::in_flight_turn` reuses the stop path's own capture, so the two
surfaces can never disagree about which turn is open or what it said, and it
is taken in the SAME lock hold as the residency and activity this body
reports.

Red on the pre-fix body (verified by suppressing exactly the insertion): both
reads answered `partial: Null`.

Tool-face budgets move with a stated reason (5900→6100 B full, 2460→2600 B
leaf): a response field the face never mentions is a field callers do not
reach for, and the point of this one is that it is reached for INSTEAD of a
stop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ame who can answer — GitHub #197 (G)

Three loose ends from the read surface:

- A closed codex thread left its narration entry in the map for the rest of
  the daemon's life. The cell belongs to a live turn on a live thread.
- `PartialNarration::bounded_to` had no caller (the read surface narrows with
  `bounded_tail` directly), so it goes; its test moves onto the primitive
  every excerpt is actually narrowed with, including the multi-byte case a
  byte-wise tail would have split.
- `docs/mcp{,-cn}.md` claimed every stdio harness answers an in-flight read.
  It is claude stream-json, codex app-server and the four ACP vendors; pi's
  rpc bridge and the frozen `terminal` protocol report `unknown`, and saying
  so is the difference between "cannot say" and silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s-197

# Conflicts:
#	crates/ccteam-harness/src/execution/codex_app_server.rs
…lds — GitHub #198/#199 (H)

Claude Code wakes its own model. When a background Bash task, a Monitor or
an Agent task the child launched finishes, the CLI writes a user line of its
own and the model answers in a brand-new turn ending with its own `result` —
ccteam submitted nothing. Measured on session s889: one dispatched task ran
seven turns over 47 minutes and the receipt was the last of them. The same
shape applies to a line ccteam injects mid-turn, which claude shows as a
queued command and then re-runs as the next prompt.

The neutral event stream could not express either fact, so every `result`
looked like the end of the task. Two small enums close that:

- `TurnStarted.opening` — `Submitted` when ccteam delivered the line that
  opened the turn (the id was reserved), `VendorContinuation` when nothing
  reserved it. `TurnIdentity::open` already knew; it now says so.
- `TurnCompleted.continuation` — `Pending` when the vendor still holds work
  that will wake it again, `Settled` otherwise. For claude stream-json that
  is the `background_tasks_changed` snapshot (tracked by the translator's own
  broadcast subscriber, because the status tap's fold is not ordered against
  it) plus the adapter's record of an injected line awaiting replay. Harness
  state only: nothing reads what the model wrote. A `TurnFailed` is terminal
  whatever the vendor holds — a continuation does not repair a failure.

codex, every ACP vendor and pi answer `Submitted`/`Settled`: there a turn
exists only because ccteam asked for one. Grok's vendor-self-started turn is
still `Submitted` — it is grok answering an interjection ccteam sent, not
grok waking itself.

Also lands the durable primitives the gateway half needs: `RequestProgress`
on a delegation request (bounded trail of boundaries it rode through),
`DelegationRequests::{note_progress,rebind_continuation}`, and the
`chat_turn_continues` progress row — appended after `chat_turn_completed`,
which is an idle tail to the activity classifier, so a child three turns into
one task does not read `idle` to every parent polling it.
…lf — GitHub #198/#199 (H)

A dispatched task was resolved by the FIRST `result` the child produced. On
claude that is routinely a checkpoint, not an answer: the CLI wakes its own
model whenever a background task, a Monitor or an Agent the child launched
finishes. Measured on excore's s889 — one `agent{notify:"final"}` produced
seven turns over 47 minutes, the receipt was turn seven, and the request's
watch had been spent on turn one. The parent read a checkpoint as the answer;
the answer reached nobody.

The gateway now reads the two facts the adapter reports:

- A boundary marked `TurnContinuation::Pending` settles nothing. It is
  recorded against every request bound to it (a `progress` entry naming the
  execution turn, surfaced on `agent_read`'s request rows), every binding is
  kept, and no parent is woken in any notify mode. Deliberately BEFORE the
  delivery plan: claiming a boundary nobody is told about would make a restart
  owe a notification for a turn that answered nothing. The turn is still
  billed — every boundary accounts.
- A `TurnStarted` marked `VendorContinuation` carries `continues_from`, and
  the notifier moves the previous execution turn's outstanding requests onto
  it under the same lock hold that marks them executing. By identity, so a
  continuation adopts only what it is continuing; a rebinding that cannot be
  persisted is warned about rather than dropped, because a restart would then
  resolve the request off the turn it left.

The injected-follow-up path falls out of the same two facts: claude re-runs an
injected line as the next prompt, so the turn it joined reports `Pending` and
the request moves onto the replay turn, which is where it is answered. The
stage-3 docs said the opposite; corrected in docs/mcp*.md, docs/orchestration*.md
and the `agent{routing}` tool description (face budgets 6100→6300 / 2600→2700 B,
reasons recorded in the gate tests).

Restart reconcile still delivers terminal: a child whose process died with the
daemon holds nothing that can wake it.

Closes GitHub #198 and GitHub #199.
…out a scan — GitHub #197 (G)

Two halves of one defect the stage-4 checker found: `agent_read`'s in-flight
read asked the adapter while holding the one global gateway mutex, and the ACP
implementation answered by scanning and copying everything the turn had said.
Lock hold therefore grew with narration, so one talkative child put every
route in the daemon behind it — and `agent_read` is on every orchestrator's
hot path.

- ACP keeps the bounded tail incrementally, the way codex and claude
  stream-json already do: `TurnBuffer` owns a `NarrationAccumulator` fed by
  each `agent_message_chunk` alongside the full `text` that becomes the answer.
  The read hands that tail over — no pass over the turn. `agent_thought_chunk`
  still never enters either, so private reasoning is excluded one layer below.
- The gateway resolves the handle under the lock, RELEASES it, calls the
  adapter, then re-takes the lock for the bindings — the shape the residency
  and activity paths already use. Two acquisitions cannot lie in the direction
  that matters: a partial can only come from a session that was live in the
  first hold, so "narration from a body the response says is gone" is not
  reachable; only "the turn ended in between", which is what an absent partial
  already means. `in_flight_turn` is replaced by `in_flight_turn_shared` (its
  only caller was `agent_read`) with the assembly in one free function.
- The trait doc now states the bound instead of implying it.

Tests: `an_in_flight_read_asks_the_adapter_with_the_gateway_lock_free` — the
fake tries the gateway mutex from inside `in_flight_narration` and the read
fails if anyone held it; `an_in_flight_read_never_scans_the_turns_full_text` —
a megabyte planted directly in `buffer.text` must not move the partial.
…it continued — GitHub #198/#199 (H)

Checker blocker. Restart reconcile rebuilt the delegation chain from the
request's binding, and that binding moves in a DIFFERENT durable write from
the turn it moves onto. A daemon that died between them — or a rebind that
could not be persisted at all — left the record pointing at an intermediate
turn while the answer was already in `turns.jsonl`, so the reconcile handed
the parent a checkpoint and never mentioned the turn that answered: the same
mis-attribution this issue exists to remove, arrived at from the other side.

Fixed at the root by putting the chain edge on the TRANSCRIPT, in the same
durable append as the turn itself: `TurnRecord.continues_exec_turn` names the
execution turn this one was opened to carry on, written by the pump from
`TurnStarted{opening: VendorContinuation}`. `repair_continuation_bindings`
then walks that edge forward from whatever a request is bound to and rebinds
it to the LAST recorded turn of the chain, before anything is delivered — pure
and re-derived on every start, so nothing has to have been persisted for it to
be right in the next life.

What cannot be proven is not guessed. A fork, a cycle, or a bound turn the
request's own `progress` trail says it merely rode through with nothing later
recorded, all mark the request so every surface reads it `unknown`; those
bindings produce no signal and the request stays outstanding. Notifying the
checkpoint is the worse answer.

Also (G): pi asserts the vendor-continuation no-op it inherits by default —
`opening == Submitted` on the turn it opens, `continuation == Settled` on both
arms that close one — the proof codex and grok already carry.
… observable and correlated by request — GitHub #197 (closes #198, #199)

Stages A–H: accounting regression (#196 follow-up), request identity and two-phase
completion delivery, delivery dispositions, inject/queue routing split, explicit
stop receipts with interrupted records, bounded in-flight narration, and
vendor-driven continuation turns that keep their request owner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…206)

dsh 0.1.5 put a launch token on the `dsh web` readiness line and answers
401 to every request that does not carry the cookie it exchanges for.
ccteam had four same-shaped defects behind one assumption — that a
vendor's web face is open and its readiness line is a fixed string:

- the readiness probe demanded 2xx, so a healthy instance reported
  `DSH web health probe returned 401 Unauthorized`;
- the port was scraped off a hardcoded prefix, keeping the port and
  throwing away the credential printed beside it in the same line;
- the companion port strips the browser's Cookie (ccteam's auth
  boundary) and had nothing of its own to send upstream;
- the attach probe also demanded 2xx, so an operator's own instance read
  as absent and ccteam started a rival process over the same DSH home
  and ACP socket.

The rules this establishes, so the next dsh release does not break it
again:

- Readiness is the URL the vendor printed, taken verbatim and parsed as
  a URL, scanned on BOTH pipes; a `dsh web:` line that is not a URL is
  skipped, not fatal.
- Liveness is not authorization. Any HTTP answer proves the listener is
  up; only a transport error means "did not start".
- The startup credential is exchanged exactly once, against the loopback
  authority the proxy sends upstream (the cookie is bound to it), and
  stays opaque: never parsed, never assumed, never logged. No query in
  the readiness URL means no credential, and everything downstream stays
  correct.
- The companion port injects that credential on the upstream HTTP and
  WebSocket hops. The browser's own Cookie is still stripped, so
  ccteam's auth boundary is exactly where it was.
- An instance ccteam did not start is present as soon as it answers, so
  it is attached and never duplicated; ccteam holds no credential for it
  and says so in the panel's error tail instead of proxying a bare 401.

Tests: a hermetic python `dsh web` fake reproduces the tokened readiness
line, the 401 challenge and the Host-bound 303 cookie exchange. Against
the pre-fix runtime the new integration test fails with the operator's
own error; after it the manager reaches Running holding the cookie the
fake minted, and a request carrying it is accepted where a bare one is
refused.

docs-local/issues/#206

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZ6gN35P8W56brV3xpEbsu
…oundary (#207)

GitHub #206 — a session whose `/goal` Stop hook refused to end its turn for
11.7 hours answered a question in 57 seconds, and the chat received nothing.
The pump's delivery unit was the TURN BOUNDARY, which is an accounting fact,
not a delivery schedule: every `ItemCompleted{AgentMessage}` of a turn-bounded
protocol sat in `pending_answers` until `TurnCompleted`. That wait bought one
thing — the boundary knows which message is the turn's last, so that row can
carry status/usage/conclusion — and paid for it with unbounded silence.

Fixed in the one layer that decides delivery, so every paneless protocol and
both chat front ends inherit it. The hold is now bounded and interruptible:

- the chat is WAITING ON THIS TURN (a message the vendor absorbed mid-turn is
  being answered by it) → held text goes out at once. The claude stream-json
  path reports such a line as `injected`, which is exactly the reported shape;
- or the hold outlived one liveness heartbeat while the turn runs on → it goes
  out on its own. The message count is unchanged; the hold only ever delayed.

A turn that delivered that way still owes its CLOSING receipt, so the boundary
sends the status line alone — never a second copy of text the chat has — under
the same addressee rule the answer path applies.

And the facts that explain a silence were already in hand, unused:

- `/status` now carries `💬 <what the running turn has said>` from the
  adapter's bounded narration tail (no scan, no IO);
- `ThreadStatus::stop_hook_blocks` counts recent Stop-hook refusals to end the
  turn, read from the Claude transcript in the SAME tail read as the goal
  (`Option` so "this harness cannot tell" stays distinct from "nothing
  refused"). The card says `Stop hook 近 1h 拒停 N 次,turn 无法结束`, with
  `/goal clear` only when a goal installed the hook. Probed against the real
  transcript: 123 refusals, ~30s apart;
- `后台任务` is now the vendor's own answer (`RunningTask::backgrounded`), not
  a `task_type` guess — a foreground `Bash` call is a `local_bash` task too,
  and labelling it a background task sent the reporter looking for a script
  that did not exist. A foreground shell reads `命令`.

Not in this change: the queued-dispatch TTL (#206 P3). Dropping or re-routing
an accepted at-least-once `agent{routing:"queue"}` request is a revision of the
A2A delivery contract, hence an owner call; `docs-local/issues/207` records the
observability-only alternative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y survives resume

Ship gate for the post-v0.11.0 cycle (50 commits since the tag, 56 files,
+19480/-2468). Reliability-only cycle: no README rewrite needed (no new
user-facing capability), but docs/orchestration*.md, docs/mcp*.md and
docs/usage*.md were already kept current per-commit through the cycle.

Version lockstep: workspace `Cargo.toml` 0.11.0 -> 0.11.1; `@ccteam/ccteam-ui`
version + `ccteam.engine` + the four `@ccteam/engine-*` optionalDependencies +
the lockfile + `PACKAGE_VERSION` / `ENGINE_VERSION` (asserted equal by the
plugin's own tests, 266/266 green); the embedded `ccteam-ui.tgz` repacked,
two-path reproducible (plugin source itself did not change this cycle).

Main line: GitHub #197 (delegation delivery and completion observable and
correlated by request — queue lag, watch overwrite, silent stop, the #196
accounting regression), closed by ~30 commits. Folded in on top: #198/#199
(a parent is notified when a delegated child finishes a turn it opened on its
own, e.g. Monitor-woken; a final receipt stays owned through post-completion
continuation turns) and #200/#203-local (a Codex thread reloaded onto a new
app-server connection keeps its MCP principal and observed model). #195/#196
were already closed going into this range; the cycle's tests pin them so they
cannot regress.

Two GitHub issues are code-resolved by this range but left OPEN for owner
close, per convention (a ship gate never closes a ticket itself): #205
(parked completion notifications now batch at turn boundaries, dedupe, and
`agent_read` cursors are explicit and bounded) and #206 (a long turn's words
now reach chat without waiting for its boundary — P0-P2 of the issue; P3,
queued-dispatch TTL policy, stays deferred).

Local gate (clean checkout, no parallel worktree contention — CI remains the
arbiter): fmt clean, clippy 0 warnings, test-baseline 2393/0 (up from 2269 at
v0.11.0), full `test --workspace --exclude ccteam-web` 3111/0, SPA 735/735,
plugin 266/266. `test-web` 384 passed / 3 failed — the same pre-existing
`pty_ws_test` `ws_*` trio tracked by local issue #17 (terminal protocol is
frozen-maintenance; untouched this cycle), not a regression.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-11T09:04:01.240966Z a17059f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@firstintent
firstintent merged commit 1146cce into main Sep 11, 2026
3 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a17059fb1d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
let retained_in = held.holding(request);
let delivery = delivery_word(request, &held);
if retained_in.is_none() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Settle or resume tasks retained by agent_stop

When agent_stop hits a child with another task parked in deferred-input.json or pending_turns.jsonl, this condition leaves that request nonterminal because retained_in is present. The stop simultaneously writes meta.stopped_at, and subsequent agent{sid} calls are rejected by assert_target_not_stopped with “hire a new one”; a new sid cannot consume the old sid's queue. Consequently the request remains outstanding and never notifies its parent unless a human manually /uses the stopped session, despite the returned resume_policy promising replay. Either settle these requests during stop or provide the delegating parent a route to resume the same sid.

AGENTS.md reference: AGENTS.md:L60-L60

Useful? React with 👍 / 👎.

// Normalized FIRST, so every branch below — validation, policy facts,
// spawn-and-dispatch, dispatch-to-sid — sees one `task` and a new one
// gets it for free.
let resolved_task_file = resolve_task_file(args)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replay idempotency before reading task_file

When a successful agent{task_file,idempotency_key} call loses its response and is retried within the replay window, this reads and validates task_file before either spawn or dispatch checks the idempotency cache. If the temporary file has since been deleted, unmounted, or made unreadable, the retry errors instead of returning the recorded result, even though the original task was already dispatched. Move the replay lookup ahead of this file I/O so idempotency_key remains a true retry contract.

AGENTS.md reference: AGENTS.md:L62-L62

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant