Skip to content

fix(sdk,acp,agent,gc): twelve SDK lifecycle, ACP session-survival, and disk-retention defects - #4021

Closed
probepark wants to merge 19 commits into
Yeachan-Heo:devfrom
probepark:fix/probepark-issue-batch-20260808
Closed

fix(sdk,acp,agent,gc): twelve SDK lifecycle, ACP session-survival, and disk-retention defects#4021
probepark wants to merge 19 commits into
Yeachan-Heo:devfrom
probepark:fix/probepark-issue-batch-20260808

Conversation

@probepark

@probepark probepark commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Twelve fixes for SDK lifecycle, ACP session survival, provider loops, and disk retention. Every one was reproduced on a real machine during a single overnight batch of parallel agents, and the batch itself is what surfaced most of them — agents kept dying in ways the reported issues predicted.

What this fixes

Commit Issue Defect
18e303083 #4011, #3900 Anthropic thinking-replay repair looped unbounded — 121 rejected requests over 29 min, then silence
20f21fa00 #3639 !cmd output typed while the agent is Working vanished from the transcript
52555a2ee #3963 Stale broker lock tombstones never reaped; 126 MB corrupt-ledger sidecar; silent code=0 broker exit
5d4b70dd0 #4014 Chat daemon attached sessions kept the 175 ms reconnect budget #4012 fixed for ACP
0a3c4b739 #4013 Memory phase-1 LLM jobs ran inside the broker readiness window → No ready SDK endpoint remains available.
060e90ff2 #4019 One transcript entry without a body made session/load fail permanently for the whole session
8fd783604 #4010 Session hosts leaked indefinitely under a healthy broker
cc4c09c43 #4020 A tool call resolved to exactly one active tool was rejected instead of dispatched
c9d5c4535 #4018 A prompt whose producer went silent never settled — client hung on a dead turn
df2f8cf24 #3903 session/new intermittently reported terminal_uncertain, permanently poisoning an ACP host's provider
764b01983 #3853 Nothing reclaimed disk: 13 GB under ~/.gjc in three weeks, unreachable from any command
d94aa8673 sdk-acp-provider-reconnect exhaustion test burned the real 41.75 s budget and timed out at 5 s on origin/dev

Plus two generated/contract updates required by the above: 989ced538 regenerates the telegram generation manifest for the attach digest (via --write-manifest, not hand-edited), and ea49cd930 bumps CHAT_DAEMON_GENERATIONS because attach() is a protected lifecycle declaration — an owner on the old generation gives up reconnecting after 175 ms and permanently loses its attachment.

Two defects that were live during this batch

#4013 is why parallel agents intermittently refused to start. Memory startup issues one LLM request per queued rollout, so its duration scales with the backlog; running it inside the 10 s readiness window killed the child at the cutoff. Deferring it past readiness fixed agent creation immediately. The secondary half — handleFatalError collapsing a thrown record to [object Object] — is why six earlier occurrences left no diagnostic at all.

#4018 / #4019 together are why a dropped transport was unrecoverable: the prompt never settled (client hangs), and the documented recovery through session/load died on the first entry without a production body (session unloadable). Either alone is bad; together they made paseo stop the only exit.

Verification

Each slice was verified independently rather than trusted from its agent's self-report, including a with-fix / without-fix comparison per regression test:

sdk-lifecycle-memory-defer          20 pass  (0 pass / 1 fail without the src change)
sdk-session-host-idle-reap           7 pass  (0 pass / 1 fail without)
acp-prompt-watchdog                  4 pass  (0 pass / 4 fail without)
acp-transcript-replay-degradation    5 pass  (1 pass / 4 fail without)
sdk-lifecycle-terminal-evidence      5 pass  (2 pass / 3 fail without)
agent-loop-tool-call-alias-dispatch  8 pass  (2 pass / 6 fail without)
bash-command                         9 pass  (4 pass / 5 fail without)
gc-disk-retention                   15 pass  (0 pass / 1 fail without)
anthropic-thinking-repair-budget    +retry  18 pass
chat-daemon-session-reconnect       +acp    30 pass
sdk-broker-lock-artifacts           +broker 77 pass

Suite level on this branch:

  • packages/agent — 702 pass / 0 fail
  • packages/coding-agent/test/acp + watchdog — 164 pass / 0 fail
  • broker/lifecycle suites — 138 pass / 0 fail
  • gc suites — 39 pass / 0 fail
  • the 10 test files this PR touches — 56 pass / 0 fail
  • check:schemas clean, lint:ts clean, check clean for ai / agent / utils / tui / coding-agent
  • telegram-daemon-generation-guardv43 required generation bump verified

Pre-existing failures, confirmed identical on clean origin/dev

Verified by running the same command on a detached origin/dev checkout, not assumed:

  • check:sdk-closure canonicalization: 25 violations, introduced by feat(sdk): expose model profiles as synthetic gajae-code/<profile> models #3988 (6e45efead) via acp-agent.ts → model-profile-model.ts → model-registry.ts → settings.ts. Violation target set is byte-identical between origin/dev and this branch.
  • credential-rotation-session.e2e — 1 pass / 1 fail on both.
  • session-storage — 76 pass / 4 fail on both.
  • anthropic-* — 7 failures on both (this branch adds 4 passing tests: 223→227 pass, 7 fail unchanged).
  • /copy command — 2 failures on both.
  • lint:rs clippy errors in crates/pi-natives; this PR touches no Rust.

