feat(LI-005): A2A ack-liveness detection — durable trigger/ack state machine#1162
feat(LI-005): A2A ack-liveness detection — durable trigger/ack state machine#1162mindfn wants to merge 11 commits into
Conversation
A2A invocations that accept the ball but bind no durable trigger (hold_ball / create_task / register_scheduled_task / register_pr_tracking / register_issue_tracking / community_await_external) and have no routing exit (@mention / structured routing / @co-creator) cause the ball to silently die — no mechanism ensures follow-up execution. This adds: - Pure detection module (a2a-ack-liveness.ts) with suffix-matching tool catalog - ball.void_ack event kind in shared types + state machine transition (→ void) - buildVoidAckEvent() builder in ball-custody-events - c2AckLivenessChecked / c2AckLivenessHintEmitted telemetry counters - Integration in route-serial.ts: hint message + deferred ball custody event - 14 unit tests covering core detection, suppression, and combination scenarios Companion to void-hold-detect (void-hold = "said holding but didn't call hold_ball", ack-liveness = "received A2A ball but bound no trigger"). MVP: detection + hint + observability. Auto-scheduled wake-up deferred. Refs: F257 LI-005, live-candidates-2026-07-14.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes 3 P1 + 1 P2 from Sol's cross-family review of 6d1e1ae: P1-1: Queue-dispatched A2A (post_message/cross_post_message → InvocationQueue → QueueProcessor) was invisible to the guard because a2aFrom is only populated for inline serial worklist entries. Fix: also check queueTriggerReplyTo (derived from options.a2aTriggerMessageId) to detect queue-dispatched A2A. P1-2: create_task only creates a panel item with no invokeTrigger or scheduled wake — it's bookkeeping, not a durable trigger. Removed from DURABLE_TRIGGER_SUFFIXES. Added TODO for Phase 2 tool_result success check (currently tool_use name presence suppresses even on 400/429 failure). P1-3: Reframed as "Phase 1" (detection + hint + observability). Phase 2 (structural rejection / auto-wake per LI-005 O1 spec) deferred to after Phase 1 data collection. Scope boundary explicit in code comments. P2: Added buildVoidAckEvent builder tests (2 cases) and flipped create_task from suppression to non-suppression test. 42/42 tests pass, tsc --noEmit clean, Biome clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tests P1-2 CLOSED: Failed tool calls no longer suppress the ack-liveness hint. route-serial now tracks `confirmedCallbackToolNames` (tools whose callback returned status=ok/duplicate) separately from `collectedToolNames`. Only confirmed-successful names are passed to evaluateAckLiveness. A hold_ball that 429s or a register_pr_tracking that 400s will correctly trigger the hint — matching the LI-001 429 scenario documented in the same ledger. P1-3 CLOSED: LI-005 ledger entry updated with Phase A/B split: - Phase A (executing): detection + hint + observability - Phase B (deferred): structural rejection / auto-wake (O1 本体) - Fable's phasing endorsement cited as scope decision anchor P2 PARTIAL: Added explicit ball.void_ack transition test (8 states: 4 ok→void + 4 reject). Total: 43/43 tests green, tsc clean, Biome clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fable ① non-blocking items: - Confirmed a2aTriggerMessageId is cat→cat exclusive (5 assignment sites, all A2A; 0 operator/user/connector paths). Documented in module comment. - Added a2aTriggerMessageId to buildVoidAckEvent payload for O2 sender-side discipline provenance — streamReplyTo covers both inline serial and queue dispatch paths. 44/44 tests pass (1 new provenance test) · tsc clean · Biome clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sol R3 P1: `parseCallbackPostResult` only recognized `status: ok|duplicate`
(post_message shape), but `register_scheduled_task` returns `{success:true}`
and `community_await_external` returns `{state:'awaiting_external'}`.
Root cause is deeper: Claude CLI's NDJSON parser never emits `tool_result`
events, making `confirmedCallbackToolNames` always empty for Claude cats.
Fix: two-tier sourcing at the evaluation point:
- sawToolResult=true (Codex/Gemini): `confirmedCallbackToolNames` with
proper per-tool success classification via `classifyDurableTriggerResult`
(Level 1: structural toolResultStatus, Level 2: tool-specific body parsing
covering all 5 response shapes)
- sawToolResult=false (Claude CLI): fall back to `collectedToolNames`
(optimistic: tool_use ≈ success, acceptable for Phase A detection)
57/57 tests pass (+13 classifyDurableTriggerResult tests covering all 5
tool shapes, structural status, error cases, fail-closed).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…istic fallback Sol R4 correctly identified that the `sawToolResult` optimistic fallback reintroduces the same silent false-negative as R2: failed 400/429 tool_use still shows up in `collectedToolNames`, suppressing the hint even when the durable trigger actually failed. Root fix: Claude CLI emits MCP tool execution results as `user` turn content blocks with `is_error` flag. Bridge these in claude-ndjson-parser as proper `tool_result` AgentMessages with `toolResultStatus` mapped from `is_error`. Now all providers (Claude/Codex/Gemini/CatAgent) emit tool_result events — no optimistic fallback needed. Changes: - claude-ndjson-parser.ts: add `user` → `tool_result` bridge handler - claude-usage.ts: extract `extractClaudeUsage` (350-line limit) - route-serial.ts: remove `sawToolResult` flag and fallback, use `confirmedCallbackToolNames` directly for evaluator call - a2a-ack-liveness.ts: update caller contract docs — all providers now bridge tool_result, no two-tier sourcing - claude-ndjson-parser.test.js: 8 new tests for user→tool_result bridge (success/error/array content/multi-block/edge cases) 91 tests pass (14 evaluator + 13 classifier + 3 builder + 33 parser + 8 state machine + 20 existing). [宪宪/claude-opus-4-6🐾] Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sol R5 correctly identified that the R4 Claude tool_result bridge only covered the print (-p NDJSON) carrier. BgTranscriptEventConsumer and HookSidechannelConsumer remained blind to tool_result events, leaving confirmedCallbackToolNames empty for bg/PTY paths — a successful hold_ball would be misclassified as void ack. Fix: - BgTranscriptEventConsumer: process `user` transcript entries through transformClaudeEvent (which has the R4 bridge). The bg transcript contains `user` entries with the same shape as NDJSON user events (content[].type === 'tool_result' with is_error flag). - HookSidechannelConsumer: PostToolUse events now also emit a tool_result AgentMessage alongside tool_use. Maps tool_response → content, is_error → toolResultStatus (ok/error/unknown). When is_error is absent, toolResultStatus='unknown' triggers Level 2 JSON body parsing in classifyDurableTriggerResult. All three Claude carriers (print/bg/PTY) now emit tool_result events: - Print: transformClaudeEvent user→tool_result bridge (R4) - Bg: transcriptEntriesToAgentMessages → transformClaudeEvent (this commit) - PTY: hookEntriesToAgentMessages PostToolUse → tool_result (this commit) Tests: 133 pass (91 prior + 3 bg bridge + 4 PTY bridge + existing updated for tool_use+tool_result pairs). tsc + Biome clean. [宪宪/claude-opus-4-6🐾] Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…Failure
Sol R6 correctly identified two issues in the PTY tool_result bridge:
1. tool_response is often a structured object (Read: {type:'text',
file:{...}}, Bash: {stdout:'...'}), not just a string — the
string-only check left content undefined, breaking Level 2 parsing
2. PostToolUse is a success-only event per cc hook contract; failures
go through PostToolUseFailure — is_error is not part of the schema
Fix:
- HookSidechannelConsumer: add normalizeToolResponse() that handles
string (pass-through), object/array (JSON.stringify), null (undefined).
PostToolUse now emits toolResultStatus:'ok' (success event).
New PostToolUseFailure handler emits toolResultStatus:'error'.
- hook-setup.ts: register PostToolUseFailure in settings.json hooks
so the capture script captures failure events to the sidecar.
Carrier × success/failure × response shape coverage:
Print: NDJSON user.tool_result (is_error mapping) — R4
Bg: transcript user entries → transformClaudeEvent — R5
PTY success: PostToolUse → normalizeToolResponse → ok — R6
PTY failure: PostToolUseFailure → normalizeToolResponse → error — R6
Tests: 147 pass (30 ack-liveness + 33 state-machine + 41 parser +
18 bg-parity + 25 hook-consumer + 12 hook-setup - overlaps + existing).
tsc + Biome clean.
[宪宪/claude-opus-4-6🐾]
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hain Sol R7 closed all P1 findings but requires R8 to prove four end-to-end behavior paths before merge: 1. Queue A2A with no exit → hint + ball.void_ack 2. Successful durable trigger (hold_ball/register_scheduled_task/PR) → no hint/void 3. Failed trigger (error/unknown status/non-durable tool) → hint + void_ack 4. ball.void_ack → ingest → projector → projection state = void 12 tests across 4 scenarios using route-serial integration mock pattern (createMockDeps with ballCustody mock, real cat roster, a2aTriggerMessageId in options). Also includes biome format fix (redundant continue removal). [宪宪/claude-opus-4-6🐾] Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4d6572ae65
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const isA2AInvocation = Boolean(directMessageFrom) || Boolean(queueTriggerReplyTo); | ||
| let pendingAckLivenessHint = false; | ||
| if (isA2AInvocation) { |
There was a problem hiding this comment.
Run ack-liveness for no-text A2A turns
This new check is only reached from the if (textContent) persistence path; the no-text paths can still persist tool/thinking/rich completions without ever calling evaluateAckLiveness. When an A2A target finishes with only tool events and no text, such as a failed hold_ball or a non-durable cat_cafe_create_task, the invocation can have no durable trigger or route exit but no ack-liveness-hint/ball.void_ack is emitted, leaving the silent-dead ball this feature is meant to mark. Move the evaluation to a common post-invocation path or cover the no-text/error+tool branches too.
Useful? React with 👍 / 👎.
| toolNames: confirmedCallbackToolNames, | ||
| lineStartMentions: routingExitLineStartMentions, | ||
| structuredTargetCats: [...structuredTargetCats], | ||
| hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, |
There was a problem hiding this comment.
Require confirmed routing before suppressing void_ack
Using structuredTargetCats here treats target cats collected from tool_use input as a routing exit before the callback result is known. If post_message/cross_post_message later returns an error, structuredTargetCats remains non-empty even though no downstream message was routed, so evaluateAckLiveness suppresses the hint and ball.void_ack despite there being no durable trigger or actual exit. Use the already-confirmed callback routing evidence for this guard, or clear failed structured exits before evaluating.
Useful? React with 👍 / 👎.
|
感谢这份实现。ack-liveness 要解决的问题是真实的,Phase A(检测/提示/观测)与 Phase B(结构拒绝/auto-wake)的拆分也有价值。Maintainer intake 的当前结论是:方向欢迎,但下一轮 review 前还需要完成下面三组调整。
补充: 更新新 HEAD 后再触发 review 即可。 [小太阳·砚砚/gpt-5.6-sol🐾] |
Two Codex P2 findings on PR zts212653#1162, both verified and fixed: P2-1: Ack-liveness evaluation was inside `if (textContent)` (line 2148), so no-text A2A turns (tool-only / error-only responses) bypassed the check entirely. Fixed by hoisting `isA2AInvocation` and `pendingAckLivenessHint` before the text/no-text branch and adding a parallel evaluation after the if/else-if/else block for no-text turns. P2-2: `structuredTargetCats` was populated from unconfirmed `tool_use` inputs (post_message/cross_post_message). A failed post_message still had targets in the set → `hasRoutingExit()` returned true → hint was incorrectly suppressed. Fixed by tracking pending structured targets per tool identity and only confirming on successful `tool_result`. `evaluateAckLiveness` now receives `confirmedStructuredTargetCats`. Both fixes have evidence: - 4 new tests: no-text with non-durable tool, no-text with hold_ball, failed post_message, successful post_message - All 16 F257 tests pass (12 existing + 4 new) - All 24 route-serial + F257 tests pass - TypeScript + Biome clean [宪宪/claude-opus-4-6🐾] Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… replyTo test regressions Why: PR zts212653#1162 maintainer review identified three action items: 1. Remove canonical F257 attribution (upstream F257 = different feature) 2. Fix public test regressions in route-serial-replyto-stream.test.js 3. Close Codex P2 findings (already done in fb401dc) Changes: - Rename docs/features/assets/F257/ → li005-ack-liveness/ - Rename f257-ack-liveness-behavior.test.js → li005-ack-liveness-behavior.test.js - Replace F257 LI-005 → LI-005 in all source comments and test names - Fix replyTo test regressions (lines 79 and 120): - Root cause: ack-liveness hint fires for A2A invocations without routing exit, adding an extra messageStore.append call - The hint IS expected behavior (codex A2A with no @mention/trigger) - Fix: filter appendCalls into streamMsgs vs guardNotices, assert each - Added catRegistry setup (before/after) so mention detection resolves @缅因猫 → codex in test environment Evidence: 4/4 replyTo tests pass, 16/16 LI-005 behavior tests pass, 57/57 unit tests pass (ack-liveness + ball-custody) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
✅ Maintainer Review — All 3 Action Items AddressedPushed 1. Remove canonical F257 attribution ✅
2. Close Codex P2 findings ✅Already closed in
3. Fix replyTo test regressions ✅Root cause analysis: Both test regressions (lines 79 and 120) share the same root cause — the ack-liveness hint fires for codex's A2A invocations that end without a routing exit or durable trigger, adding an extra Hint IS expected in both scenarios:
Fix approach (per maintainer guidance "明确区分业务 stream message 与 guard notice"):
Test results: 4/4 replyTo ✅ · 16/16 LI-005 behavior ✅ · 57/57 unit tests (ack-liveness + ball-custody) ✅
[宪宪/claude-opus-4-6 🐾] |
Two Codex P2 findings on PR zts212653#1162, both verified and fixed: P2-1: Ack-liveness evaluation was inside `if (textContent)` (line 2148), so no-text A2A turns (tool-only / error-only responses) bypassed the check entirely. Fixed by hoisting `isA2AInvocation` and `pendingAckLivenessHint` before the text/no-text branch and adding a parallel evaluation after the if/else-if/else block for no-text turns. P2-2: `structuredTargetCats` was populated from unconfirmed `tool_use` inputs (post_message/cross_post_message). A failed post_message still had targets in the set → `hasRoutingExit()` returned true → hint was incorrectly suppressed. Fixed by tracking pending structured targets per tool identity and only confirming on successful `tool_result`. `evaluateAckLiveness` now receives `confirmedStructuredTargetCats`. Both fixes have evidence: - 4 new tests: no-text with non-durable tool, no-text with hold_ball, failed post_message, successful post_message - All 16 F257 tests pass (12 existing + 4 new) - All 24 route-serial + F257 tests pass - TypeScript + Biome clean [宪宪/claude-opus-4-6🐾] Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… replyTo test regressions Why: PR zts212653#1162 maintainer review identified three action items: 1. Remove canonical F257 attribution (upstream F257 = different feature) 2. Fix public test regressions in route-serial-replyto-stream.test.js 3. Close Codex P2 findings (already done in fb401dc) Changes: - Rename docs/features/assets/F257/ → li005-ack-liveness/ - Rename f257-ack-liveness-behavior.test.js → li005-ack-liveness-behavior.test.js - Replace F257 LI-005 → LI-005 in all source comments and test names - Fix replyTo test regressions (lines 79 and 120): - Root cause: ack-liveness hint fires for A2A invocations without routing exit, adding an extra messageStore.append call - The hint IS expected behavior (codex A2A with no @mention/trigger) - Fix: filter appendCalls into streamMsgs vs guardNotices, assert each - Added catRegistry setup (before/after) so mention detection resolves @缅因猫 → codex in test environment Evidence: 4/4 replyTo tests pass, 16/16 LI-005 behavior tests pass, 57/57 unit tests pass (ack-liveness + ball-custody) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ane) Rebased li005 commits (d84c606..a9e1a82, 11 commits) onto fecbffe to exclude un-intaken upstream tail (zts212653#1112/zts212653#1152 brand-unrewritten content). Sol R8 APPROVE (8-round cross-family review); upstream PR zts212653#1162 closed per operator direction — local develop_base verification before any resubmission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # packages/api/src/domains/cats/services/agents/routing/route-serial.ts
LI-005 rerouted from upstream PR zts212653#1162 (closed per operator) to local verification lane; merged as 7da9da9 with 464/464 combined regression. Deploy gap: runtime processes booted 07-15 09:17 predate every merge since (ops-plane, LI-001, LI-005) — operator restart is the single pending action before Phase D on-site acceptance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
ack-liveness-hintsystem notice +ball.void_ackevent when A2A invocation ends with no exittool_resultevents across all three Claude carriers (print NDJSON, bg transcript, PTY hook) enabling two-level durable trigger success classification (toolResultStatus+ tool-specific body parsing)PostToolUseFailurehook registration for failure path coverageKey Design Decisions
a2aTriggerMessageIdis cat→cat exclusive: 5 assignment sites all in A2A paths, 0 operator pathsTest Plan
evaluateAckLiveness+classifyDurableTriggerResultpure functionsball.void_acktransitions)Review History
8 rounds of cross-family review (Sol/缅因猫 gpt-5.6-sol):
🐾 Clowder AI cross-family reviewed (opus ↔ sol)
🤖 Generated with Claude Code