ACP waits for every owned Agent disposal and throws one AggregateError when any Session teardown fails. The aggregate message embedded each rejected value with String(failure) because the connection-close logger itself renders only the outer error message. String preserves only an Error name and message, so causes and AggregateError members disappeared from the operational warning.
Render each per-session rejection with the existing errorChain diagnostic helper before joining it into the outer message. The original rejected values remain in AggregateError.errors for programmatic inspection, while the message now carries cause chains and nested aggregate members through the String-based logger.
Exercise a disposal failure containing both AggregateError members and a nested cause, while retaining the existing barrier that proves the second Session finishes disposal before any warning is emitted. All 10 ACP disposal tests pass and the ACP TypeScript project builds cleanly.
The connection-close teardown path threw a bare `AggregateError` whose
message counts the failed sessions, and its only production consumer logs
through `String(error)` — which renders the message alone. Compared with
the previous `Promise.all` behavior, every actual disposal failure reason
disappeared from operational logs.
Join the per-session reasons into the aggregate message, matching the
subagent seam's own aggregate disposal messages, and pin the reason in
the dispose spec's warning assertion.
Round 1 traded one teardown ordering problem for another. The observer now
splits capture from emission, which satisfies both consumers at once:
- Terminal facts are captured while the child is still registered, so consumers
that resolve it for the child's log and scope still work.
- The edge is emitted only after handle disposal settles, so a rejecting scoped
cleanup is reported as a failed epoch instead of a successful one.
Also:
- Keep the Activation in the map until disposal settles. Removing it first let a
racing followup() see no Activation and cold-resume into the still-registered
agent, and let a concurrent forest drain skip a still-disposing child and
release its parent first.
- Derive terminal telemetry from this epoch's event suffix rather than the whole
session, so a cold resume whose prompt is blocked no longer reports the
previous epoch's answer and turn reason.
- Cancel the ACP bridge's own prompts before awaiting the descendant drain: a
drain can block on persistence, and the top-level agents must not keep running
model and tool work for its whole duration.
Pins that the bridge releases the Activation forest before its own sessions, and
that a failed drain is reported without stranding that teardown. Reads the one
teardown method structurally so the bridge keeps no dependency on the subagent
seam.
A continuable Activation outlives the turn that started it and owns descendant
teardown, so the bridge must drain that forest child-first before releasing the
top-level agents whose runtime the descendants depend on.
Also rewrites the authored continuable snapshot transcript for the Task-free
tool surface; the scenario's keyless replay is still under diagnosis.
agent-loop: behavior tests for retry-while-busy, cancelled recovery
windows, no-facts stream failures, idle-listener preemption, rejected
driver promises under whenIdle, finish-chunk failures after step close,
presentationMeta persistence, pre-aborted and torn-down create/resume
signals, and configured-start failures over existing artifacts or after
teardown. The remaining guards that no public path can reach carry
justified v8 ignore annotations naming the invariant that starves them.
acp bridge: cover the retry-adoption path (a retry turn resolves the
prompt the failed turn deferred), the no-retry quiescence rejection, and
the admission-blocked cancelled settlement; the synchronous send-throw
catch is annotated as a future-proofing guard since the machine's send()
contains listener failures.
The automation bridge inherited two master-era assumptions the message
machine no longer honors. A prompt blocked at pre-turn admission opens no
turn, so no turn/end could ever settle it — the bridge now watches
whenIdle() and reports a turnless slot as cancelled (the disposed-agent
guard moved to a registry identity check before send). A failed turn no
longer rejects at its turn/end either: agent.retry() closes the failed
turn and opens a successor on the same history, so the bridge holds the
terminal error and lets a retry-triggered turn/start adopt the prompt,
rejecting only at quiescence with no successor.
Also: refresh the empty-response-retry fixture for retry-as-turn logging,
adapt master-side tests to the unified send()/UserMessageData API and
registry-fact disposal, resync the doc pairs both sides touched, trim
architecture.md back under its word ceiling, and regenerate the event and
persistence catalogs.
ACP v1 requires every agent to accept text AND resource_link prompt
content; the automation rewrite dropped the resource_link half of that
baseline. Restore the old bracketed-reference flattening in the codec,
reject only beyond-baseline blocks, and update the package contract and
Agent Note.
Also release the per-session prompt slot when agent.send() throws
synchronously (an agent disposed outside the bridge would otherwise
wedge the session into permanent 'already in flight' rejections),
drop the tautological version-negotiation branch, prove the scenario
env layer reaches the snapshot subprocess, pin bridge-side fail-closed
permission errors, and correct two overpromising test names.
Move the 18 flat packages/<name> packages into role-grouped dirs:
core/, llm/, bash/, session-persistence/, ui/, support/. Group dirs are
pure containers; each package keeps its @deepseek-ai/dsh-* name.
Collapse the per-package tsconfig paths maps (base + typecheck) into one
@deepseek-ai/dsh-* wildcard with a candidate per group, and derive the
publint list from the hierarchy. Update all depth-coupled globs/configs
(workspace, tsdown, vitest, eslint, knip, tsconfig includes/refs,
per-package tsconfigs, generators, doc-script scopes, type-equiv manifest)
and the cross-package/script relative imports in tests.
Fix doc-typecheck's workspacePaths() to parse tsconfig JSONC via the
TypeScript API instead of a regex comment-strip, which corrupted the
new wildcard `/*/` path candidates.
WIP: doc cross-links and package/RFC docs still to update.
Co-locate the ACP feature support checklist with the bridge package
(packages/acp/acp-feature-support.md) and rewrite its relative links for
the new depth. Broaden the doc-sync globs (doc-typecheck, verify-md-wrap,
verify-md-links) from packages/*/README.md to packages/*/*.md so a
package-level doc beyond the README stays under the drift gates, and
update the AGENTS.md prose describing that scope.
The disconnect-mid-prompt test comment said "PR D's per-agent AgentHandle
teardown", narrating the change's origin. Per the repo doc-current-state
convention, state the mechanism (the session's AgentHandle teardown) without
naming the PR that introduced it.
Review follow-ups on the bash owner-token PR:
- packages/acp/README.md still described task isolation in object-identity terms
("records each background task's owning agent", "a different agent"). Rewrite
to the session-token model: ownership is by `session.header.id`, stored on the
executor's task, so a different Agent object on the same session may access it
and ownership survives a tool-bash HMR reload.
- The reviewer flagged that the notice routes by `session.header.id` while the
registry only enforces unique `agent.id`, so a programmatic caller could
register two agents sharing a session token and mis-route a notice (not
reachable via ACP). Rather than bolt a session-id invariant onto the generic
registry, add a proposed RFC (2026-06-20-unify-agent-and-session-id) to remove
the precondition by construction — an agent IS its session, one id — with a
full risks discussion (forecloses multi-session-actor / fork futures, makes the
config resume-or-create policy load-bearing, migration churn). The actual
unification ships as its own Codex-converged PR. Cross-linked from the
agent-lifecycle RFC's seam-precondition note.
- Reframe the tool-bash module-doc ownership paragraph to current-state (per the
new AGENTS.md doc convention): contrast storing the token on the executor vs
in the plugin as a standing rationale, not as "closing the old gap".
Two blocking lifecycle findings from the deep review:
- `SessionStore.enter()` is a public cross-package primitive that a caller can
separate from `prepare()` by arbitrary work, so it must re-check the id: a
stale prepared session could otherwise overwrite a live store entry of the
same id, and the stale session's detach disposer would later delete the REAL
session. Re-add the duplicate-id throw (removed earlier on a coverage
rationale that only held for the back-to-back internal caller). Tests cover
the stale-overwrite rejection and the prepare/enter/announce lifecycle (which
also covers the throw branch).
- `AgentHandle.dispose()` exposed the raw single-shot cordis effect disposer, so
a concurrent/second dispose() returned immediately (effect epoch already
cleared) instead of awaiting the in-flight teardown — violating the
dispose(): Promise<void> contract that every caller observes the same
quiescence boundary. Memoize the disposal promise in startOwned. Regression
test gates the loop's final flush, fires two dispose() calls, and asserts the
second stays pending until the first's teardown completes (fails without the
memo).
A reviewer noted the quiesce() comment + ACP README said `AgentHandle.dispose()`
stops the loop "with the queue-aware cancel", but the handle delegates to the
start-disposer's `stop(); await agent.done`, where `stop()` sets `disposed` and
aborts the current controller — it does NOT call `agent.cancel()`. The pre-step
teardown window is still closed (the disposed promise wakes the parked loop and
`isDisposed()` breaks before a turn starts), but the mechanism is the DISPOSED
path and a mid-flight turn ends with reason `disposed`, not `aborted`. Corrected
the comment and the README to describe the actual path.
(This commit follows the merge of PR C's `cancel(reason)` fix up into this branch.)
A reviewer found that `cancel(reason)` only preserved the caller's reason when
an active AbortController observed it (the mid-step path, via
`abort.signal.reason`). The marker-only windows (step-start at loop.ts and the
continuation gate) hardcoded `reason: 'cancelled'`, so the logged `turn/end`
reason was race-dependent on WHERE the cancel landed and the public
`cancel(reason?)` parameter was half-effective.
Capture the resolved reason (`reason ?? 'cancelled'`) on the agent when the
marker is armed, expose it on the LoopHandle as `cancelReason()`, and use it in
both marker branches so a turn dropped without a live controller records the
SAME `{kind:'aborted', reason}` the mid-step path produces.
The two existing window tests asserted `reason: 'cancelled'` while passing
`'from turn-start'` / `'from continuation'` — they documented the bug. Updated
both to assert the caller's reason (behavior + test changed together, per
AGENTS.md "tests document behavior, not golden truth").
Also fixes two stale docs the PR's contract change left behind: the
module-level ACP mapping comment and `codec.ts` both still said `session/cancel
-> agent.abort()`.
Codex found a real teardown-leak (A): the AgentHandle's composite effect runs
its disposers as a `.then()` chain, and the register disposer emitted
`agent/disposed` UNCONTAINED. A throwing listener rejected the chain, skipping
the LATER session-detach disposer — stranding the session in the store with
`onAppend` attached (a leak AND a durability hole, since the new composite
design relies on detach running). Verified by tracing fiber.ts:299-301
(`task = task.then(dispose)`) against the yield order in AgentLoop.start.
Wrap the disposer's `agent/disposed` emit in try/catch + logger.warn (the
store entry is already removed before the emit — the useful state is captured
— so logging and continuing is correct, mirroring the guarded `agent/status`
emit in ReactLoopAgent). The sibling `agent/created` emit stays uncontained on
purpose: its throw is MEANT to propagate and roll the registration back.
Regression test (acp dispose.spec): register a throwing `agent/disposed`
listener, drive a clean turn, dispose, assert the session was STILL removed.
Confirmed it FAILS without the guard (the throw escapes dispose and detach is
skipped) and passes with it.
Also (B): document the new `prepare`/`enter`/`announce` ordered-teardown
lifecycle primitives in the dsh-session README (they are public cross-package
methods now consumed by dsh-agent-loop).
A stronger durability test (dispose MID-turn, then re-load from disk) caught
that the original two-sibling-effect design dropped the loop's closing
`turn/end` on the bare fiber-dispose path: a fiber unload disposes sibling
effects CONCURRENTLY (`Promise.all`, vendor/cordis/fiber.ts), so the
session-create effect detached `onAppend` racing the loop's final
`session/flush` — the re-loaded log showed crash-recovery's synthetic
`interrupted` closer instead of the real `disposed` reason. The disconnect
path happened to work (only `quiesce()` ran), but the contract must hold
uniformly.
Fix: fold the session lifecycle INTO the agent's single composite effect.
`SessionStore` now exposes `prepare` (validate + construct, no store entry),
`enter` (attach onAppend + store, returns detach), and `announce` (emit
session/created), replacing the sibling-effect `createOwned`. `AgentLoop.start`
builds ONE effect that yields, in order: session-detach, register, then
stop-and-`await agent.done`. LIFO disposal runs them as an ORDERED chain (the
runtime awaits each disposer's promise before the next), so the loop is
stopped and awaited to exit — its closing flush captured through the still-
attached onAppend — BEFORE the session detaches, whether the trigger is the
handle's dispose() OR a fiber unload. The config path uses prepare()+start
too, so it gets the same ordered teardown. All three factory entrypoints now
funnel through the one composite builder.
The mid-turn durability test asserts the REAL `disposed` reason lands on disk
(not a recovered `interrupted` substitute), proving the closing event was
captured rather than reconstructed.
The bridge now holds each session's `AgentHandle` disposer in its
`SessionRecord` and runs it on teardown (client disconnect or fiber dispose)
instead of the old `abort()` + `whenIdle()` drain that left agents
registered. A bare client disconnect now leaves NO registered agent and NO
session-store entry — not an idled-but-still-registered one. The queue-aware
`cancel()` inside the disposer also closes the former pre-step best-effort
window (a turn about to start is dropped), so teardown reaches true
quiescence.
The `session/load`-races-teardown leak is fixed: if the bridge closed while
`resume()` was pending, the just-resumed handle is disposed before throwing,
so it leaves no orphan (it has no SessionRecord, so quiesce() never sees it).
Tests: the disconnect test now asserts (through the SAME memoized teardown)
that the agent is unregistered AND its session removed; a durability test
re-loads the persisted log after dispose and asserts the closing turn/end is
on disk (guards the teardown-order contract); a sibling-isolation test proves
one handle's dispose() leaves other agents untouched. Docs: agent /
agent-loop / acp READMEs, architecture.md, and the stale in-code quiesce()
ownership comment updated to the per-agent disposal model; the now-resolved
TODO(rfc010-agent-disposal) / TODO(rfc010-cancel-prestep) teardown notes
removed.
abort() only kills the in-flight step, so a queued-but-not-yet-started prompt
ran to completion after a cancel and a prompt accepted right after could be
batched into the cancelled turn (the loop merges queued messages into one turn).
This closes TODO(rfc010-cancel-prestep) with a distinct cancel() verb.
cancel() clears the queued + steering FIFOs, aborts the in-flight step, and
drives a turn-scoped marker on the LoopHandle that the driver checks at EVERY
point a turn could start or continue:
- right after the idle wait (window 1): drop the about-to-run turn and settle
whenIdle() waiters directly (no running→idle transition fires, and no
agent/status is emitted, so an ACP listener can't see a spurious idle that
resolves a freshly-queued prompt as cancelled);
- after the synchronous setStatus('running') emit (window 2): a running listener
can cancel in the gap before runTurn;
- in the step-start window (before runStep, after setAbort): a synchronous
turn-start/step-start listener can cancel before any AbortController exists;
- at the continuation gate: a cancel during the continuation waterfall (the
finished step's controller already cleared) ends the turn aborted.
The marker is ARMED only when there is something to cancel (running, an
in-flight step, or queued/steering work) — an idle no-op cancel cannot leave it
set to drop a later prompt — and RESET unconditionally once per loop iteration,
so it governs exactly one turn and never leaks onto the next prompt (even when a
send() lands in the cancelled turn's flush window).
ACP session/cancel now maps to agent.cancel() (keeping the synchronous
settlePrompt). Teardown/disconnect still use abort('disposed') until PR D, so
the ACP README narrows the remaining best-effort window to teardown only.
Tests (agent-loop/cancel.spec.ts) cover every window unit-level (the F1 hang
guard: a whenIdle() waiter registered before a pre-step cancel resolves; the F2
leak guard: idle cancel then a prompt runs; mid-step, continuation, both
pre-step windows, turn-start-listener, steering-cleared, marker-reset). ACP
turns.spec.ts adds the through-bridge tests with NO intervening whenIdle (idle
cancel→prompt runs; mid-stream cancel→immediate next prompt runs) and updates
the stale pre-step test to the queue-aware guarantee. The existing cancel
snapshot golden is byte-identical (it drives the new cancel() path end-to-end
through the real subprocess), so no new golden is needed. 100% coverage.
SessionSummary (updatedAt/title/firstPrompt) and SessionPersistence.update()
were dead state: zero production callers of update(), no production reader of
updatedAt/firstPrompt, and ACP's title comes from a tool-call presenter, not
storage. The live Session.header was already typed SessionHeader, so the
summary only ever existed in the persistence layer, written and read by nothing
but its own contract test.
Delete it entirely (no SessionMeta alias — SessionMeta collapses to
SessionHeader everywhere). This removes the JSONL .summary.json sidecar
machinery, the SQLite title/first_prompt/updated_at columns and per-append
updated_at bump, and the update() method from the abstract service and both
backends. SQLite SCHEMA_VERSION goes 1->2 and openDatabase now rejects any
non-current user_version (older or newer) — no migration, unreleased software.
Net -400 lines, and it erases the JSONL-sidecar-vs-SQLite-column durability
divergence that the upcoming write coordinator would otherwise have to model.
Records the decision in docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md
and migrates the 2026-06-14 session-persistence RFC's facts to current truth.
Adds a standalone AGENTS.md section "Tests document behavior, not golden truth"
(a passing test pins current behavior, not necessarily correct behavior) with
the summary-drop as its worked example, and reinforces the no-migration
pre-release stance.
Rename the concrete Agent class to make its ReAct-style reasoning loop
explicit in the name. Package name, default-export plugin (`AgentLoop`),
and the `ctx.agentLoop` service key are unchanged.