The reconnect-exhaustion timeout was also pre-existing, but it is fixed here (d94aa8673) because #4012 caused it and #4014 extends the same budget: 41.9 s → 96 ms.

Not included

#3798 stays open pending device evidence — three of its four contributing paths are already fixed by #3806, and no further source change is justified until someone reproduces it on the iOS client.

Closes #3639, #3853, #3903, #3963, #4010, #4011, #4013, #4014, #4018, #4019, #4020

One ACP session burned 121 rejected requests over 29 minutes and then went
silent: every repair reset the provider retry budget it was meant to consume,
and the per-call repair flags let a layer above re-enter the same latest/all
pair ~60 times against a request the provider would never accept.

The repair budget now lives in providerSessionState, so it survives stream
re-invocation for the same session, and a "blocks cannot be modified" 400 is
answered by dropping native thinking replay entirely instead of mutating the
very blocks the provider demands verbatim.

Lore-id: 7c3f9a12
Constraint: repairs must not renew PROVIDER_MAX_RETRIES -- they are bounded independently
Constraint: a turn that stops must emit a terminal error frame carrying the provider message
Rejected: raise PROVIDER_MAX_RETRIES | the request shape is unacceptable, more retries only widen the loss
Rejected: keep escalating latest -> all on the mutation 400 | the provider states those blocks must be replayed verbatim, so mutation can never converge
Confidence: high
Scope-risk: narrow
Reversibility: easy
Directive: do not reset providerRetryAttempt from any degradation branch
Tested: mutation 400 degrading into masked api_error stops at 2 requests with a terminal error
Tested: repair budget survives stream re-invocation sharing providerSessionState
Not-tested: live CLIProxyAPI replay against api.anthropic.com
A `!cmd` typed while the agent was Working ran, then vanished: completion
detached the block from the pending container without ever re-parenting it,
so it lived on in a plain array with no parent and was never drawn. Ordinary
mid-turn events made it worse — `updatePendingMessagesDisplay()` and the
transcript rebuild both called `Container.clear()`, which disposes children,
tearing down a still-streaming block and dropping its buffered output.

Completion now moves the block into the chat transcript, and both rebuild
paths detach-and-reattach parked execution components instead of disposing
them. `Container.clear()` keeps its disposing contract; the retention lives
in a private helper on the coding-agent side.

Lore-id: 5b81e40c
Constraint: Container.clear() must keep disposing children -- other callers depend on it
Constraint: a running block must stay parented while it streams, not be flushed early into chat
Rejected: flush parked components on the streaming submit path | an in-flight block would jump into the transcript before it finished
Rejected: change clear() to detach instead of dispose | silently leaks every other container's children
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: `!` submitted mid-turn is parented while streaming and lands in the transcript on completion
Tested: pending-queue refresh and transcript rebuild no longer dispose a running execution block
Not-tested: interactive TUI smoke on a real terminal
…uggable

A machine accumulated 54 `.broker.lock.stale-*` tombstones, a 126 MB
`lifecycle-ledger.jsonl.corrupt` next to a 6 MB live ledger, and a dead-owner
lock that wedged broker startup. The reclaim path renamed dead locks to
tombstones but nothing ever removed them, and the quarantine sidecar was
appended to forever.

The broker now reaps abandoned lock artifacts on startup after it owns the
lock, the corrupt sidecar rotates at a bounded size, and the spawn no longer
throws away its own diagnostics: `stdio: "ignore"` is replaced by a truncated
per-spawn log whose stderr tail is attached to the discovery failure.

Lore-id: 9d24af61
Constraint: reaping is fail-closed -- owner-alive, unreadable, or ambiguous artifacts are kept with a reason
Constraint: only the lock holder reaps, so concurrent brokers cannot race the removal
Constraint: corruption evidence must survive rotation, not be silently dropped
Rejected: reap before acquiring the lock | racing brokers would delete each other's artifacts
Rejected: delete the corrupt sidecar outright | destroys the only evidence of what was quarantined
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: aged tombstones removed, live-owner and unreadable artifacts retained with reasons
Tested: corrupt quarantine rotates at the cap instead of growing unbounded
Tested: a broker that exits before discovery now reports why
Not-tested: multi-machine concurrent broker contention
… budget

The chat daemon holds its attached-session clients until an explicit detach or
endpoint roll, under the same host liveness reaper that Yeachan-Heo#4012 fixed for ACP, yet
attach() still dialed with the transport's one-shot defaults -- 3 attempts at
25ms base, 175ms total. The host drops any session not ponged within
HEARTBEAT_TTL_MS (20s), so every stall long enough to be reaped left the
attachment permanently lost. ACP_SESSION_RECONNECT is hoisted out of the ACP
adapter into src/sdk/session-reconnect.ts so the bus layer can reach it without
importing the ACP layer, and attach() now connects on that shared budget.

