<!-- Authored per the the-loop:writing skill. -->
Bugfix spec: the typed selection grammar reads past the phase list (P1); the close path races the session's endgame (P2)
Phase 1 of 4 (bugfix → design → testing plan → tasks). Source: the 2026-09-20 run 3 end-to-end Slack test (
docs/reports/e2e-slack-test-2026-09-20-run-3.md, filed as #404), findings P1 and P2, filed together as issue-405.
Summary
Two independent bugs, both on paths the previous two runs had just fixed around:
- P1 —
execute without <phase>refuses every valid argument. The only phase-shaping gesture a connector or phone-first operator has is 0 for 4 across runs 2–3, and since the N1 fix it quotes the refused name back as skippable in the same message. The grammar's clause reader takes everything to the end of the line afterwithoutas phase names; the live message does not end where the operator's list ends. - P2 — the close path kills the session while it is composing its completion summary. A merge that carries
Closes #Ncloses the ticket at once; the close-poller fires;close_sessionends the harness (SIGTERM, pane status 143) and removes the checkout while the agent is writing the one message a room most wants — what shipped, the evidence, the accepted gaps. Run 2 won that race by luck; run 3 lost it. The same closure loggedsession.autoclosed merged: falseseconds after the delivering PR merged.
Steps to reproduce
P1 — the typed grammar:
- Declare a private room for a work item,
listen: mentions; start it so the daemon posts the phase-selection checklist. - From the Slack MCP connector (or any client that signs its messages), send
<@BOT_ID> execute without capability-docs— a name the checklist offers. - Send
<@BOT_ID> execute without 14— the numeric form the refusal itself suggests.
P2 — the close race:
- Drive a work item to
human-approvalwithrouting.mergeOnApproval: true(the default) and a PR whose body saysCloses #N. - Approve on the room's gate message. The PR merges; GitHub closes issue
#N. - Watch the session's pane and the room during the next poll cycle.
Expected vs actual
| Expected | Actual | |
|---|---|---|
| P1 step 2 | The checklist is frozen with capability-docs unticked; ✅ | ⚠️, mirrored refusal "that name is not a phase this checklist offers … 14. capability-docs …"; channel.dropped reason=unskippable-phase |
| P1 step 3 | Same as above, by number | Refused with the identical text |
| P2 step 3 | The session posts its completion summary (ticket + room), claims graph complete, and only then is ended and retained | session.autoclosed merged:false, session.harness_terminated, checkout removed, the pane dead mid-sentence; the summary never posts |
Root cause (confirmed by reading the live path; P1 additionally instrumented)
- P1.
execute,start,approvedandhelpare unaffected because every other reader matches a whole-token keyword anywhere in the text or reads the first token only (parse_verb,wants_public_help).without_clausealone consumes the rest of the line, andapply_withoutrefuses the reply on the first item that is not an offered phase. A word that is not token-shaped (_TOKEN_RE: letters, digits,.,_,-) is named "that name" — which is exactly what the operator saw, forcapability-docsand14alike; a valid item would have been echoed in backticks. So the item list carried more than the phase, and the extra was not a word: a connector signature (*Sent using* @Claude, issue-397 O5) or markup on the command's line. The run-2 isolation repro put the signature on a second line, where[^\n]*never reaches it, which is why every component passed there. Two more misses in the same reader compound it: Slack markup around a name (*5*,_design_) and invisible format characters (zero-width space, U+200B) also make a valid name non-token-shaped, and bothapply_withoutrefusals drop asunskippable-phasealthough only one of them is. - P2.
Dispatcher.handle's close branch runsclose_session(registry close,_close_tmux→_terminate_harnessunder the defaultkillHarnessOnClose, workspace removal) →_record_closure→session.autoclosed→_cleanup_after_close, synchronously, whatever the session is doing. Before O9 the session merged and posted its summary in one breath and usually beat the poll cycle; the merge'sCloses #Nnow closes the ticket the moment the merge lands. Themergedfield ofsession.autoclosedisreason == "pr-merged", which an issue closure can never satisfy, even when the daemon has already recorded the delivering pull request asmergedin the work item's ownwork-item-state.json(on_pr_state, issue-368).
Requirements
Requirement 1 — the typed clause reads the phases and nothing else (P1)
User story: As an operator on a connector or a phone, I want execute without <phase> to freeze the selection I typed, and a refusal to tell me which word it could not read, so that the one typed gesture the checklist offers actually works.
Acceptance criteria (EARS)
- WHEN the text after
<execute keyword> withoutcarries a connector signature (*Sent using* …, with or without markup, on the same line or a later one) THEN the grammar SHALL ignore the signature and read the phases before it. - WHEN an item is wrapped in Slack markup (
*,_,~, backticks, quotes, brackets or trailing punctuation) or carries invisible format characters (Unicode categoryCf) THEN the grammar SHALL read the bare name or number inside it. - WHEN an item is not token-shaped after that cleaning THEN the reply SHALL be refused with a refusal that names the item's position after
withoutand what it reads as (markup, a mention or prose), never echoing it (abuse case 4 and the existing hostile-name rule hold), and SHALL tell the operator to put only phase names or numbers on thewithoutline. - WHEN a reply is refused THEN the drop reason SHALL say which family it is:
unknown-phase(a name or number the checklist does not offer, or a non-token item),unskippable-phase(a protected phase),empty-clause(nothing named), or the existingchecklist-unreadable.unskippable-phaseSHALL no longer be recorded for a name the checklist does not offer. - WHEN the grammar runs THEN the exact text it was handed and the items it read SHALL be logged at
info, and a refusal'schannel.droppedrecord SHALL carryread: the items as read, token-shaped items verbatim and any other item as its length only — the instrumentation the report asked for, on the one observable the deployment has. - The fix SHALL include regression tests that fail before the fix and pass after: a same-line signature (
*Sent using* @Claude,<@U…>mention form, plain), a second-line signature (unchanged behaviour), markup-wrapped names and numbers, a zero-width space, the position-naming refusal, and the distinct drop reasons.
Requirement 2 — a session at its endgame finishes before the close path ends it (P2)
User story: As the person reading the room, I want the agent's completion summary to land even when the merge closes the ticket first, so that the last message of a work item is the agent's, not a 🎉 closed.
Acceptance criteria (EARS)
- WHEN a close event (
issue-closedorpr-merged) matches a live session whose tmux pane is running AND the work item's graph pointer stands on a terminal node whose exit has not been recorded (the endgame:finish-tasksin progress) THEN the dispatcher SHALL defer the whole closure — registry close, harness termination, checkout removal, closure stamp, roster and room clear, cleanup — and recordsession.closingwith the reason, the node and the grace. - WHEN a deferred closure's work item records its terminal node as exited (the session's
the-loop graph complete <id>claim) ORrouting.tmux.finishGraceSeconds(default300) has elapsed since the deferral THEN the closure SHALL run exactly as it does today, andsession.autoclosedSHALL carrywaited_secondsandfinished: true | false(whether the claim was seen). - WHEN the pointer is anywhere else (a wontfix mid-work), the session is not live, the pane is dead, the graph cannot be read, or
finishGraceSecondsis0THEN the closure SHALL run immediately, as before. - WHEN the work item is reopened during the grace THEN the pending closure SHALL be cancelled (
session.closing_cancelled); WHEN a second close for the same work item arrives during the grace THEN it SHALL be a no-op. The poller SHALL not re-ask the provider about a work item whose closure is pending. - WHEN an issue closure is recorded THEN
session.autoclosed'smergedSHALL betruewhen the closure is a merge OR the work item's state file records a merged pull request; the closure stamp (state: closed | merged) is unchanged. - The
finish-taskscommand and the skill SHALL state the order of the endgame — post the completion summary, claimgraph complete, then close the ticket if it is still open — and that a ticket already closed by a merge'sCloses #Nis expected, with the daemon waitingfinishGraceSecondsfor exactly those two acts. - The fix SHALL include regression tests that fail before the fix and pass after: a closure at the terminal node is deferred and the harness is not ended; the claim ends the grace early; the deadline ends it; a pointer off the terminal node closes at once; a reopen cancels;
merged: trueon an issue closure whose PR merged.
Security considerations
- Actors & trust. P1 reads a Slack member's text that the allow-list and the roster have already admitted; nothing new reaches the checklist record — a cleaned item is still validated against the rows the gate parses, and a non-token item is still refused without being echoed (the
<!channel>,@hereand 60-character cases stay pinned). Stripping the signature removes text; it never adds a phase the person did not type. Thereadfield of the drop record carries token-shaped items only, others as a length — no broadcast, mention or prose reaches the event log through it. - P2 widens no permission. A deferred closure holds a session open that GitHub has already declared finished, for a bounded time, on the machine that spawned it: the session keeps the access it already had, and the only actors are the daemon's own close path and the state file the session already writes. The deferral is decided from the-loop's own graph state, never from event text; a state file that cannot be read, or names no terminal node, answers close now (fail-closed toward today's behaviour). The grace is bounded by a config value the operator sets,
0disables it. - The cleanup gate is unchanged.
_cleanup_after_closestill requires a named, allowlisted closer; a deferred closure re-reads the same routed event at finish time. - No new attack surface. One additive config key (
routing.tmux.finishGraceSeconds, number ≥ 0), two additive fields onGraphContext, three additive event fields and three event types; no grant, scope, schema-shape, lock or state-file change.
Out of scope
- Posting the completion summary from the daemon (the report's second option): the summary is the agent's voice, and the room rework (issue-393) put that voice in the session on purpose.
- Rewriting the PR body to strip
Closes #Nbefore the merge (the third option): it would take the ticket's closure away from GitHub's own linkage, which every other reader relies on. - The
phase.startedstutter, the room-opened header and the gate digests pasting the document (run 3's "still reads wrong" 2–4): separate polish, not bugs. - The hosted process log going to
/dev/null(a deployment setting).
Open questions
None.