feat(daemon): infer the parent reply when a needsReply delegation turn ends without its report - #984
Conversation
…n ends without its report (#800) The mechanism half the parked directive fix (#905) could not substitute for, measured on the webchat night-collection cell: a COLD needsReply child mostly answers its delegation as its ordinary assistant response — a correct answer delivered to nobody — and never reaches for any messaging tool (9/9 lost at baseline, 8/9 still lost with #905's directive). The pi-intercom pattern flips the delegate-and-forward red pin: a headless child's answer is never silently dropped. maybeInferParentReply runs at clean turn completion, while the turn's activeTurnCallMeta is still installed: if the turn's OWN trusted CallMeta carries needsReply + an origin and the obligation is still 'awaiting', the child's final ordinary output is delivered to the parent through the UNCHANGED replyToSession path (origin authorization, hop charge, queue/ coalesce, markChildParentReply), prefixed with an explicit '[inferred reply]' marker so the parent and the artifacts can always tell it from a real report. Empty / no-response finals become an explicit 'finished without reporting' wake instead of silence. Niche boundary: delegation turns only (human follow-ups, plain calls, continuations never infer); failed/suppressed turns keep their own semantics; sessions with live background tasks defer to the bg-task wake. Sanctioned expectation flip: the scripted night-collection prose-loss cell now pins 'delivered-inferred' (was 'lost' — that WAS current main's truth, and changing it is this fix's entire point), with the scorer distinguishing inferred deliveries by the marker inside an admitted reply wake. New unit pins in inferred-parent-reply.test.ts: prose answer delivered+marked; empty answer → explicit no-report wake; a real report is never doubled; a plain call never infers. Refs #800 (mechanism half; adapter disallowedTools finding noted there), #905 (stays parked — directive alone measured insufficient). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Requesting changes for one background-task lifecycle race in the new inferred-reply path. The plain prose, empty output, real-report deduplication, and non-needsReply cases are otherwise well scoped, and the focused tests, daemon lifecycle suite, daemon typecheck, lint, and formatting checks all passed. However, a task can leave the live-task set before its deferred completion wake is delivered, so the new code may infer a premature report and the later background wake may produce a second real report.
sent by review-bot (Codex · gpt-5.6-sol) · open in session
| // task settles (see wakeOnBackgroundTaskDone). Do not preempt that with a premature | ||
| // inference of "I started the task…" narration. | ||
| const sessionId = this.store.getSession(childKey)?.acpSessionId ?? undefined | ||
| if (sessionId !== undefined && (this.sdkLease.get(sdkLeaseKey(agentId, sessionId))?.tasks.size ?? 0) > 0) return |
There was a problem hiding this comment.
This fence misses a settled-but-not-yet-delivered background task. settle() deletes the task from lease.tasks before scheduleBackgroundTaskWake() increments armedWakes, and wakeOnBackgroundTaskDone() explicitly defers that wake when the current dispatch is still finalizing. In that window this check sees zero tasks and infers the turn's narration (for example, “I started the task”) to the parent; the deferred wake then runs with the sticky parent-report directive and can send the actual result as a second parent wake. Please also defer inference while a completion wake is armed/delivering (or otherwise use the existing background-work fence), so the background-task path still owns the one report.
There was a problem hiding this comment.
Fixed in bef225d: the guard now also treats lease.armedWakes > 0 as live background work, closing the settle window — a task that left lease.tasks with its (possibly deferred) wake still armed defers inference to the bg-task wake path instead of shipping the turn's narration.
There was a problem hiding this comment.
Confirmed—the armedWakes guard closes the settle/deferred-wake race. I re-reviewed bef225d5 and approved the revision. Thanks for the fix.
sent by review-bot (Codex · gpt-5.6-sol) · open in session
…ound guard (review) A task that just settled leaves lease.tasks before its wake timer fires, and that wake defers while the current dispatch finalizes — a tasks-only check saw zero and inferred the turn's narration while the bg-task wake was still owed. Guard on armedWakes too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Approved. The synchronized revision fixes the prior background-task race at the correct lifecycle boundary: inference now stands down while a settled task still owns an armed completion wake, preventing the premature inferred report followed by a second real report. I found no remaining blocking issues in the complete PR patch. Verification on bef225d59555d8aa97b378ce4e615e30817b79d7: 120 focused inferred-reply/message-agent/night-collection tests passed, 63 daemon lifecycle tests passed, and daemon typecheck, targeted ESLint, and Prettier checks passed.
sent by review-bot (Codex · gpt-5.6-sol) · open in session
… an event `needsReply` had no timeout. A child that simply never answers produces no event anywhere: the parent ended its turn expecting a report, nothing wakes it again, and `viewSessionStatus` is poll-only, which requires already being awake. #984's inferred reply covers the adjacent case (a child whose turn ENDS without reporting). It cannot cover a child that never starts, never finishes, or whose wake is gated — there is no turn end to hang an inference on. `toAgent.deadlineMs` (only with `needsReply: true`, 1s-24h) arms a one-shot, child-anchored deadline. On expiry the daemon wakes the parent with a notice naming the target, delivery id, and the child's last known state; it states explicitly that it is not the child's answer. The daemon never fabricates a reply — the parent decides whether to re-prompt, escalate, or proceed. Reuses the retained orchestration-deadline machinery (durable epoch, live one-shot timer, CAS claim, duty gating, re-arm on startup and duty change) with its own record keyed by child session: - the durable row is written at CALL time, so it exists even when the child has no session row and never gets one — the case the deadline exists for. It carries the child's coordinates for the same reason; - a QUEUED report disarms it; a report that FAILED to deliver does NOT, because the parent still received nothing; - exactly-once between an arriving report and the firing timer is the CAS delete; - the wake carries the parent as a trusted internal origin, because the ordinary authorization reads the child's session row, which may not exist. Local targets only: every disarm path runs on the daemon that owns the child, so a caller-side row for a remote child could never be cancelled and would later fire a false "no report arrived". A remote `deadlineMs` is refused loudly instead — logged, and surfaced as `deadlineIgnored` with `nextAction: wait` and advice to use `viewSessionStatus`, so the caller is never told to wait for a wake that cannot come. Tests: 6 daemon unit cells (fires on silence, cancelled by an arriving report, exactly-once under a double fire, restart re-arm over the same store, a failed report keeps it armed, no arm without a deadline), 3 tool-surface cells, and a scripted eval cell in the night-collection scenario where a child that never reports still wakes the referee, which then re-prompts unaided. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… an event `needsReply` had no timeout. A child that simply never answers produces no event anywhere: the parent ended its turn expecting a report, nothing wakes it again, and `viewSessionStatus` is poll-only, which requires already being awake. #984's inferred reply covers the adjacent case (a child whose turn ENDS without reporting). It cannot cover a child that never starts, never finishes, or whose wake is gated — there is no turn end to hang an inference on. `toAgent.deadlineMs` (only with `needsReply: true`, 1s-24h) arms a one-shot, child-anchored deadline. On expiry the daemon wakes the parent with a notice naming the target, delivery id, and the child's last known state; it states explicitly that it is not the child's answer. The daemon never fabricates a reply — the parent decides whether to re-prompt, escalate, or proceed. Reuses the retained orchestration-deadline machinery (durable epoch, live one-shot timer, CAS claim, duty gating, re-arm on startup and duty change) with its own record keyed by child session: - the durable row is written at CALL time, so it exists even when the child has no session row and never gets one — the case the deadline exists for. It carries the child's coordinates for the same reason; - a QUEUED report disarms it; a report that FAILED to deliver does NOT, because the parent still received nothing; - exactly-once between an arriving report and the firing timer is the CAS delete; - the wake carries the parent as a trusted internal origin, because the ordinary authorization reads the child's session row, which may not exist. Local targets only: every disarm path runs on the daemon that owns the child, so a caller-side row for a remote child could never be cancelled and would later fire a false "no report arrived". A remote `deadlineMs` is refused loudly instead — logged, and surfaced as `deadlineIgnored` with `nextAction: wait` and advice to use `viewSessionStatus`, so the caller is never told to wait for a wake that cannot come. Tests: 6 daemon unit cells (fires on silence, cancelled by an arriving report, exactly-once under a double fire, restart re-arm over the same store, a failed report keeps it armed, no arm without a deadline), 3 tool-surface cells, and a scripted eval cell in the night-collection scenario where a child that never reports still wakes the referee, which then re-prompts unaided. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…essions (#1553) The runtime's agent-teams SendMessage collides with mcp__agentconnect__sendMessage (#800) and is a working delivery channel: a mis-picked call delivers session-private content to unrelated co-located local sessions with no audit trail (#998). Disallow it on every AgentConnect-managed Claude session via _meta.claudeCode.options.disallowedTools, which claude-agent-acp spreads into the SDK query() options; older adapters ignore the unknown option. Fixes #998. Refs #800, #984. Co-authored-by: 谭凯文 <tankaiwen@tankaiwendeMacBook-Pro.local> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
What
The #800 mechanism half — measured to be what the parked directive fix (#905) cannot substitute for: on the webchat night-collection cell, a COLD needsReply child answers its delegation as its ordinary assistant response (a correct answer delivered to nobody) and never reaches for any messaging tool — 9/9 lost at baseline, 8/9 still lost WITH #905's directive; 7 of 9 children made zero messaging-tool calls.
maybeInferParentReply(pi-intercom's inferred-reply pattern) runs at clean turn completion while the turn'sactiveTurnCallMetais still installed: if the turn's OWN trusted CallMeta carriesneedsReply+ an origin and the obligation is stillawaiting, the child's final ordinary output is delivered to the parent through the unchangedreplyToSessionpath (origin authorization, hop charge, queue/coalesce,markChildParentReply), prefixed[inferred reply]so the parent — and every artifact — can always tell it from a real report. An empty / no-response final becomes an explicit finished without reporting wake instead of silence.Exactly-one-obligation scoping (the niche boundary): delegation turns only — human follow-ups, plain calls, and continuations never infer; failed/suppressed turns keep their own semantics; a session with live background tasks defers to the existing bg-task wake.
Sanctioned expectation flip (the one intended behavior change): the scripted night-collection prose-loss cell now pins
delivered-inferredwhere current main's truth waslost— flipping that pin is this fix's entire point. The scorer distinguishes inferred deliveries by the marker inside an admitted reply wake (daemon evidence, not content visibility).Lever-1b finding (noted on #800, not wired):
claude-agent-acpforwardssessionMeta.claudeCode.options.disallowedTools, so the colliding built-inSendMessagecould be suppressed per session — more than a trivial flip to do safely (runtime-gated seam + subagent implications); the inferred reply catches that failure mode regardless.Tests
New
packages/daemon/test/inferred-parent-reply.test.ts: prose answer delivered + marked; empty answer → explicit no-report wake; a real report is never doubled; a plain call never infers. Night-collection + webchat Werewolf scripted games green; daemon webchat/continuation/session-manager suites unmodified.Refs #800, #905 (stays parked), #941 (the measuring cell).
🤖 Generated with Claude Code