Lore-id: 5b3e1c74
Constraint: exactly one definition of the budget -- ACP call sites keep importing it
Constraint: the bus layer must not import sdk/acp -- the constant moves, not the dependency
Rejected: put it in bus/daemon-paths.ts | that module is deliberately paths-only and would gain an SdkClientOptions dependency
Rejected: raise the bridge-client transport defaults | correct for the one-shot request clients that already rely on them
Confidence: high
Scope-risk: narrow
Reversibility: easy
Directive: leave the per-request broker client on the one-shot defaults -- it closes in its own finally
Tested: the real ChatDaemonRuntime attach path driven to reconnect exhaustion on a fake transport and clock, asserting the concrete 250/500/1000/2000 schedule and a cumulative budget of 41750ms
Not-tested: recovery against a host that comes back mid-schedule
Every broker-launched session ran the local memory backend's phase-1 stage-1
LLM jobs synchronously inside the 10s readiness window. Whenever the queue had
pending rollouts, those calls ate the whole budget, the child was killed at the
cutoff, and the broker answered "No ready SDK endpoint remains available." Six
such failures landed in ~15h; the same shape recurred while running this batch.

Lifecycle sessions now defer memory startup as an invariant and resume it once
readiness is published, unawaited, with failure logged rather than fatal. The
ACP carve-out in main.ts goes away with it, and postmortem stops collapsing a
thrown record to "[object Object]" so the next cutoff keeps its phase/reason.

Lore-id: 3f6ad920
Constraint: session readiness must never depend on LLM calls -- startup work is unbounded
Constraint: memory failure after readiness is a degraded-memory condition, not a startup failure
Rejected: widen READY_TIMEOUT_MS | startup work scales with rollout backlog and provider latency, so any constant is just a wider flake window
Rejected: keep deferral a caller option for lifecycle sessions | the broker deadline makes it an invariant, not a choice
Confidence: high
Scope-risk: narrow
Reversibility: easy
Directive: never await memory startup on a path that publishes readiness
Tested: readiness is published before a memory start that outlives the budget resolves
Tested: a post-readiness memory rejection neither tears down the session nor becomes an unhandled rejection
Tested: a non-Error throwable keeps its payload in the crash record
Not-tested: live broker under a real multi-day rollout backlog
… load

A session whose transport dropped could never be recovered: `session/prompt`
answered `Unknown session, not found`, and the documented recovery through
`session/load` then died on `ACP cannot replay a transcript entry without its
production body`. One malformed row revoked `loadSession` for the entire
session, and the agent ended `closed` with its work unreachable — reproduced
across a broker restart and a fresh binary.

Replay now decides per entry: an entry without its production body is skipped
and reported through the same `_meta` boundary channel that already reports
unavailable historical images, so a session with zero replayable rows still
loads.

Lore-id: c4e7b285
Constraint: never fabricate an empty body -- that replays a message that never existed
Constraint: gjc advertises loadSession: true, so load must not be revocable by one bad row
Rejected: keep throwing and let the client retry | the failure is deterministic, every retry dies identically
Rejected: substitute a placeholder body | silently invents transcript content the user never wrote
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: one bad entry among good ones -- good ones replay, the skip is reported with count and reason
Tested: every entry unreplayable -- load still succeeds with zero replayed messages
Tested: healthy transcripts replay with an unchanged update sequence
Not-tested: recovery against a live host that dropped mid-turn
Detached session hosts had no exit path while the broker stayed alive. The one
automatic reaper, watchSessionHostBrokerLiveness, is conditioned on broker
discovery going ABSENT, and the default warm broker never disappears — so the
grace window never opened. The other leg, session.close, is never issued by
ordinary SDK usage. The reporter measured 119 leaked hosts holding 4.1 GB under
one healthy 6-day broker; this machine reached 29 hosts for 9 agents with 20 of
them holding zero connections, within hours.

Hosts now self-reap after a bounded idle interval with no attached client, and
the broker drops registrations whose host is provably gone. The broker-absence
watcher is untouched; this is a second, independent bound.

Lore-id: e81c4a37
Constraint: a host that has never seen its first attachment must not be reaped -- the client may still be dialing
Constraint: attachment is decided from the host's own subscription state, never by scraping ps or lsof
Constraint: detached + unref stays -- the spawn shape is deliberate, the missing bound was the bug
Rejected: rely on session.close alone | ordinary SDK usage never issues it, which is exactly how the leak starts
Rejected: cap concurrent hosts | evicts live sessions under legitimate parallel load
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: a host whose client detaches exits after the idle bound
Tested: a host with an attached client is never reaped, driven well past the bound
Tested: a freshly spawned host survives the first-attach grace
Tested: broker-absence behavior unchanged
Not-tested: multi-day accumulation against a live warm broker
The harness already resolved these calls with certainty and then refused to run
them: `Tool mcp__<server>__<instance>_search not found. It is active as
`search``. Every occurrence burned a whole model turn, and it recurs constantly
because bridges mint a fresh instance segment per session, so a name replayed
from earlier context differs from the live registry only in that segment.

