(triggers): read the turn end from the transcript while the CLI stays busy (#360) - #433
Conversation
… busy The CLI descriptor keeps status busy while background agents run, and shell while background shell jobs run, even with the prompt free. A chain then waited out its whole deadline after step 0. While the descriptor reads busy or shell, a chain now treats the prompt as free when the transcript's last main-thread message closes a turn (an assistant end_turn, a local command's stdout such as /compact's, or the compaction summary), stamped after the previous step's Enter, the file has been quiet for 3 s, and no dialog was seen. A main-thread entry written after our Enter also counts as the CLI's reaction, since a busy descriptor writes no new stamp. Each step records ready_source and idle_source. Closes #360
|
Adversarial review at cfde327: changes requested. CI all green; fallback tests 45/45 locally. Blocking:
Non-blocking: Nits: a pointer comment at Checked: partial first line, CRLF, missing file and an oversized entry all fail toward "not closed"; forks re-key with |
…n entry Review of #433. An end_turn alone is not the end of a turn: on the real transcripts of this machine 9.8 % of the gaps to turn_duration are 3 s or more, up to 124 s, and some turns resumed after a quiet end_turn. An assistant end_turn now closes only once a main-thread turn_duration follows it; local command output keeps its rule, since no turn_duration follows it. The compaction summary closes only after a manual compact_boundary. The transcript reaction now applies to chain steps only, and only the step's own user entry or enqueue, matching its command, confirms the Enter. The tail cache is kept per path.
|
Delta review cfde327..df07084: previous findings resolved (7 extra mutations all caught; re-measurement agrees: 4156/4400 final end_turn followed by turn_duration, 348/365 for turns that launched background tools; single triggers confirmed unchanged). CI green. Still changes requested: Blocking:
Non-blocking: the per-path cache is never evicted (one entry per session ever read); |
Second review of #433. The CLI writes the <command-name>/compact entry only when compaction ends, minutes after the Enter, and a descriptor held busy writes no new stamp, so a /compact step under busy ended on "step not confirmed" after the 2 s verify window. Such a step is now pending: it is confirmed when the descriptor reacts, or when its own entry appears and the turn is closed after it, and the chain stops at the step deadline otherwise, with its own reason and no Enter written meanwhile. Each step records confirm_source. A slash command now matches its <command-name> element wherever it sits among the command elements (skills write <command-message> first), and a !cmd step matches its <bash-input>. A chain evicts its session's cached transcript tail when it ends.
|
Third review (df07084..d52ab0c): no code defect left; CI green on d52ab0c; fallback tests 73/73; 6/7 extra mutations caught; Remaining before merge:
|
Third review of #433. A pending step whose own entry does not appear within 30 s of its Enter now fails then, with its own reason, instead of spending the chain's whole budget. /compact is exempt: on 82 measured manual compactions no entry naming it was written before compaction ended (median 129 s, up to 332 s), so it waits to the step deadline, as does a step whose entry appeared and whose turn is still running. The dialog check in that wait is dropped: a waiting status after the Enter carries a new stamp and confirms the step through the descriptor first. The docs ask for timeout_ms 600000 on a chain starting with /compact under busy. The end-to-end regression run red on the base is added unchanged.
…ontext 79 of 82 measured compactions write a plain /compact user entry at the Enter; the exemption covers the 3 that do not.
|
Final review (d52ab0c..d6ae464 + 1cf7b04 doc fix): approved. Evidence gate met: run 37129150581 on draft #436 (test file only, base 9eb6435) fails tests 3026/3027 on all four test jobs with the #360 symptom; the same blob passes here. Mutations of the /compact exemption, the no-own-entry branch and its guard all caught; the pending wait never writes; |
Closes #360
What changed and why
The CLI descriptor (
~/.claude/sessions/<pid>.json) keepsstatus: "busy"while background agents run, andshellwhile background shell jobs run, even when the prompt is free. A trigger chain then waited out its whole deadline after step 0 (steps_completed: 0,waited_ms599959).While the descriptor reads
busyorshell, a chain now treats the prompt as free when all of these hold:the transcript's last main-thread message entry closes a turn:
assistantentry withstop_reasonend_turn(or the syntheticstop_sequencemessage) followed by a main-threadsystemturn_durationentry;userentry whose string content is one<local-command-stdout>block start to end (the output of/compact,/clear,/model);isCompactSummary: true) right after acompact_boundarywhosecompactMetadata.triggerismanual.Any other
userentry (prompt, meta prompt, the local command caveat, the<command-name>entry of a slash command that expands into a prompt,tool_result) means a turn is in progress, atool_usestop is not closed, sidechain entries are skipped, and a queued prompt after the closed turn (anydequeue, or anenqueuenot removed) counts as a turn about to start;that turn is stamped at or after the anchor: the Enter of the step just written (busy-fall wait), or the previous step's Enter (readiness wait; no anchor before step 0);
the transcript file has not changed for 3 s (
SWITCHBOARD_TRANSCRIPT_QUIET_MS), so the entries of the same burst land first;no dialog was seen (
createDialogProbe, the existing detection).Why
turn_duration: measured on the 19 real transcripts of this machine (read only), over 4344 main-threadend_turnmessages:turn_duration, p50 0.27 s, p90 2.9 s, p99 42.8 s, max 124 s after theend_turn. 9.8 % of the gaps are 3 s or more, so a quiet window alone would have released turns whose Stop hook was still running.stop_hook_summaryalways comes beforeturn_duration(3695 of 3695). The 39 cases with astop_hook_summaryand noturn_durationwere all continued.turn_durationfollows a local command's stdout (0 of 95) or a compaction summary (0 of 82), so those keep their own rule.This covers the
compact-now.shchain: on a real transcript,/compactends with a manualcompact_boundary, the summary, the caveat and<command-name>/compactentries (stamped at the Enter), then the<local-command-stdout>entry stamped last, then attachments. The stdout entry is the last message entry, so the step after/compactis released from the transcript while background agents hold the descriptor busy. The test fixture copies that shape with synthetic text.An
idledescriptor keeps its own path and the transcript is not read. Single triggers are unchanged: they use neither the fallback nor the transcript reaction below.A busy descriptor that does not change status writes no new
statusUpdatedAt, so without more a chain step's Enter would never count as seen and the chain would stop onstep not confirmed. For a chain step, while the descriptor readsbusyorshell, the step's own entry stamped at or after the Enter also counts as the CLI's reaction. That is a main-threaduserprompt or anenqueuewhose text equals the step's command. For a slash command it is an entry made only of command elements whose<command-name>element, wherever it sits, equals the command's first word: skills and custom commands write<command-message>first (12 of the 111 command entries measured). For a!cmdstep it is one<bash-input>element holding the command. Attachments,systementries, meta entries and other texts never confirm. Each step recordsconfirm_source(descriptor/transcript).The CLI writes the
<command-name>/compactentry only when compaction ends, one to three minutes after the Enter, although it is stamped at the Enter. So a chain step whose submission is not confirmed within the 2 s window, while the descriptor readsbusyorshelland the transcript is readable, is pending instead of failed. No Enter is written while it is pending. It is confirmed when the descriptor reacts after the Enter, or when the step's own entry (stamped at or after the Enter) appears and the turn is closed after it under the rules above. At the step's deadline the chain stops withstep not confirmed, a reason saying the deadline passed, and nothing more typed. A dialog, a remote session or a missing transcript keep the immediatestep not confirmed. A chain evicts its session's cached transcript tail when it ends.A pending step whose own entry does not appear within 30 s of its Enter fails then, with its own reason.
/compactis exempt: of 82 manual compactions measured, none wrote an entry naming it before compaction ended (Enter to boundary from 0.5 s to 332 s, median 129 s). So/compactwaits to the step deadline, and so does any step whose entry appeared while its turn is still running.docs/automation.mdnow asks fortimeout_ms600000 on a chain that starts with/compactunder busy. The dialog check is not applied in this wait: awaitingwritten after the Enter carries a new stamp, so the descriptor branch, checked first, confirms the step on it.Each chain step records
ready_source(descriptor/transcript) and, for a non-final step,idle_source(descriptor/transcript/busy_flag/no_rise), so a wrong guess shows in the result file.Files:
docs/automation.md(thestep not confirmedrow),transcript-turn.js(new: classifies the transcript tail and lists the recent prompts, re-reads a file only when its mtime or size change, last 256 KB, one cache entry per path),trigger-context.js(ctx.getTranscriptTurn, local sessions only,<projectsDir>/<projectFolder>/<realSessionId or key>.jsonl),main.js(passesPROJECTS_DIR),trigger-watcher.js,.ai/contexts/trigger-watcher.md(new section with the conditions, the measurements and the known limits),CHANGELOG.md.How it was tested
test/trigger-busy-chain-stall.test.jsis the end-to-end regression for #360: a chain under a descriptor held busy by background agents, with a closed turn andturn_durationin a transcript on disk, and/compactunder busy with its output written 3 s after the Enter. In both, step 1 must be written. It loads no new module, so it runs unchanged on the base: on 9eb6435 both tests fail withstep 1 was never written(not sent, the CLI still reported busy at the deadline). Failing CI run on the base: run 37129150581, draft PR #436 (this test file only, on 9eb6435): both tests fail on all four test jobs withstep 1 was never written... "the CLI still reported a turn running (busy) at the deadline".test/trigger-transcript-fallback.test.js(74 tests): the classification on JSONL text, the reader on real files, the wait helpers under mocked timers, and the chain through the real watcher and the real trigger context over a real transcript file:idle_source/ready_sourcearetranscript, and step 0 is confirmed through its own transcript entry;tool_use), or atool_resultlast: step 1 is never written;descriptoras the source;/compactas step 0 with the descriptor held busy: step 1 is written,idle_sourcetranscript;step not confirmed;step not confirmed;/compactunder a busy descriptor with the compaction output written 3 s after the Enter (beyond the verify window): the step is confirmed from the transcript (confirm_sourcetranscript,submittedconfirmed), step 1 is typed only after the output, and no recovery Enter is written; the same as the only step of a chain;/compactunder a busy descriptor: waits to the step deadline;confirm_sourcedescriptor;session exited during wait;step not confirmed, as before;promptMatchesfor a skill written<command-message>first and for<bash-input>;forgetTranscriptTurnin the trigger context.Red first, in five rounds:
transcript-turn.jsaccepted them.turn_duration(3), manual boundary, prompts list,promptMatches, per-path cache, the three foreign-entry chain tests and the single trigger. The swallowed-Enter, idle-descriptor and dequeue tests pin guards that already existed, so they passed before; the mutations below turn each of them red.<bash-input>matches, the five pending chain tests, and the context eviction. The no-transcript, dialog, foreign-turn, turn-before-own-entry and session-exit tests pin guards of the new code and were written alongside it; the mutations below turn each of them red./compacttest and the elapsed-time check on the open-turn test pin the exemption and the wait after the own entry.Mutations, each run against the test file and then reverted. Each one turned at least one test red:
closedcheck, the after-anchor check, the quiet window, the transcript reaction, the chain's previous-Enter anchor, the busy-fall fallback, the readiness fallback, the sidechain skip, "user means open", the stop_reason set, the queue check, the unstamped-turn check, the local-only check in the context, and the real-session-id lookup./compactexemption, the limit applying only while no own entry exists, the limit applying only before the step deadline, and its reason. The limit is counted from the Enter, not from the start of the wait; that choice is not pinned (a 0.4 s difference), so it is declared unmutated.<command-name>element anywhere, the command-elements-only check, the<bash-input>branch, its end anchor, its non-empty command, its inner trim, the reader'sforget, the context remembering the path, the context eviction, pending only underbusy/shell, pending only with a transcript, pending needing the own entry, pending needing the closed turn, the closure anchored at the own entry, pending confirmed by the descriptor, the deadline reason, the session exit while pending,confirm_sourcerecorded, the probe's transcript source, the probe's descriptor source, the eviction when the chain ends, and a pending confirmation counting asconfirmed.turn_durationrequirement, its subtype check,stop_sequencein the closing set, the summary's boundary requirement, themanualtrigger check, the boundary type/subtype check,dequeued === 0, the meta exclusion and the enqueue-only rule for prompts, the prompt stamp requirement, the trim, the empty-command guard, the anchored<command-name>, the first-word rule, the type guard, the per-path cache, the reaction'ssinceMs(replaced by-Infinity), its busy/shell check, its command match, its chains-only gate, and the chain passing the option.Lint: 0 errors. The seven trigger test files passed under Node 22 with c8 (278 tests, 0 failed, 0 cancelled).
task checkexits 1 on one file,test/viewer-file-watch.test.js: its 7 tests pass and the process then dies with 0xC0000409. The same crash occurs onmain(9eb6435) in the primary checkout, and this branch does not touch that file.Not verified
end_turnwith noturn_durationnever reads closed (3 of 4344 in the corpus ended a file that way); a chain there waits for the descriptor.popAllqueue operations (26 in the corpus) are not counted./compactwas read; what the transcript holds while compaction runs was not observed. The anchor keeps the turn before/compactfrom counting.