Skip to content

feat(daemon): give needsReply an optional deadline so silence becomes an event - #1164

Open
Poytr1 wants to merge 4 commits into
mainfrom
claude/needs-reply-deadline
Open

feat(daemon): give needsReply an optional deadline so silence becomes an event#1164
Poytr1 wants to merge 4 commits into
mainfrom
claude/needs-reply-deadline

Conversation

@Poytr1

@Poytr1 Poytr1 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Why

needsReply has 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 "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 with needsReply: 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:

  • 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 (markChildParentReply), so an ordinary delegation never pays for it;
  • exactly-once between an arriving report and the firing timer is the CAS delete: whichever runs first is the only one that acts, 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. This is not reachable from the tool surface.

New table parent_reply_deadline goes in the CREATE block (new tables need no migration step), so SCHEMA_VERSION is untouched.

The orchestration tools are not resurrected and no unrelated policy is touched.

Tests

  • 5 daemon unit cells (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.
  • 2 tool-surface validation cells (mcp-ops.test.ts): deadlineMs forwarded as trusted metadata; rejected without needsReply, out of range, or non-integer.
  • 1 scripted eval cell (webchat-night-collection.test.ts, in eval: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

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blocking correctness gaps remain in the new needsReply deadline path:

  1. A deadline is armed only after the local-target branch, so an accepted cross-daemon sendMessage silently loses deadlineMs and the waiting parent is never woken.
  2. 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread packages/daemon/src/daemon.ts Outdated
})
// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/daemon/src/daemon.ts Outdated
// 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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Poytr1 and others added 4 commits August 17, 2026 21:49
… 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>
@Poytr1
Poytr1 force-pushed the claude/needs-reply-deadline branch from 7a16d9d to 1072ec1 Compare August 17, 2026 13:52
@Poytr1

Poytr1 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

The Werewolf rerun: this PR cannot reach "zero host quiet-nudges", and here is exactly why

This change was motivated by the measured Werewolf failure — in every completed trial the human host had to post Referee: the vote has gone quiet — collect the missing votes 2–4 times before votes arrived. I did not run the 5p ×3 / 7p ×2 rerun, because tracing the arena shows it cannot move that number, and burning the matrix to demonstrate no change is not worth the hours or the spend.

The vote phase carries no needsReply obligation, so there is nothing for a reply deadline to attach to.

  • The referee opens the ballot with a public conversation postreplies.push('VOTE <n>. Discussion is closed. …') in evals/games/webchat-werewolf.ts. No sendMessage call is made.
  • Votes arrive as ordinary public posts, parsed in the phase === 'day-vote' branch via parseStatedTarget.
  • rePromptNonVoters — the lever that would fix this — is called from exactly one place: the branch guarded by humanLine && /collect the missing votes/. Its trigger is the human.
  • webchat-werewolf-runner.ts posts VOTE_NUDGE_TEXT when the arena has settled during day-vote, max 2 per round.

So the gap is the trigger, not the re-prompt. A needsReply deadline fires on a silent child of a call; at vote-open there is no such call outstanding.

What this PR would change, precisely

The re-prompt legs rePromptNonVoters sends are needsReply calls. Putting a deadlineMs on those lets the referee self-close the ballot when a re-prompted player still says nothing — that removes the second nudge of each round (the close-with-abstentions trigger). It cannot remove the first, which is what actually starts the recovery. Best case is roughly halving the count, never zero.

What reaching zero actually needs

A 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 RETIRED_ORCHESTRATION_TOOLS still describes:

the one-shot session-anchored DEADLINE WAKE … is the one capability sendMessage(needsReply) cannot express today (it has no timeout), and it is kept intact so needsReply can gain an optional deadline on top of it.

This PR is that follow-through for the child-reply half. The phase/self-timer half is genuinely separate, and re-exposing startOrchestration for it would undo #732.

The alternative I deliberately did not take

The referee could issue one private needsReply + deadlineMs leg per living player when it opens the vote. Non-voters would then produce deadline wakes and the existing lever would fire unaided — zero host nudges. I did not do this because it changes what the benchmark measures: the vote stops being a public act that players volunteer into, which is precisely what #986's binding public wording was tuned for. That is a call for whoever owns the arena design, not a side effect of a mechanism PR.

What this PR is verified to fix

The 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 webchat-night-collection.test.ts where a child whose turn never ends still wakes the referee, which then re-prompts unaided.

@agentconnect-md-test

Copy link
Copy Markdown
Contributor

Agreed. The current primitive covers silence from an outstanding needsReply child, not inactivity in a public vote phase with no child call. Skipping the expensive rerun is reasonable.

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 review-bot (Codex · gpt-5.6-sol) · open in session

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant