feat(daemon): give needsReply an optional deadline so silence becomes an event - #1164
feat(daemon): give needsReply an optional deadline so silence becomes an event#1164Poytr1 wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Two blocking correctness gaps remain in the new needsReply deadline path:
- A deadline is armed only after the local-target branch, so an accepted cross-daemon
sendMessagesilently losesdeadlineMsand the waiting parent is never woken. - Any failed child-to-parent report delivery disarms the deadline even though no event reached the parent, recreating the silence the feature is intended to eliminate.
I verified the review against head 40809d6f8fc4b721934bcd1e98b1d4836922a7da (the local checkout is a synthetic merge with exactly the supplied base/head parents) and traced the local and relay delivery paths. I could not run the focused tests because this isolated checkout has no installed dependencies; the findings are based on the exact PR patch and source inspection.
sent by review-bot (Codex · gpt-5.6-sol) · open in session
| replyRequested: req.needsReply === true, | ||
| replyState: 'awaiting' | ||
| }) | ||
| if (req.needsReply === true && req.replyDeadlineMs !== undefined) { |
There was a problem hiding this comment.
[P1] Preserve deadlines for cross-daemon targets. This arm is reached only after the local-target path: when !target, messageAgent returns from the relay branch at lines 6907–6945. replyDeadlineMs is also absent from RdAgentMsg/RdAgentMsgFwd, so the target daemon cannot arm it either. As a result, the public tool accepts deadlineMs and returns an admitted remote wake, but silence still produces no event. Carry the deadline through the relay and arm it where the durable child obligation can be canceled and fired with enough parent routing metadata.
| }) | ||
| // The obligation is settled either way — a queued report or a terminal failure both mean | ||
| // there is nothing left for a deadline to report as missing (#800). | ||
| this.cancelParentReplyDeadline(childSessionKey) |
There was a problem hiding this comment.
[P1] Do not disarm when the report failed to reach the parent. markChildParentReply(..., 'failed') is used when local admission is busy/queue-full and when a cross-daemon session reply fails; in those cases the parent received no report or failure event. Unconditionally deleting the deadline here leaves the sleeping parent silent forever—the exact failure this deadline is meant to surface. Keep the deadline armed for failed, or deliver a separate durable failure wake before disarming; only queued-for-parent currently proves an event reached the parent.
There was a problem hiding this comment.
The failed-report-delivery case from the previous review is fixed, but the cross-daemon deadline remains incorrect.
The new remote branch persists the deadline on the caller daemon while the rest of the implementation treats it as child-owned. A duty-enforced caller does not serve the remote child, so the deadline is never armed/fired. On a standalone caller it can fire, but an accepted remote report never cancels the source-side row, causing a later false “No report arrived” wake. The source store also cannot provide the remote child’s last-known state.
Please establish one consistent owner for remote deadlines: either carry the deadline through the relay and manage it on the child owner with durable parent routing metadata, or make it explicitly parent/source-owned and cancel it when the corresponding lineage reply is admitted.
Reviewed against head 60fc79e5ea642e3f34c94277208c4fb1c0651a49. Focused tests were not run because this isolated checkout has no installed dependencies; the finding is based on the exact fix delta, full PR patch, and delivery-path inspection.
sent by review-bot (Codex · gpt-5.6-sol) · open in session
| // A cross-daemon child owes the same report, so it gets the same deadline (review): | ||
| // arming only on the local branch would silently drop `deadlineMs` for remote targets. | ||
| if (req.needsReply === true && req.replyDeadlineMs !== undefined) { | ||
| this.armParentReplyDeadline({ |
There was a problem hiding this comment.
[P1] This still does not make cross-daemon deadlines work reliably. The row is written to the caller daemon’s store but remains keyed and gated as child-owned. In a duty-enforced deployment the caller holds the parent, not the remote child, so syncParentReplyDeadlines/fireParentReplyDeadline reject it at servesAgent(row.childAgentId) and it never fires. In a non-duty deployment it can fire, but a successful remote lineage reply is handled by handleRelayAgentMsg on this source daemon without calling markChildParentReply(remote.targetSession, ...), so the row is not canceled and later emits a false timeout after the real report. Move ownership/cancellation to the remote child via the relay, or model a source-owned deadline and cancel it from the accepted lineage reply using durable correlation.
There was a problem hiding this comment.
The previous cross-daemon ownership/cancellation bug is resolved by making remote deadlines an explicit unsupported case, but the resulting tool response still directs the caller into the failure mode this feature is meant to prevent.
For an admitted remote call, wake.deadlineIgnored is returned, yet executeTool unconditionally emits nextAction: "finish-turn-and-wait" and a message saying to end the turn and wait for a later wake. If the remote child is silent, no wake exists, so following the response strands the parent indefinitely. Branch the top-level guidance on deadlineIgnored (and preferably surface the marker at the top level) so the caller is clearly told that no timeout was armed and must not rely on a wake.
Reviewed against head 6e646c8a800383949d6b8d4f16dfb48c57bca0f9. Focused tests were not run because this isolated checkout has no installed dependencies; the finding is based on the exact fix delta and full tool-result path.
sent by review-bot (Codex · gpt-5.6-sol) · open in session
| reason?: string | ||
| /** Present only when a requested `deadlineMs` was NOT armed, so the caller does not wait on a | ||
| * wake that will never come. Today the one case is a target served by another daemon. */ | ||
| deadlineIgnored?: string |
There was a problem hiding this comment.
[P1] Use this marker to change the actual sendMessage result guidance. executeTool currently nests it under wake, but for every admitted needsReply call it still unconditionally returns nextAction: 'finish-turn-and-wait' and “End this turn and wait.” For a remote silent child, the new code guarantees there is no deadline wake, so following that instruction leaves the parent asleep forever. When wake.deadlineIgnored is present, return explicit top-level guidance that no timeout was armed and the caller must not rely on a wake (and expose the marker where the description says callers will find it).
There was a problem hiding this comment.
The previous response-guidance issue is fixed, but deadlineMs can still be silently ineffective after a duty handoff.
Duty-managed daemons intentionally retain an agent replica after losing its duty. messageAgent classifies a target as local using only this.agents.get(...), so a retained, non-held target bypasses the remote branch and its deadlineIgnored result. The new local path arms a timer, but both synchronization and firing require servesAgent(childAgentId). The source member therefore refuses its own timer at expiry, while the actual holder is not notified to arm it, leaving the parent waiting without the promised event.
Use current service ownership—not replica presence—to choose the local deadline path. A non-held retained target should route through the remote path or at least return the same explicit unsupported-deadline result.
Reviewed against head 7a16d9d4b1b9bcda17ad81ac32ad7aae1ce1861b. Focused tests were not run because this isolated checkout has no installed dependencies; the finding is based on the exact patch and the duty handoff lifecycle.
sent by review-bot (Codex · gpt-5.6-sol) · open in session
| replyRequested: req.needsReply === true, | ||
| replyState: 'awaiting' | ||
| }) | ||
| if (req.needsReply === true && req.replyDeadlineMs !== undefined) { |
There was a problem hiding this comment.
[P1] Gate this “local” arm on actual service ownership. In duty mode, losing a grant deliberately keeps the agent replica in this.agents (dutyBundleIsStale documents this), so target can be truthy while servesAgent(req.toAgentId) is false. Such a call skips the remote branch and does not return deadlineIgnored, arms here, then the timer returns at fireParentReplyDeadline’s servesAgent check. The current holder receives no notification to arm the shared row, so silence still produces no timely event. Treat a retained non-held replica as remote for this decision (ideally route the wake through the relay), or return the explicit unsupported-deadline guidance.
… 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. Measured in the webchat Werewolf arena: after the referee announced a vote and ended its turn, votes only ever arrived after a HUMAN posted "the vote has gone quiet" — 2-4 times in every completed game. The referee could not run its own non-voter re-prompt lever, because nothing told it anything was missing. 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 report arriving first disarms it, so an ordinary delegation never pays; - exactly-once between an arriving report and the firing timer is the CAS delete: whichever runs first is the only one to act, and on a shared store only one pool member wins; - the wake carries the parent as a trusted internal origin, because the ordinary authorization reads the child's session row, which may not exist. Tests: 5 daemon unit cells (fires on silence, cancelled by an arriving report, exactly-once under a double fire, restart re-arm over the same store, no arm without a deadline), 2 tool-surface validation 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>
…t armed on a failed report (review) Two correctness gaps the review found: 1. The deadline was armed only on the local-target branch, so an accepted cross-daemon `sendMessage` silently dropped `deadlineMs` and the waiting parent was never woken. A remote child owes the same report, so it now gets the same deadline, keyed by the canonical session key the target returned. 2. `markChildParentReply(..., 'failed')` disarmed the deadline even though a failed report reached the parent with nothing — recreating exactly the silence this feature exists to break. Only a QUEUED report disarms now, and the fire treats `failed` as still-outstanding. Adds the regression cell for (2); the schema-shape pin in mcp-tools.test.ts picks up the new `deadlineMs` property. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y for remote (review) The previous fix armed a caller-side deadline for a cross-daemon child, which the review correctly rejected as having no consistent owner: every disarm path (`markChildParentReply`) runs on the daemon that OWNS the child, so the caller's row would never be cancelled by an accepted remote report and would later fire a false "no report arrived". A duty-enforced caller does not serve the remote child either, and its store cannot supply that child's last-known state. Arming on the child's daemon instead needs the deadline and durable parent routing carried through the relay — a wire change, deliberately out of this PR. So the deadline is local-only, and a remote `deadlineMs` is refused LOUDLY rather than silently ignored: the daemon logs it and the tool result carries `deadlineIgnored`, which the tool description tells the caller to read as "nothing will wake you — use viewSessionStatus". Documented as a stated boundary in send-message-routing-rework.md §3.1a. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…review) An admitted remote call returned `deadlineIgnored` but the tool response still carried `nextAction: "finish-turn-and-wait"` and told the caller to end its turn and wait for a later wake. With no deadline armed and a silent child, no wake exists — following that advice strands the parent indefinitely, which is the exact failure this feature exists to prevent. The guidance now branches on it: `deadlineIgnored` is surfaced at the top level, `nextAction` becomes `wait`, and the message tells the caller that nothing will wake it and to check with `viewSessionStatus` and decide when to proceed without an answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7a16d9d to
1072ec1
Compare
The Werewolf rerun: this PR cannot reach "zero host quiet-nudges", and here is exactly whyThis change was motivated by the measured Werewolf failure — in every completed trial the human host had to post The vote phase carries no
So the gap is the trigger, not the re-prompt. A What this PR would change, preciselyThe re-prompt legs What reaching zero actually needsA wake on the absence of public activity — "wake me at T if nothing has happened" — anchored on the referee's own session rather than on a child. That is a different primitive. Notably it is the one
This PR is that follow-through for the child-reply half. The phase/self-timer half is genuinely separate, and re-exposing The alternative I deliberately did not takeThe referee could issue one private What this PR is verified to fixThe needsReply-silence gap it claims, no more: a child that never starts, never finishes, or whose wake is gated now wakes its parent instead of stranding it. That is covered by 6 daemon unit cells, 3 tool-surface cells, and a scripted arena cell in |
|
Agreed. The current primitive covers silence from an outstanding The PR description should make that boundary explicit: it may eliminate later close-with-abstentions nudges after re-prompts, but cannot eliminate the initial host nudge. A session/phase inactivity timer is a separate feature. sent by |
Why
needsReplyhas no timeout. A child that simply never answers produces no event anywhere: the parent ended its turn expecting a report, nothing wakes it again, andviewSessionStatusis poll-only — which requires already being awake.Measured in the webchat Werewolf arena: after the referee announced a vote and ended its turn, votes only ever arrived after a human posted "Referee: the vote has gone quiet — collect the missing votes" — 2–4 times in every completed game. The referee could never run its own non-voter re-prompt lever, because nothing told it anything was missing.
#984's inferred reply covers the adjacent case — a child whose turn ends without a report has its final output delivered to the parent. 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.
What
toAgent.deadlineMs— optional, only valid withneedsReply: true, 1s–24h:{ "toAgent": { "agentId": "player-3", "needsReply": true, "deadlineMs": 120000 }, "message": "Cast your vote." }On expiry the daemon wakes the awaiting session with a notice naming the target, the delivery id, and the child's last known state, and saying 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.
How
Reuses the deadline machinery retained when the orchestration tools were retired in #732 — durable epoch, live one-shot timer, CAS claim, duty gating, re-arm from the store on startup and on every duty change — with its own record keyed by child session:
markChildParentReply), so an ordinary delegation never pays for it;New table
parent_reply_deadlinegoes in theCREATEblock (new tables need no migration step), soSCHEMA_VERSIONis untouched.The orchestration tools are not resurrected and no unrelated policy is touched.
Tests
packages/daemon/test/parent-reply-deadline.test.ts): fires on silence; cancelled by an arriving report; exactly-once under a double fire; restart re-arm over the same store; no arm without a deadline.mcp-ops.test.ts):deadlineMsforwarded as trusted metadata; rejected withoutneedsReply, out of range, or non-integer.webchat-night-collection.test.ts, ineval:collab:contracts): a child whose delegation turn never ends still wakes the referee, which then re-prompts unaided.Local:
pnpm typecheck,pnpm lint,pnpm format:check,pnpm eval:collab:contracts(17 files / 123 tests) all green.Note
The real-model Werewolf rerun (5p ×3, 7p ×2) that this change is ultimately aimed at is reported separately — see the PR discussion.
🤖 Generated with Claude Code