Skip to content

(triggers): read the turn end from the transcript while the CLI stays busy (#360) - #433

Merged
devsuitup merged 5 commits into
mainfrom
fix/360-transcript-turn
Oct 3, 2026
Merged

devsuitup merged 5 commits into
mainfrom
fix/360-transcript-turn

Conversation

@devsuitup

@devsuitup devsuitup commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Closes #360

What changed and why

The CLI descriptor (~/.claude/sessions/<pid>.json) keeps status: "busy" while background agents run, and shell while 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_ms 599959).

While the descriptor reads busy or shell, a chain now treats the prompt as free when all of these hold:

  • the transcript's last main-thread message entry closes a turn:

    • an assistant entry with stop_reason end_turn (or the synthetic stop_sequence message) followed by a main-thread system turn_duration entry;
    • a user entry whose string content is one <local-command-stdout> block start to end (the output of /compact, /clear, /model);
    • the compaction summary (isCompactSummary: true) right after a compact_boundary whose compactMetadata.trigger is manual.

    Any other user entry (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, a tool_use stop is not closed, sidechain entries are skipped, and a queued prompt after the closed turn (any dequeue, or an enqueue not 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-thread end_turn messages:

  • 4120 are followed by a turn_duration, p50 0.27 s, p90 2.9 s, p99 42.8 s, max 124 s after the end_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.
  • The other 224 were all continued: by a meta prompt, a prompt, a task notification, or a synthetic message 9 to 76 s later. Three end the file.
  • stop_hook_summary always comes before turn_duration (3695 of 3695). The 39 cases with a stop_hook_summary and no turn_duration were all continued.
  • No turn_duration follows 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.sh chain: on a real transcript, /compact ends with a manual compact_boundary, the summary, the caveat and <command-name>/compact entries (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 /compact is released from the transcript while background agents hold the descriptor busy. The test fixture copies that shape with synthetic text.

An idle descriptor 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 on step not confirmed. For a chain step, while the descriptor reads busy or shell, the step's own entry stamped at or after the Enter also counts as the CLI's reaction. That is a main-thread user prompt or an enqueue whose 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 !cmd step it is one <bash-input> element holding the command. Attachments, system entries, meta entries and other texts never confirm. Each step records confirm_source (descriptor / transcript).

The CLI writes the <command-name>/compact entry 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 reads busy or shell and 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 with step not confirmed, a reason saying the deadline passed, and nothing more typed. A dialog, a remote session or a missing transcript keep the immediate step 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. /compact is 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 /compact waits to the step deadline, and so does any step whose entry appeared while its turn is still running. docs/automation.md now asks for timeout_ms 600000 on a chain that starts with /compact under busy. The dialog check is not applied in this wait: a waiting written 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 (the step not confirmed row), 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 (passes PROJECTS_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.js is the end-to-end regression for #360: a chain under a descriptor held busy by background agents, with a closed turn and turn_duration in a transcript on disk, and /compact under 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 with step 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 with step 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:

  • busy descriptor, closed turn, quiet: the chain writes step 1, idle_source / ready_source are transcript, and step 0 is confirmed through its own transcript entry;
  • busy descriptor and a long tool call (tool_use), or a tool_result last: step 1 is never written;
  • a closed turn followed by sidechain entries: step 1 is written;
  • a closed turn older than the previous step's Enter: step 1 is not written;
  • idle descriptor: the descriptor path, descriptor as the source;
  • /compact as step 0 with the descriptor held busy: step 1 is written, idle_source transcript;
  • a slash command that expands into a prompt, its model turn still running: step 1 is never written;
  • a swallowed Enter with only a pre-Enter entry of the same text: the chain ends on step not confirmed;
  • an idle descriptor that never moves, with the step's entry in the transcript: the step is not confirmed;
  • an attachment and a system entry, another agent's notice, or an enqueue of another text after the Enter: step not confirmed;
  • a single trigger with its own entry in the transcript and a busy descriptor: not confirmed;
  • /compact under 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_source transcript, submitted confirmed), step 1 is typed only after the output, and no recovery Enter is written; the same as the only step of a chain;
  • a swallowed Enter under a busy descriptor: the chain fails once the 30 s own-entry wait (1 s in the tests) is over, not at the step deadline, with its own reason; one Enter only, step 1 never typed, and the cache entry is evicted;
  • a swallowed /compact under a busy descriptor: waits to the step deadline;
  • under a busy descriptor: the step's own entry with the turn still running, another turn closing without the step's own entry, and a turn closed before the step's own entry all fail at the deadline;
  • a pending step confirmed by the descriptor reacting later: confirm_source descriptor;
  • a session exiting while its step is pending: session exited during wait;
  • no readable transcript, or a dialog right after the Enter: the immediate step not confirmed, as before;
  • promptMatches for a skill written <command-message> first and for <bash-input>; forgetTranscriptTurn in the trigger context.

Red first, in five rounds:

  1. Before the watcher change, 12 of the first 33 tests failed; the chain test timed out after 6 s with step 1 never written.
  2. The tests for local command output and the compaction summary failed 4 before transcript-turn.js accepted them.
  3. The review round's tests failed 11 before its change: 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.
  4. The second review's tests failed 9 before its change: the skill and <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.
  5. The third review's tests failed 2 before its change: the swallowed Enter failing at the own-entry limit, and the foreign turn reporting that reason. The swallowed /compact test 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:

  • First round: removing the busy/shell status gate, the dialog guard, the closed check, 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.
  • Local command rule: the compaction summary check, the local stdout check, the string-content guard, the start-tag check, the end-tag check, a user branch that always closes, and a widened entry filter.
  • Third review (5): the own-entry limit, the /compact exemption, 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.
  • Second review (22): the <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's forget, the context remembering the path, the context eviction, pending only under busy/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_source recorded, the probe's transcript source, the probe's descriptor source, the eviction when the chain ends, and a pending confirmation counting as confirmed.
  • Review round (21): the turn_duration requirement, its subtype check, stop_sequence in the closing set, the summary's boundary requirement, the manual trigger 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's sinceMs (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 check exits 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 on main (9eb6435) in the primary checkout, and this branch does not touch that file.

Not verified

  • Not run against a real CLI with background agents running.
  • An end_turn with no turn_duration never reads closed (3 of 4344 in the corpus ended a file that way); a chain there waits for the descriptor.
  • A step whose text the CLI stores differently from what was typed (a pasted-text placeholder, for example) is not matched, so its Enter is confirmed by the descriptor or not at all. Not measured.
  • popAll queue operations (26 in the corpus) are not counted.
  • Only the final shape of a /compact was read; what the transcript holds while compaction runs was not observed. The anchor keeps the turn before /compact from counting.

… 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
@devsuitup

Copy link
Copy Markdown
Owner Author

Adversarial review at cfde327: changes requested. CI all green; fallback tests 45/45 locally.

Blocking:

  1. trigger-watcher.js:308 / trigger-watcher.md: the 3 s quiet window rests on a wrong measurement ("stop_hook_summary 0.8-1.3 s after end_turn"). Over 3726 end_turn entries in 19 real transcripts: gap to stop_hook_summary p50 0.27 s, p90 3.1 s, p99 41 s, max 124 s; 10.3 % are >= 3 s, and in 5 cases the main thread resumed 9-76 s after an end_turn that looked closed and quiet. Since the fallback fires on any busy/shell descriptor, ordinary chains (no background agent) would type the next step during a running Stop hook about one step in ten. Fix: an end_turn counts as closed only once a main-thread system/turn_duration (or stop_hook_summary) follows it; correct the doc.
  2. trigger-watcher.js:335-340 (transcriptReactedSince): the guards deciding that a step was submitted are unpinned. Replacing sinceMs with -Infinity or dropping the busy/shell check fails no test (mutations on a copy). Needs a swallowed-Enter test with a pre-Enter entry ending on step not confirmed, and one with an idle descriptor.

Non-blocking:
3. :555 / :1247: the probe sits in submitWithVerify, which single triggers also use; a single trigger into a busy descriptor now gets submit_confirmed: true from any entry. Docs say single triggers are unchanged. Restrict to chains.
4. Any stamped main-thread entry (attachment, a background agent's notice) confirms an Enter; accept only the step's own user/enqueue entry.
5. transcript-turn.js:23: isCompactSummary closes the turn whatever triggered the compaction; require a manual compact_boundary.
6. transcript-turn.js:48: removing dequeued === 0 fails no test.
7. transcript-turn.js:75: single-file cache; two chains evict each other. Key it by path.

Nits: a pointer comment at :307 now sits above unrelated code; doc line count for transcript-turn.js; stop_sequence treated as closed beyond the ruling, unmeasured.

Checked: partial first line, CRLF, missing file and an oversized entry all fail toward "not closed"; forks re-key with realSessionId; remote sessions excluded; idle path unchanged; comment sweep clean; CHANGELOG format correct.

…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.
@devsuitup

Copy link
Copy Markdown
Owner Author

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:

  1. transcript-turn.js:78-79 (promptMatches, anchored ^<command-name>): the current CLI writes skills and custom commands with <command-message> first, then <command-name> (12 of 111 command entries, e.g. /update-config, /loop, /pre-compact). A chain step /pre-compact under a busy descriptor never matches and stops on step not confirmed. Match the <command-name> element anywhere; add a fixture in that order.
  2. /compact under a busy descriptor: the <command-name>/compact entry is written when compaction ends (1-3 min after the Enter, though stamped at the Enter), while the verify window is 2 s and a stuck-busy descriptor writes no new stamp. So the step after /compact — the case the CHANGELOG and doc claim — cannot be confirmed. The test passes only because its fixture writes the compaction output 100 ms after the Enter. Either treat an unconfirmed step under busy/shell as pending until the closure wait sees the step's own entry and a closed turn after the Enter, with a realistic fixture, or drop the claim.

Non-blocking: the per-path cache is never evicted (one entry per session ever read); !cmd steps are stored as <bash-input> and never match.

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.
@devsuitup

Copy link
Copy Markdown
Owner Author

Third review (df07084..d52ab0c): no code defect left; CI green on d52ab0c; fallback tests 73/73; 6/7 extra mutations caught; promptMatches matches 111/111 real command entries (both element orders) and 24/24 !cmd entries. The pending wait never writes (traced: confirmed, session exit, deadline → step not confirmed, nothing typed); error unchanged for strict callers.

Remaining before merge:

  • Evidence: a recorded failing CI run of the (triggers): chain times out after step 0 because the busy flag stays up on an idle session #360 chain scenario on the base (a throwaway draft PR carrying the tests only), cited here.
  • A swallowed step with no own entry waits out the whole chain budget (5-10 min): fail after ~30 s without an own entry; keep the long wait only once it has appeared (/compact).
  • Manual compactions measured 47-283 s against the 300 s default: document timeout_ms >= 600000 for chains starting with /compact; fix the automation.md row.
  • dialog.seen(now) in the pending wait is unpinned.

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.
@devsuitup

Copy link
Copy Markdown
Owner Author

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; error unchanged. Doc corrected: 79/82 compactions write a plain /compact entry at the Enter (confirmed by the 2 s probe); the exemption covers the other 3. Merging on green CI.

@devsuitup
devsuitup merged commit 71e892c into main Oct 3, 2026
12 checks passed
@devsuitup
devsuitup deleted the fix/360-transcript-turn branch October 3, 2026 14:31
@devsuitup devsuitup mentioned this pull request Oct 3, 2026
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.

(triggers): chain times out after step 0 because the busy flag stays up on an idle session

1 participant