When the namespace-stripped base name resolves to exactly one active tool, the
call is now dispatched to it and the rename is recorded. Zero or multiple
candidates keep today's error, unchanged and still naming every candidate --
guessing between two tools would route the model at one it did not ask for.

Lore-id: 2a9f6d31
Constraint: resolution stays the existing exact namespace-stripped base match -- no fuzzy matching
Constraint: validation, hooks, permissions and telemetry run against the resolved tool
Rejected: keep only improving the error text | Yeachan-Heo#3917 did exactly that and the turn is still lost
Rejected: pick the first of several candidates | silently runs a tool the model did not name
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: a stale bridge namespace with one active match executes, with arguments intact
Tested: two candidates still error and list both names
Tested: no candidate still reports the unchanged base not-found message
Not-tested: live bridge reconnect churn across a long session
An ACP prompt settled only on a terminal frame. There was a rejection path for
a lost owner connection and one for a malformed terminal, but none for "the
producer stopped and no terminal will ever arrive", so `activePrompt` stayed
unsettled forever and `session/prompt` never returned. Captured twice in one
hour on this machine: host alive at 0% CPU, only the loopback socket left, last
log line then silence, while the client reported the agent running for 21 more
minutes. One host had already produced its complete final answer.

A per-prompt inactivity watchdog now settles the prompt with a diagnosable
terminal error naming the silence duration and the last frame seen. It settles
the ACP prompt only; it never cancels the agent's underlying work.

Lore-id: 8e5c1f43
Constraint: the bound is derived from MAX_TOOL_RUNTIME_MS + 10 x HEARTBEAT_TTL_MS, never a magic number
Constraint: any frame for the prompt refreshes the watchdog, so slow-but-healthy work is untouchable
Constraint: the session must still accept a subsequent prompt after a watchdog rejection
Rejected: cancel the agent run on expiry | the work may be fine, only its terminal frame is missing
Rejected: a short fixed timeout | a single long bash or ssh call would trip it
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: silence past the bound rejects the prompt instead of hanging
Tested: frames arriving just under the bound, repeatedly, never trip it
Tested: a normal agent_end settles exactly once and the watchdog does not double-settle
Not-tested: a live host going silent against a real model gateway
`session/new` failed intermittently with "Lifecycle terminal evidence could not
be verified after persistence" — ~29% in a large tree, 0% in small ones, and
non-deterministic. One failed probe permanently poisons an ACP host: the
provider goes `status: "error"` for the daemon's whole lifetime, so gjc cannot
be selected until the host restarts. Observed on this machine today.

The race: a recovery pass stamps `terminal_uncertain` on every row it finds
mid-flight, so a slow operation can have that marker interleaved before its own
terminal row lands. `readTerminal` treated only `terminal_ok`/`terminal_error`
as terminal, so it reported a row that WAS on disk as unpersisted and replaced
its real reason with the generic uncertainty error. Reading back the durable
record fixes the report at its source.

Lore-id: b6d02e59
Constraint: genuinely unverifiable evidence must still report terminal_uncertain -- the signal is not suppressed
Constraint: a proven terminal row stays immutable and admits no successor
Rejected: retry the verification | the slow path is normal on a large tree, so it must be handled, not out-waited
Rejected: widen the startup scan deadline | being slow on a big tree is legitimate, not the defect
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: the owner's terminal record is read after a concurrent recovery stamps its in-flight row
Tested: a durable terminal_uncertain is read back instead of reported unpersisted
Tested: proof is still withheld when a persisted row cannot be reproduced
Not-tested: live repeated session/new against a multi-gigabyte home directory
Giving the chat daemon's attached-session client the long-lived reconnect
budget changed `chat-daemon-runtime.ts:attach`, so its declaration digest moved
for the discord and slack surfaces. Regenerated with --write-manifest rather
than hand-edited.

Lore-id: 4d17b9e2
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: check:sdk-closure
The exhaustion test dialed a dead endpoint with the production budget, which
Yeachan-Heo#4012 deliberately grew to outlive the host heartbeat TTL (41.75s of real
backoff). It has been timing out at 5s on origin/dev ever since, so
check:sdk-closure fails on a clean checkout. Injecting a one-shot client keeps
the assertion about the typed rejection and drops the run from 41.9s to 96ms.

Lore-id: 7a2c8e04
Constraint: the budget itself stays asserted from its constants in acp-session-reconnect.test.ts
Rejected: raise the test timeout past 42s | pays 42s of wall clock on every run to assert an error code
Rejected: shrink the production budget | it is sized against HEARTBEAT_TTL_MS on purpose
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: the file now runs 2 pass / 0 fail in 96ms
@probepark
probepark force-pushed the fix/probepark-issue-batch-20260808 branch from d94aa86 to 9f7fc39 Compare August 7, 2026 21:40
`attach()` is a protected chat-daemon lifecycle declaration, so changing it to
dial on the long-lived session reconnect budget requires a strictly higher
generation: an owner still running the old code gives up reconnecting after
175ms and permanently loses its attachment, and the generation is what stops it
from serving requests captured against the new contract.

