Repository navigation
fix: acknowledge journaled coordinator messages after the injected-text cache expires - #26
Merged
Merged
Conversation
npm audit now reports a high-severity advisory for @modelcontextprotocol/sdk 1.12.0–1.30.1: the OAuth client could send credentials to an authorization server chosen by the MCP server. CI's production-dependency audit therefore fails on every run, main included. Pin the lockfile to 1.31.0, the first patched release; package.json's ^1.30.0 range is unchanged. Upstream, 1.30.1 and 1.31.0 change only the OAuth client and the HTTP transport's body limits. SAB uses only the stdio Server, so the vulnerable path was never loaded. Validated: - npm audit reports 0 vulnerabilities, npm test passes (564), and npm run check passes. - An end-to-end smoke test ran the real channel/server.mjs on 1.31.0 against a local fake daemon. It covered the MCP handshake with both claude/channel capabilities, an SSE message delivered as notifications/claude/channel, and a permission request relayed to the daemon. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Xs8AVBVrTGfpzUMiCQp2R
The daemon's UserPromptSubmit branch decides whether a native prompt is the bridge's own input (a delegated task, a coordinator message, a Slack injection) or local input, and applies that to the team lifecycle. It could only be tested through source-regex contracts, because daemon.mjs cannot be imported without starting a live bridge. Its body now lives verbatim in daemon/prompt-submit.mjs as createPromptSubmitHandler(). The daemon creates it with its own persistence, poller, Slack and audit effects and delegates to it. Behaviour is unchanged. Supporting pure moves, each byte-identical in effect: - the injected-text cache becomes createInjectedTextCache() in util.mjs, with an injectable clock; - currentTeamTaskProviderTurn and teamTaskTurnOwnsCurrentLifecycle delegate to state-taking functions in team-provider-turn.mjs; - isSystemPrompt moves to providers.mjs; - the coordinator message envelope is built by coordinatorTaskMessagePrompt() in teams.mjs. The handler contract tests now read the new module; their assertions are unchanged. Full suite: 566 pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Xs8AVBVrTGfpzUMiCQp2R
…handler Behavioral regression tests for the actual UserPromptSubmit handler. They use real team state, the real injected-text cache on a controllable clock, and the same journal functions as the delivery path. Only Slack, pollers, audits and persistence are recorded instead of performed. This commit deliberately leaves 10 of the 14 tests failing. They reproduce the incident: - A journaled Codex coordinator message hooked 123.3 s after delivery, past the 120 s cache, fails the running task with "A local terminal prompt replaced the delegated worker turn". - The same happens after a daemon restart with an empty cache. - Claude's <pasted_content>-wrapped task and message prompts can never match the journal. - The bridge's own journaled prompts are mirrored to Slack as terminal typing. The four passing tests pin behaviour that must not change: untrusted or misaddressed input, genuine local input, replay on another session or a rebound channel, and a hook that beats its transport callback. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Xs8AVBVrTGfpzUMiCQp2R
…xt cache expires A coordinator message delivered while a worker is mid-turn may be queued by the provider and submitted later. Codex does this, and fires UserPromptSubmit only at submission. In a live team the exact message prompt arrived 123.3 s after delivery, beyond the 120 s in-memory injected-text record. The prompt hook authenticated it correctly: the marker, generation, bound turn and journaled digest all matched. The lifecycle branch, however, keyed on taskMarker(), which recognizes only <sab-team-task>. So the acknowledged message fell through to the local-input fallback, which failed the active task with "A local terminal prompt replaced the delegated worker turn". The same happened after a daemon restart, and the prompt was also mirrored to Slack as terminal typing. Fix: - An acknowledged current-generation coordinator message is settled as bridge input. It never reaches the local-input fallback. - Mirroring skips any prompt that is acknowledged or whose exact digest is in the provider-turn journal (current, pending or history). journaledTeamProviderPrompt() proves provenance only and authorizes nothing. - Journal digests are computed on the unwrapped prompt. Claude Code wraps pasted input in <pasted_content>, so Claude task and message prompts could never match the journal after the cache expired or a restart. - taskMarker() keeps its semantics: withoutDelegatedTaskPrompt() relies on it to purge only task-start envelopes. Task, session, channel, native-turn and generation checks are unchanged. A tag alone is still untrusted; stale generations are ignored; terminal tasks are not resumed; genuine local input still fails the delegated turn. Tests: the reproduction commit's 14 handler tests all pass now, where 10 failed before. Seven mutations turn them red, including a cache-timeout-only "fix" and trusting a tag without its digest. Full suite: 581 pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Xs8AVBVrTGfpzUMiCQp2R
SergioTCG
force-pushed
the
fix/coordinator-message-acknowledgement
branch
from
October 7, 2026 16:06
942a8a3 to
0c6702c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A coordinator message whose prompt hook arrived after the 120-second in-memory injected-text record, or after a daemon restart, failed the delegated task with:
The handler had already authenticated the prompt against the persisted journal. It still sent it down the local-input path. This PR settles an authenticated current-generation message as bridge input. It also adds behavioural regression tests that drive the actual
UserPromptSubmithandler.Incident
A live Codex worker:
<sab-team-message …>prompt hook arrived at T + 123.331 s. Codex queues input submitted during a running turn and firesUserPromptSubmitwhen it actually submits it.teamProviderTurn.promptHash) for the exact task, session, channel and generation. It was not manual terminal input.Root cause
providerPromptTurnMarker()<sab-team-message>providerPromptAcknowledgesTask()acknowledgedTurnis set and the turn is activatedacknowledgedTurn && teamTaskId && …teamTaskIdcomes fromtaskMarker(), which knows only<sab-team-task>, so it isnulland the acknowledged branch is skippedunacknowledgedPromptTeamEffect()injectedis true and nothing happens; after expiry or a restart it returnsfail_task💬 You (terminal)A sibling defect: the journal digest was computed over the raw hook prompt. Claude Code wraps pasted input in
<pasted_content id="…">, so Claude task and message prompts could never match the journal once the cache expired or the daemon restarted.taskMarker()keeps its semantics. Its other caller,withoutDelegatedTaskPrompt(), must purge only queued task-start envelopes and not coordinator messages.Commits
refactor:move the handler intodaemon/prompt-submit.mjs, verbatim.daemon.mjscannot be imported in tests, socreatePromptSubmitHandler()now holds the exact former body and the daemon delegates to it with its own effects. Supporting moves, each byte-identical in effect:createInjectedTextCache()(with an injectable clock);isSystemPrompt;coordinatorTaskMessagePrompt().The handler contract tests now read the new module, and the full suite passes (566).
test:the regression suite on its own. 10 of its 14 tests fail here on purpose, reproducing the incident through the actual handler.fix:journaledTeamProviderPrompt(), provenance only), is not mirrored as typing.Preserved guarantees
Regression tests (
test/prompt-submit.test.mjs)These drive
createPromptSubmitHandler(), the code the daemon runs. They use:teams.mjs;Slack, pollers, audits and persistence are recorded instead of performed.
codex:/claude: … hooked after the 120 s cache …codex:/claude: … recognized after a daemon restartcodex:/claude: the original task-start acknowledgement …a stale same-task generation …altered or misaddressed …,… replayed on another session or a rebound channelgenuine local terminal input …a hook that beats its transport callback …,a late older-generation hook cannot discard a newer in-flight …,a declared completion survives …,a late hook for a terminated task …Mutations. Each was applied on its own against the suite, then the files were restored byte-identical:
Validation
npm test: 581 pass, 0 fail.npm run check,node --check(daemon/channel/scripts), Pi extension--list-modelsandshellcheckall pass. Every named import indaemon.mjsresolves (319 checked).npm run auditfails on a newly published advisory, GHSA-6qxp-vccf-f47h (@modelcontextprotocol/sdk≤ 1.30.1, OAuth client). This PR changes no dependencies; a separate lockfile PR fixes it.Deployment notes
dispatchingclaim is never retried across a restart.🤖 Generated with Claude Code
https://claude.ai/code/session_011Xs8AVBVrTGfpzUMiCQp2R