Lore-id: 6f21b8ac
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: telegram-daemon-generation-guard against origin/dev
@Yeachan-Heo

Copy link
Copy Markdown
Owner

CI is currently blocked by Telegram daemon generation guard (run 31220975129, job 93005401778). Please inspect and repair the generation-guard failure on the exact head, then push a fresh CI run. No merge review can proceed while this required check is red.


[repo owner's gaebal-gajae (clawdbot) 🦞]

`gjc gc` was a liveness reaper only: it removes a record when its owning PID is
provably dead, and nothing in GJC ever reclaimed bytes. One daily-driver
workstation reached 13 GB under ~/.gjc in three weeks — 6.9 GB of session
transcripts, 371 MB of blobs, 552 MB across 14 cached native versions, 717 MB
of update backups — none of it reachable through any command. The only remedy
was `rm -rf` with hand-written find predicates against a live session store.

`gjc gc --disk` reports reclaimable bytes per surface and mutates nothing;
`--disk --prune` performs it. Session retirement is reference-aware and releases
the retired transcripts' blobs through a mark-and-sweep of the canonical store.

Lore-id: 1c93e7f5
Constraint: the existing PID-liveness contract and exit-code policy are untouched when --disk is absent
Constraint: dry-run by default -- deleting resumable user work requires an explicit --prune
Constraint: fail-closed -- live, referenced, permission-denied or ambiguous entries are kept, with a reason
Rejected: auto-GC on startup | an implicit deletion pass over resumable work must never be a startup side effect
Rejected: age-only pruning | orphans blobs and races a live session index, which is what users improvise today
Confidence: high
Scope-risk: moderate
Reversibility: migration-needed
Directive: never delete a session referenced by an active lease or recently resumable
Tested: --disk without --prune mutates nothing and reports per-surface bytes
Tested: a blob referenced by a surviving session outlives the retirement of another that shared it
Tested: natives retention keeps the running version plus the configured predecessors
Not-tested: reclaiming a multi-gigabyte real store end to end

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Blocking reconnect regression:

ChatDaemonRuntime only installs client.onFrame() when it attaches a session. SdkClient does not automatically open a replacement socket after an active socket closes; it merely retires the active incarnation and reconnects when a later request/connect call occurs. The new longer budget therefore covers only an initial dial (as the added test exercises) or a later command. It does not restore the passive event subscription after an established chat attachment disconnects, and any later command-triggered reconnect does not replay from the prior cursor. Chat notifications silently stop after a transient connection loss, contrary to this change’s session-survival premise.

Register a reconnect path for attached clients that replays events from the last acknowledged generation/sequence (or reattaches through the normal endpoint-generation fence), and add an integration test that drops an already-active socket, restores it, and verifies delivery/replay before merging.


[repo owner's gaebal-gajae (clawdbot) 🦞]

@probepark probepark changed the title fix(sdk,acp,agent): eleven SDK lifecycle and ACP session-survival defects fix(sdk,acp,agent,gc): twelve SDK lifecycle, ACP session-survival, and disk-retention defects Aug 7, 2026

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Still blocking: current head does not resolve the active-socket reconnect blocker.

Verification of head 764b01983ab4dd7e09cb3a28b8fb3c3965da51a1:

  • The successor commit is GC/disk-retention only. git diff ea49cd93..764b0198 over packages/coding-agent/src/sdk/, packages/coding-agent/src/modes/acp/, chat-daemon-session-reconnect.test.ts, acp-session-reconnect.test.ts, and sdk-acp-provider-reconnect.test.ts is empty — none of the reconnect/replay sources or tests changed after the previous review.
  • chat-daemon-runtime.ts:461-491 still installs client.onFrame() only inside attach(), and the only client creation path is SdkClient.connect(...) with the longer budget (session-reconnect.ts). Nothing watches for an established socket closing, re-opens a replacement socket, re-installs the passive frame subscription, or replays from the last acknowledged generation/sequence.
  • The added chat-daemon-session-reconnect.test.ts has a single test asserting the connect-dial budget (reconnect_exhausted, FakeWebSocket.instances === ACP_SESSION_RECONNECT.reconnectAttempts + 1). It never drops an already-attached, already-active socket and verifies delivery/replay resumes.

The longer budget therefore still covers only an initial dial or a later command-triggered connect; an established chat attachment that loses its socket still loses its passive event subscription silently, and a later reconnect does not replay from the prior cursor. Requested before merge: a reconnect path for attached clients that reattaches through the endpoint-generation fence and replays from the last acknowledged sequence, plus an integration test that drops an already-active socket, restores it, and verifies delivery/replay.

— re-review at current head, exact-sha verification; prior blocker unchanged.

Giving the attached-session client the long-lived reconnect budget covered the
initial dial only. `SdkClient` never opens a replacement socket on its own — it
retires the closed incarnation and re-dials on the next `connect`/`request` —
and a chat attachment is purely passive, so a transient drop silently ended
delivery for good and any later command-triggered reconnect resumed from
nowhere. Chat notifications just stopped, which is the opposite of the
session-survival premise the budget change was made for.

The attachment now keeps its socket dialed through `onReconnect` and replays
from its own cursor on the way back, fenced exactly as `attach()` already
fences: this session, this endpoint generation. A superseded attachment is
disposed rather than resurrected.

Lore-id: 3ba7c209
Constraint: replay is fenced by session id and endpoint generation -- a stale incarnation must never replay onto a newer one
Constraint: the cursor advances on every delivered frame, including filtered ones, or a reconnect re-delivers them interleaved
Constraint: revival lives outside #pending -- the reconnect budget outlives the heartbeat TTL and stop() must not wait for it
Rejected: auto-reopen inside SdkClient | changes reconnect semantics for every consumer, including one-shot request clients
Rejected: reattach from scratch on every drop | loses the cursor and re-delivers the whole backlog
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: an established attachment loses its open socket, regains it, and resumes from the last acknowledged event
Tested: a superseded endpoint generation disposes the old attachment instead of resuming it
Tested: the connect-dial budget assertion is intact and unweakened
Not-tested: a live chat daemon across a real network partition
Resuming an established attachment through onReconnect changed
`chat-daemon-runtime.ts:attach` again, moving its declaration digest for the
discord and slack surfaces. Regenerated with --write-manifest, not hand-edited;
the generation bump from the earlier attach change still covers this.

Lore-id: 8c4f2d71
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: telegram-daemon-generation-guard against origin/dev -- v43 required generation bump verified
@probepark

Copy link
Copy Markdown
Collaborator Author

Blocker addressed in 9a21b2cb2 (fix(sdk): resume an established chat attachment after its socket drops).

You were right, and the finding reproduced exactly as described: onFrame() was installed only inside attach(), SdkClient never reopens a retired socket on its own, so the longer budget covered the initial dial and nothing else.

What landed:

  • The attachment keeps its socket dialed through client.onReconnect — the same mechanism sdk/acp/adapter.ts already relies on — instead of a parallel one. ChatDaemonSdkClient gains optional onReconnect/connect so command-scoped one-shot clients are unaffected.
  • AttachedSession carries a replay cursor that advances on every delivered frame, including ones the downstream filters drop; otherwise a resume re-delivers them interleaved.
  • On reconnect the attachment replays from that cursor, fenced exactly as attach() already fences: this session id, this endpoint generation. A superseded attachment is disposed, never resurrected.
  • Revival runs outside #pending deliberately — the reconnect budget outlives the heartbeat TTL and stop() must not wait on it; closing the client aborts it.

packages/bridge-client is untouched: changing reconnect semantics there would hit every consumer, including one-shot request clients.

Requested integration test is chat-daemon-session-reconnect.test.ts, which now drops an already-open socket, emits during the outage, restores it, and asserts delivery resumes from the last acknowledged event:

an attached chat session reconnects on a budget that outlives the host heartbeat TTL
an established chat attachment that loses its open socket resumes from its last acknowledged event
a superseded endpoint generation disposes the old attachment instead of resuming it

Verified by me, not taken from the agent's report:

  • chat-daemon-session-reconnect.test.ts3 pass / 0 fail; with only packages/coding-agent/src stashed: 2 pass / 1 fail, failing precisely on the resume case. The connect-dial assertion (reconnect_exhausted, FakeWebSocket.instances === ACP_SESSION_RECONNECT.reconnectAttempts + 1) is intact and unweakened.
  • chat-daemon-session-reconnect + sdk-chat-daemon-control-frames + daemon-control + acp-session-reconnect + sdk-acp-provider-reconnect + acp-prompt-watchdog199 pass / 0 fail.
  • bun --cwd=packages/coding-agent run check — exit 0.
  • telegram-daemon-generation-guardv43 required generation bump verified. attach moved again, so d55aabe15 regenerates the manifest via --write-manifest; the existing generation bump covers it.

The rejection listed the allowed keys but never said which key was wrong, so a
caller could not tell what to remove and a retry reproduced the failure
verbatim. Two agent sessions burned turns on it today, both by passing `note` —
`op: "note"` is valid and its body field is `text`, an easy and repeatable
confusion.

`hasUnknownKeys` returned a boolean and threw the offending keys away, so
nothing downstream could name them. It now returns the keys, the rejection
carries them as structured detail, and the thrown message appends them to the
existing guidance. A correction is suggested only where the mapping is exact.

Lore-id: 5e8b3c47
Constraint: the allowed key set is unchanged -- the schema was right, only the diagnostic was wrong
Constraint: codes that carry no detail (the ask-* family) keep their exact current message
Rejected: accept `note` as an entry key | would silently drop the note body, since the field is `text`
Rejected: fuzzy/edit-distance suggestions | a confident wrong suggestion is worse than naming the key and stopping
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: a `note` key is named and pointed at `{ op: "note", text }`
Tested: unknown keys with no exact mapping are named without a suggestion
Tested: multiple unknown keys on one entry are all named
Tested: root and init-list-entry rejections name their offending key
Tested: `content` still works as the `task` alias, and ask-* messages are unchanged
@probepark
probepark requested a review from Yeachan-Heo August 8, 2026 01:29
The watchdog was sized at MAX_TOOL_RUNTIME_MS + 10 x HEARTBEAT_TTL_MS = 63.3
minutes, derived from the bash/ssh ceiling a caller may *request*. That is the
rarest possible case, so the safety net could not react to an ordinary hang:
every stuck prompt this session had to be killed by hand long before it fired.

The bound now follows evidence. A frame-free gap with nothing executing is held
to the default tool runtime plus one reconnect budget (5.67 min); the wide
worst-case bound applies only while the host has an unfinished tool call, and
lapses as soon as that evidence does. A long bash is protected by the fact that
it is running, not by a constant sized for a tool that might run.

Lore-id: 4f7d1e83
Constraint: both bounds stay derived from named constants -- no magic literals
Constraint: the wide bound must terminate -- no tool may outlive MAX_TOOL_RUNTIME_MS without publishing tool_execution_end
Rejected: divide the old constant by a factor | picks a number with no defensible relationship to any healthy interval
Rejected: keep one bound sized for the slowest possible tool | blind to every ordinary hang, which is the case that actually happens
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: a tool running past the narrow bound does not trip the watchdog while it runs, and becomes eligible once it ends
Tested: silence past the applicable bound still rejects with the diagnosable terminal error
Tested: the concrete bound values are asserted so a future input change cannot silently inflate them
Not-tested: a live host dying mid-tool against a real gateway
A turn that completed successfully stayed reported as running: the agent
delivered its full final answer, the host went quiet, and the client kept the
turn live until the inactivity watchdog rescued it an hour later. Killing the
agent by hand was the only exit, which masked the defect all session.

None of the terminal guards were at fault — every one was satisfied. The bug is
ordering. `#handleSdkFrame` awaited `#emitEndOfTurnUpdates` before settling, and
those advisory `context.get` / `session.metadata` queries are exactly what a
host that stops the moment it publishes its terminal never answers. Settlement
now happens on the terminal frame and the metadata follows it.

Lore-id: 9e4c7a15
Constraint: settlement must not depend on any host response -- the terminal frame is the authority
Constraint: a late advisory answer must not report the session idle once the next turn has begun
Rejected: shorten the watchdog to cover this | the watchdog is a dead-producer safety net, not a substitute for terminating a healthy turn
Rejected: drop the end-of-turn metadata | it is useful, it just must not gate settlement
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: a completed turn settles on its terminal frame even when end-of-turn metadata never answers
Tested: settlement happens exactly once under duplicate and late terminal frames
Tested: the dead-producer watchdog path still fires
Not-tested: a live host against a real gateway
@probepark

Copy link
Copy Markdown
Collaborator Author

Three more defects found and fixed while driving this PR, all reproduced live on this machine during the batch itself.

f770bb975 — a completed turn never settled. An agent would deliver its full final answer, the host would go quiet, and the client kept reporting the turn as running indefinitely. None of the terminal guards were at fault; the bug was ordering. #handleSdkFrame awaited #emitEndOfTurnUpdates before settling, and those advisory context.get / session.metadata queries are exactly what a host that stops the moment it publishes its terminal never answers. Settlement now happens on the terminal frame; metadata follows it, and a late answer cannot report idle once the next turn has started.

799415372 — the inactivity watchdog was unusable. It was sized at MAX_TOOL_RUNTIME_MS + 10 * HEARTBEAT_TTL_MS = 63.3 minutes, derived from the bash/ssh ceiling a caller may request — the rarest possible case. It could not react to an ordinary hang. The bound now follows evidence: a frame-free gap with nothing executing is held to the default tool runtime plus one reconnect budget (5.67 min), and the wide worst-case bound applies only while the host has an unfinished tool call, lapsing as soon as that evidence does. A long bash is protected by the fact that it is running, not by a constant sized for a tool that might run.

cfe2b4328todo_write rejections did not name the offending key. The message listed the allowed keys but never said which one was wrong, so a retry reproduced the failure verbatim. Two sessions burned turns on it by passing note (op: "note" is valid; its body field is text). hasUnknownKeys returned a boolean and discarded the keys; it now carries them, and a correction is suggested only where the mapping is exact — no fuzzy guessing.

Verified by me, not taken from the agents' reports, each with a with-fix / without-fix comparison:

acp-prompt-settle-on-completion   3 pass / 0 fail   (2 pass / 1 fail with src stashed)
acp-prompt-watchdog               7 pass / 0 fail   (0 pass / 1 fail with src stashed)
todo_write (ai + coding-agent)  185 pass / 0 fail   (66 pass / 7 fail with src stashed)
acp suites + prompt terminal    189 pass / 0 fail

bun --cwd=packages/coding-agent run check exit 0, and telegram-daemon-generation-guard reports v43 required generation bump verified.

The sdk-broker-lifecycle-e2e failures one agent reported were a missing native addon in its worktree, not a code defect — 61 pass / 0 fail on this branch.

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

REQUEST_CHANGES

Exact-head red-team review of f770bb97590eb2151dff36b5c595e68253bde964 against the PR base object cf8a50ae04079fd3afed811aff081eae6d54985c found a remaining reconnect correctness bug.

Blocker: replacement-socket live frames race replay, causing reordering and duplicates

The new reconnect callback starts #resumeAttachment() asynchronously after the replacement hello, while the normal onFrame() subscription remains live. There is no replay barrier or (generation, seq) deduplication:

  • onReconnect schedules replay at chat-daemon-runtime.ts#L517-L519.
  • Replay events are enqueued only after event_replay returns at #L581-L599.
  • Live events arriving meanwhile are independently enqueued by the persistent frame observer at #L511-L513.
  • handleFrame advances the cursor but does not reject an event whose seq <= cursor, so replay can deliver an already-delivered live frame again at #L696-L710.

I extended the PR's real FakeSessionHost integration harness locally (temporary test removed afterward) with this sequence:

  1. deliver seq 1;
  2. drop the established socket;
  3. record seq 2 during the outage;
  4. accept the replacement socket;
  5. deliver live seq 3 immediately after replacement hello, before replay answers.

The required exactly-once order was [seq1, seq2, seq3]. Exact-head behavior was:

[seq1, seq3, seq2, seq3]

Bun result: the PR's 3 reconnect tests passed, while this red-team case failed (3 pass, 1 fail, 47 assertions). This proves passive delivery resumes, but not correctly: a normal frame arriving in the hello→replay-result window is published out of order and duplicated. The existing test at chat-daemon-session-reconnect.test.ts#L237-L271 emits no live event during that window, so it cannot detect this race.

Before merge, fence replacement-socket ingress behind replay completion (or merge live/replayed events through a generation/sequence-aware ordered dedupe barrier), and add the above race as an integration test. Also cover stop/disposal and endpoint supersession while that barrier is pending.

Other exact-head review/validation

I inspected the full 53-file PR diff (6,490 additions / 360 deletions), including Anthropic repair budgeting, deferred shell transcript retention, SDK lock cleanup/diagnostics, memory readiness deferral, transcript replay degradation, host idle reap, ACP watchdog and terminal settlement, tool alias dispatch, todo_write diagnostics, disk retention/GC, schema changes, and the generated daemon manifest. I found no second independently reproduced blocker in those surfaces.

Validation performed on the exact head:

  • reconnect + ACP lifecycle focused suite: 42 pass, 0 fail;
  • GC / broker artifacts / SDK lifecycle / host reap / postmortem / todo suite after building the matching native addon: 95 pass, 0 fail;
  • agent-loop / Anthropic repair / coercion suite: 83 pass, 0 fail;
  • Telegram daemon generation guard against the immutable PR base: v43 required generation bump verified;
  • git diff --check: passed;
  • PR CI is green, but does not cover the reproduced hello→replay live-frame race.

The PR must remain open until ordered exactly-once reconnect delivery is proven.


[repo owner's gaebal-gajae (clawdbot) 🦞]

@Yeachan-Heo Yeachan-Heo closed this Aug 8, 2026
@probepark

Copy link
Copy Markdown
Collaborator Author

Blocker fixed in 0d1ec6a9e (fix(sdk): deliver reconnect replay and live frames in order, exactly once), rebased onto current dev (998dd506b). Head is now 2b5a8aeb3.

Your reproduction was exact, and the cursor guard was the hole: it only advanced on seq > cursor and never rejected seq <= cursor, so the replay republished a frame the live socket had already delivered. Combined with the unfenced async resume, that produced [1, 3, 2, 3].

Ingress is now fenced for the duration of the resume — live frames arriving in the hello→replay window are held and drained in sequence order behind the replayed events — and any frame at or below the cursor is dropped instead of republished. The fence cannot deadlock: an unanswered replay leaves the cursor intact so the next reconnect re-issues it, which is the pre-existing failure path.

Your four required cases are covered, and each one fails without the src change (verified by stashing only packages/coding-agent/src):

case without fix with fix
live seq3 before replay answers [seq1, seq3, seq2, seq3] [seq1, seq2, seq3]
replayed frame at/below cursor duplicate published dropped
stop while replay pending hang (20 s timeout) returns, publishes nothing held
supersession while replay pending held frame replayed onto the new attachment discarded

The full published array is asserted, not a set or a length. The three pre-existing reconnect tests pass unchanged in both states — confirming, as you said, that they could not detect this race.

Local validation on the rebased head:

chat-daemon-session-reconnect                      7 pass / 0 fail   (3 pass / 4 fail with src stashed)
+ control-frames + daemon-control + acp reconnect 199 pass / 0 fail
acp suites + watchdog + settle + prompt terminal  189 pass / 0 fail
broker + lifecycle + host reap + gc              192 pass / 0 fail
packages/agent                                    702 pass / 0 fail

check:schemas clean, check clean for ai and coding-agent, telegram-daemon-generation-guardv43 required generation bump verified (2b5a8aeb3 regenerates the manifest for the moved attach digest via --write-manifest).

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.

2 participants