diff --git a/docs/designs/send-message-routing-rework.md b/docs/designs/send-message-routing-rework.md index 47f69ffa7..025c01f91 100644 --- a/docs/designs/send-message-routing-rework.md +++ b/docs/designs/send-message-routing-rework.md @@ -73,6 +73,7 @@ type AgentTarget = { | { agentId: string needsReply?: boolean + deadlineMs?: number } channel?: string message: string @@ -197,6 +198,58 @@ the trusted caller session instead of treating a platform channel supplied by th model as authorization. The child retains origin lineage, hop count, optional correlation, `needsReply`, and `viewSessionStatus` support. +### 3.1a `needsReply` deadlines — making silence an event + +`needsReply` has no timeout, so 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. A referee could not run its own +non-voter re-prompt lever, because nothing told it that anything was missing. + +The #800 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. + +`toAgent.deadlineMs` (only with `needsReply: true`, 1s–24h) arms a one-shot, +child-anchored deadline. On expiry the daemon wakes the parent session with a +notice naming the target, the delivery ID, and the child's last known state. The +notice explicitly states it is not the child's answer — the daemon never +fabricates a reply. The parent decides: re-prompt, escalate, or proceed. + +The mechanism reuses the retained orchestration-deadline machinery (§3.4/§6.8) — +a durable epoch, a live one-shot timer, a CAS claim, duty gating, and re-arm from +the store on startup and on every duty change — but keeps 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 + also carries the child's coordinates for the same reason; +- a report arriving first disarms it (`markChildParentReply`), so a normal + 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. + +**Only where this member can actually fire it.** Every disarm path runs on the daemon that OWNS the +child, so a deadline armed on the caller for a cross-daemon target would never be +cancelled by an accepted remote report and would later fire a false "no report +arrived". Arming it on the child's daemon instead requires carrying the deadline and +durable parent routing through the relay — a wire change. Until then a `deadlineMs` on +a remote target is refused loudly, not silently ignored: the daemon logs it and the +tool result carries `deadlineIgnored`, so the caller falls back to +`viewSessionStatus` rather than waiting for a wake that will never come. + +The deadline is **parent-owned**: the wake dispatches into the CALLER's session, so the +caller's duty holder is the member that must arm and fire it, and the durable row carries +`parentAgentId` for exactly that gate. Where the CHILD runs is irrelevant to ownership. If +this member does not hold the caller's duty, the same loud refusal applies +(`caller_duty_elsewhere`) rather than arming a timer nothing will fire. + ### 3.2 Channel-root form `{"toAgent":"","channel":"","message":"..."}`: diff --git a/evals/games/night-collection.ts b/evals/games/night-collection.ts index 4a9109728..7585c3239 100644 --- a/evals/games/night-collection.ts +++ b/evals/games/night-collection.ts @@ -45,8 +45,13 @@ export interface NightCollectionRefereeConfig { wolfB: WebchatSeat seer: WebchatSeat doctor: WebchatSeat + /** #800 deadline attached to every night call. Absent ⇒ the pre-deadline behavior. */ + deadlineMs?: number } +/** The marker text of the daemon's deadline wake, as the referee sees it. */ +export const DEADLINE_NOTICE = '[needsReply deadline]' + const instruction = (task: string, marker: string): string => `${task} Answer with a single line that starts exactly with \`${marker}\` — nothing before it. ` + `Do not contact anyone else and do not post anywhere.` @@ -62,6 +67,9 @@ export class NightCollectionReferee implements ScriptedBrain { readonly issued: IssuedCall[] = [] /** Marker → number of onPrompt() calls whose text contained the reply. */ readonly markerSightings = new Map() + /** Deadline wakes seen, and the re-prompts they let the referee send unaided (#800). */ + deadlineNotices = 0 + readonly rePrompted = new Set() private nightIssued = false private relayIssued = false private closed = false @@ -109,6 +117,22 @@ export class NightCollectionReferee implements ScriptedBrain { ) ) } + // #800: the deadline wake is the ONLY thing that reaches a referee whose child went + // silent, and it is what makes an unaided re-prompt possible. + if (text.includes(DEADLINE_NOTICE)) { + this.deadlineNotices += 1 + for (const [alias, purpose] of this.seatPurposes()) { + if (!text.includes(alias) || this.rePrompted.has(purpose)) continue + this.rePrompted.add(purpose) + calls.push( + this.needsReplyCall( + this.seatFor(purpose), + purpose, + instruction(`You did not answer. Send your night action now.`, MARKERS[purpose]) + ) + ) + } + } if (!this.closed && text.includes(MARKERS.verdict)) { this.closed = true reply = 'The night is resolved.' @@ -116,6 +140,23 @@ export class NightCollectionReferee implements ScriptedBrain { return { calls, reply } } + private seatFor(purpose: NightMarker): WebchatSeat { + return purpose === 'proposal' + ? this.cfg.wolfA + : purpose === 'verdict' + ? this.cfg.wolfB + : purpose === 'seer' + ? this.cfg.seer + : this.cfg.doctor + } + + private seatPurposes(): [string, NightMarker][] { + return (['proposal', 'verdict', 'seer', 'doctor'] as NightMarker[]).map((purpose) => [ + this.seatFor(purpose).agentId, + purpose + ]) + } + onCallResult(outcome: BrainCallOutcome): void { const toAgentId = (outcome.args.toAgent as { agentId?: string } | undefined)?.agentId const row = this.issued.find((candidate) => candidate.to === toAgentId && !this.settled.has(candidate)) @@ -133,7 +174,14 @@ export class NightCollectionReferee implements ScriptedBrain { this.issued.push({ to: seat.agentId, purpose, needsReply: true, delivered: false }) return { tool: 'sendMessage', - args: { toAgent: { agentId: seat.agentId, needsReply: true }, message } + args: { + toAgent: { + agentId: seat.agentId, + needsReply: true, + ...(this.cfg.deadlineMs !== undefined ? { deadlineMs: this.cfg.deadlineMs } : {}) + }, + message + } } } } diff --git a/evals/test/webchat-night-collection.test.ts b/evals/test/webchat-night-collection.test.ts index 1c1572739..f08907f84 100644 --- a/evals/test/webchat-night-collection.test.ts +++ b/evals/test/webchat-night-collection.test.ts @@ -33,6 +33,15 @@ import { callDaemonTool } from '../games/mcp-client.js' const ALIASES = NIGHT_ALIASES +async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`condition not reached within ${timeoutMs}ms`) +} + interface NightRun { arena: WebchatArena seats: WebchatSeat[] @@ -49,14 +58,27 @@ interface NightRun { * force a child reply to land while the referee's turn is still in flight * (the coalesce cell). */ -async function startNightRun(options: { refereeGate?: (promptText: string) => Promise } = {}): Promise { +async function startNightRun( + options: { + refereeGate?: (promptText: string) => Promise + /** #800 deadline the referee attaches to every night call. */ + deadlineMs?: number + /** Aliases whose delegation turn NEVER ends — the shape no turn-final inference reaches. */ + silent?: string[] + } = {} +): Promise { const seats = mintSeats([...ALIASES]) const seat = (alias: (typeof ALIASES)[number]) => seats.find((candidate) => candidate.alias === alias)! const referee = new NightCollectionReferee({ wolfA: seat('wolf-a'), wolfB: seat('wolf-b'), seer: seat('seer'), - doctor: seat('doctor') + doctor: seat('doctor'), + ...(options.deadlineMs !== undefined ? { deadlineMs: options.deadlineMs } : {}) + }) + let releaseSilent: () => void = () => undefined + const silentGate = new Promise((resolve) => { + releaseSilent = resolve }) const log: PromptLogEntry[] = [] const handlers = new Map() @@ -90,6 +112,13 @@ async function startNightRun(options: { refereeGate?: (promptText: string) => Pr return undefined }) handlers.set(seat('villager').agentId, ({ text }) => (text.includes('NIGHT 1 begins') ? 'Waiting.' : undefined)) + for (const alias of options.silent ?? []) { + handlers.set(seat(alias as (typeof ALIASES)[number]).agentId, async ({ text }) => { + if (text.includes('NIGHT 1 begins')) return 'Waiting.' + await silentGate + return undefined + }) + } const { root } = prepareScriptedWebchatRoot(seats) const arena = new WebchatArena({ @@ -119,7 +148,10 @@ async function startNightRun(options: { refereeGate?: (promptText: string) => Pr { alias: 'wolf-b', marker: 'verdict' } ] }), - stop: () => arena.stop() + stop: async () => { + releaseSilent() + await arena.stop() + } } } @@ -224,4 +256,36 @@ describe('webchat night collection (scripted)', () => { await run.stop() } }, 120_000) + + it('#800 deadline: a child that never reports wakes the referee anyway, and it re-prompts unaided', async () => { + // The seer's delegation turn never ends, so nothing turn-final can infer a reply for it. + // Before the deadline this referee had no event to act on at all — the live game needed a + // human to say "the vote has gone quiet". + const run = await startNightRun({ deadlineMs: 2_000, silent: ['seer'] }) + try { + await run.arena.postHost(NIGHT_START_TEXT) + // The silent child's turn never ends, so the arena never goes idle — poll for the + // recovery instead of waiting for a quiet that cannot come. + await waitUntil(() => run.referee.rePrompted.has('seer'), 60_000) + + const refereeInput = run.refereePrompts().join('\n') + expect(refereeInput).toContain('[needsReply deadline]') + expect(refereeInput).toContain('No report arrived') + // The notice is not an answer: the seer's marker never appears through it. + expect(refereeInput).toContain('this notice is NOT its answer') + expect(run.referee.deadlineNotices).toBeGreaterThanOrEqual(1) + + // …and the referee acted on it by itself — the lever a quiet child previously blocked. + expect(run.referee.rePrompted.has('seer')).toBe(true) + expect(run.referee.issued.filter((call) => call.purpose === 'seer')).toHaveLength(2) + + // The children that DID report are unaffected. + const score = run.score() + for (const child of ['wolf-a', 'doctor']) { + expect(score.replies.find((reply) => reply.child === child)!.mode).not.toBe('lost') + } + } finally { + await run.stop() + } + }, 120_000) }) diff --git a/packages/daemon/src/collab/coordinator.ts b/packages/daemon/src/collab/coordinator.ts index ff48282d1..0accfcdae 100644 --- a/packages/daemon/src/collab/coordinator.ts +++ b/packages/daemon/src/collab/coordinator.ts @@ -23,6 +23,7 @@ import { sessionKey, type LocalStore, type OrchestrationRow, + type ParentReplyDeadlineRecord, type SessionRecord, type SubtaskRow } from '../store/local-store.js' @@ -160,6 +161,11 @@ export class CollabCoordinator { // re-armed from the store on startup. cancelOrchestration clears the timer idempotently. readonly orchestrationDeadlines = new Map() + // #800 needsReply deadline timers, keyed by CHILD session key. Same shape as the orchestration + // deadlines above: durable SoT in `parent_reply_deadline`, this map is only the live one-shot, + // re-armed from the store on startup and on every duty change. + readonly parentReplyDeadlines = new Map() + /** An agent's channel-directory display name, used to name the caller in the * text delivered to a messaged agent. Resolution order: * a LOCAL agent from `host.agents()`; else the collab snapshot the CP pushes to every daemon @@ -497,6 +503,20 @@ export class CollabCoordinator { remote: true }) } + // A cross-daemon child is NOT covered: every cancel path (`markChildParentReply`) runs on + // the daemon that OWNS the child, so a row armed here would never be disarmed by an + // accepted remote report and would later fire a false "no report arrived". Arming it on + // the child's daemon instead needs the deadline and durable parent routing carried through + // the relay — a wire change. Refused LOUDLY rather than silently ignored. + if (req.needsReply === true && req.replyDeadlineMs !== undefined) { + this.host + .log() + .warn( + `messageAgent: deadlineMs ignored for ${req.toAgentId} — the target is served by another daemon ` + + `and cross-daemon reply deadlines are not supported yet` + ) + return record({ ...remote, deadlineIgnored: 'target_on_another_daemon' }) + } return record(remote) } @@ -567,6 +587,9 @@ export class CollabCoordinator { // the peer's reply. dispatch() drops the turn (returns null) if the target is paused/ // draining; that still counts as admitted for P1 (a reason-typed NAK on those gates is // P2's admission protocol, §6.4). + // Set when a requested deadline could not be armed, so the caller is told rather than left + // waiting on a wake that will never come. + let deadlineIgnored: string | undefined // Record the lineage BEFORE the fire-and-forget dispatch, so a parent that polls // `viewSessionStatus` the instant sendMessage returns is already authorized. if (originSessionId !== undefined) { @@ -580,6 +603,34 @@ export class CollabCoordinator { replyRequested: req.needsReply === true, replyState: 'awaiting' }) + if (req.needsReply === true && req.replyDeadlineMs !== undefined) { + // The deadline is PARENT-owned: the wake dispatches into the caller's session, so the + // caller's duty holder is the member that must fire it. If this member does not serve + // the caller, arming here would be a silent no-op — refuse loudly instead, exactly as + // the cross-daemon case does. + if (!this.host.servesAgent(req.callerAgentId)) { + this.host + .log() + .warn( + `messageAgent: deadlineMs ignored — the caller ${req.callerAgentId}'s duty is held elsewhere, ` + + `so this member cannot fire the wake` + ) + deadlineIgnored = 'caller_duty_elsewhere' + } else { + await this.armParentReplyDeadline({ + childSessionKey: targetSession, + parentSessionId: originSessionId, + parentAgentId: req.callerAgentId, + childAgentId: req.toAgentId, + platform, + channel: coordChannel, + thread: event.thread ?? '', + ...(targetTransportScope !== undefined ? { transportScope: targetTransportScope } : {}), + deliveryId, + deadlineMs: req.replyDeadlineMs + }) + } + } } // send-message-routing-rework.md §3.2/§8.6 — the "internal wake first" arrival order // of a PAIRED `toAgent + channel` call. The wake is the SEMANTIC AUTHORITY (it alone @@ -606,7 +657,11 @@ export class CollabCoordinator { .info( `messageAgent: paired delivery ${req.agentCallDeliveryId ?? deliveryId} already claimed — reusing the existing child` ) - return record({ delivered: true, targetSession: claimed.record.childSessionId ?? targetSession }) + return record({ + delivered: true, + targetSession: claimed.record.childSessionId ?? targetSession, + ...(deadlineIgnored !== undefined ? { deadlineIgnored } : {}) + }) } this.host .log() @@ -634,7 +689,7 @@ export class CollabCoordinator { this.host .log() .info(`messageAgent: ${req.callerAgentId} → ${req.toAgentId} (${targetSession}) delivery=${deliveryId}`) - return record({ delivered: true, targetSession }) + return record({ delivered: true, targetSession, ...(deadlineIgnored !== undefined ? { deadlineIgnored } : {}) }) } /** @@ -754,6 +809,164 @@ export class CollabCoordinator { replyState: state, ...(existing?.remote ? { remote: true } : {}) }) + // Only a QUEUED report disarms: a report that FAILED to deliver never reached the parent, + // so disarming would recreate exactly the silence the deadline exists to break. + if (state === 'queued-for-parent') await this.cancelParentReplyDeadline(childSessionKey) + } + + /** + * Arm the #800 needsReply deadline for one child: if no report reaches `parentSessionId` + * within `deadlineMs`, wake the parent so it can re-prompt, escalate, or move on. Silence is + * otherwise not an event, so an awaiting parent has nothing to act on. + */ + private async armParentReplyDeadline(args: { + childSessionKey: string + parentSessionId: string + parentAgentId: string + childAgentId: string + platform: string + channel: string + thread: string + transportScope?: string + deliveryId?: string + deadlineMs: number + }): Promise { + const now = this.host.clock().now() + const deadline = now + args.deadlineMs + try { + await this.host.store().upsertParentReplyDeadline({ + childSessionKey: args.childSessionKey, + parentSessionId: args.parentSessionId, + parentAgentId: args.parentAgentId, + childAgentId: args.childAgentId, + platform: args.platform, + channel: args.channel, + thread: args.thread, + ...(args.transportScope !== undefined ? { transportScope: args.transportScope } : {}), + ...(args.deliveryId !== undefined ? { deliveryId: args.deliveryId } : {}), + deadline, + createdAt: now + }) + } catch (err) { + this.host.log().warn(`parent-reply deadline: durable arm failed for ${args.childSessionKey}: ${formatErr(err)}`) + return + } + this.scheduleParentReplyDeadline(args.childSessionKey, deadline) + } + + private scheduleParentReplyDeadline(childSessionKey: string, deadline: number): void { + const existing = this.parentReplyDeadlines.get(childSessionKey) + if (existing !== undefined) this.host.clock().clearTimeout(existing) + const delay = Math.min(Math.max(0, deadline - this.host.clock().now()), 2_147_483_647) + const handle = this.host.clock().setTimeout(async () => { + this.parentReplyDeadlines.delete(childSessionKey) + await this.fireParentReplyDeadline(childSessionKey) + }, delay) + this.parentReplyDeadlines.set(childSessionKey, handle) + } + + /** Disarm — the report arrived. Idempotent. */ + async cancelParentReplyDeadline(childSessionKey: string): Promise { + const handle = this.parentReplyDeadlines.get(childSessionKey) + if (handle !== undefined) { + this.host.clock().clearTimeout(handle) + this.parentReplyDeadlines.delete(childSessionKey) + } + try { + await this.host.store().deleteParentReplyDeadline(childSessionKey) + } catch (err) { + this.host.log().warn(`parent-reply deadline: disarm failed for ${childSessionKey}: ${formatErr(err)}`) + } + } + + /** + * The deadline expired with the report still outstanding: wake the parent with a notice that + * names what did NOT arrive. It never fabricates a reply — the parent is told only that + * nothing came back, and decides what to do. + * + * Exactly-once against a report landing in the same moment: the store DELETE is the claim, so + * whichever of {@link cancelParentReplyDeadline} and this runs first is the only one to act + * (and on a shared store, only one pool member wins). + */ + async fireParentReplyDeadline(childSessionKey: string): Promise { + if (this.host.draining()) return + let row: ParentReplyDeadlineRecord | undefined + try { + row = await this.host.store().getParentReplyDeadline(childSessionKey) + } catch (err) { + this.host.log().warn(`parent-reply deadline: read failed for ${childSessionKey}: ${formatErr(err)}`) + return + } + if (!row) return + // The wake dispatches into the PARENT session, so the PARENT's duty holder must be the one + // to fire it — a child-based gate would dispatch for an agent this member does not serve. + if (!this.host.servesAgent(row.parentAgentId)) return + // `failed` still fires: the report never reached the parent, so the silence is real. + const link = this.childSessionLinks.get(childSessionKey) + if (link && (link.parentSessionId !== row.parentSessionId || link.replyState === 'queued-for-parent')) { + await this.cancelParentReplyDeadline(childSessionKey) + return + } + if (!(await this.host.store().claimParentReplyDeadline(childSessionKey, row.deadline))) return + + const child = await this.host.store().getSession(childSessionKey) + const waited = Math.max(0, this.host.clock().now() - row.createdAt) + const state = + child?.acpSessionId !== undefined && child.acpSessionId !== null + ? `started, last turn ${child.lastTurnOutcome ?? 'still running'}` + : 'never started (no session was ever created)' + const text = + `[needsReply deadline] No report arrived from \`${row.childAgentId}\` within ${waited}ms` + + `${row.deliveryId ? ` (delivery ${row.deliveryId})` : ''}. Last known state of that session: ${state}. ` + + `Nothing was received — this notice is NOT its answer, and no answer may be assumed. ` + + `Re-prompt it, escalate, or proceed without it.` + this.host + .log() + .info(`parent-reply deadline fired: ${row.childAgentId} (${childSessionKey}) → session ${row.parentSessionId}`) + // Coordinates come from the deadline row, not the child's session: replyToSession authorizes + // off the child's DURABLE origin, so this works even for a child that never started. + const result = await this.replyToSession({ + callerAgentId: row.childAgentId, + platform: row.platform, + ...(row.transportScope ? { callerTransportScope: row.transportScope } : {}), + callerChannel: row.channel, + callerThread: row.thread, + sessionId: row.parentSessionId, + trustedOriginSessionId: row.parentSessionId, + text + }) + if (!result.delivered) { + this.host + .log() + .warn(`parent-reply deadline wake not delivered for ${childSessionKey}: ${result.reason ?? 'unknown'}`) + } + } + + /** Re-arm the armed deadlines this member serves — startup and every duty change. A deadline + * already in the past fires ~immediately; the fire re-checks duty and claims through the store. */ + async syncParentReplyDeadlines(): Promise { + let rows: ParentReplyDeadlineRecord[] + try { + rows = await this.host.store().listParentReplyDeadlines() + } catch (err) { + this.host.log().warn(`parent-reply deadline: re-arm read failed: ${formatErr(err)}`) + return + } + let armed = 0 + let disarmed = 0 + for (const row of rows) { + const held = this.host.servesAgent(row.parentAgentId) + const handle = this.parentReplyDeadlines.get(row.childSessionKey) + if (held && handle === undefined) { + this.scheduleParentReplyDeadline(row.childSessionKey, row.deadline) + armed++ + } else if (!held && handle !== undefined) { + this.host.clock().clearTimeout(handle) + this.parentReplyDeadlines.delete(row.childSessionKey) + disarmed++ + } + } + if (armed || disarmed) this.host.log().info(`parent-reply deadline: armed ${armed} and disarmed ${disarmed}`) } async replyToSession(req: ReplyToSessionReq): Promise { @@ -772,7 +985,8 @@ export class CollabCoordinator { // origin (present on the wake turn), else the origin PERSISTED on the caller session (set once // at spawn). A human-triggered follow-up turn carries no CallMeta, so without the persisted // fallback the reply would be wrongly refused (`not_authorized`) after the first turn. - const authorizedOrigin = inbound?.originSessionId ?? callerRec?.originSessionId ?? undefined + const authorizedOrigin = + req.trustedOriginSessionId ?? inbound?.originSessionId ?? callerRec?.originSessionId ?? undefined if (!authorizedOrigin || req.sessionId !== authorizedOrigin) { return { delivered: false, reason: 'not_authorized' } } diff --git a/packages/daemon/src/cp/duty-coordinator.ts b/packages/daemon/src/cp/duty-coordinator.ts index 43a1b9c5d..2011ca7fe 100644 --- a/packages/daemon/src/cp/duty-coordinator.ts +++ b/packages/daemon/src/cp/duty-coordinator.ts @@ -91,6 +91,7 @@ export interface DutyConvergeHost { reclaimInterruptedWork(agentIds: readonly string[]): Promise syncAgentSchedules(agent: LoadedAgent): Promise syncOrchestrationDeadlines(): Promise + syncParentReplyDeadlines(): Promise catchUpMissedSchedules(agentIds: string[]): Promise drainSessionPurges(): Promise replayGainedSessionMetadata(agentIds: readonly string[]): Promise @@ -637,6 +638,7 @@ export class DutyCoordinator { this.convergeDutyConnections() for (const agent of this.host.agents().values()) await this.host.syncAgentSchedules(agent) await this.host.syncOrchestrationDeadlines() + await this.host.syncParentReplyDeadlines() } /** Reconcile until the duty-driven convergence has actually run. A pass that throws (a workspace diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index c5dfc78f4..96447fa23 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -2313,6 +2313,7 @@ export class Daemon { this.log.info(`watching ${this.agentsDir} for agent changes`) await this.replayInbox() await this.collab.syncOrchestrationDeadlines() + await this.collab.syncParentReplyDeadlines() // #485 startup retention pass: reconcile what accumulated (or was orphaned by a // crash) while the daemon was down. Best-effort — never blocks readiness. Runs // AFTER replayInbox so replayed durable work is visible to its active-turn guard. @@ -7104,6 +7105,7 @@ export class Daemon { reclaimInterruptedWork: (agentIds) => this.reclaimInterruptedWork(agentIds), syncAgentSchedules: (agent) => this.syncAgentSchedules(agent), syncOrchestrationDeadlines: () => this.collab.syncOrchestrationDeadlines(), + syncParentReplyDeadlines: () => this.collab.syncParentReplyDeadlines(), catchUpMissedSchedules: (agentIds) => this.catchUpMissedSchedules(agentIds), drainSessionPurges: () => this.drainSessionPurges(), replayGainedSessionMetadata: (agentIds) => this.sessionMetadataOutbox.replayGainedSessionMetadata(agentIds), @@ -15190,6 +15192,8 @@ export class Daemon { // (the durable `orchestration.deadline` epoch re-arms them on the next startup). for (const t of this.collab.orchestrationDeadlines.values()) this.clock.clearTimeout(t) this.collab.orchestrationDeadlines.clear() + for (const t of this.collab.parentReplyDeadlines.values()) this.clock.clearTimeout(t) + this.collab.parentReplyDeadlines.clear() this.metrics?.stop() await this.watcher?.close() // §2.5: gate new turns and let in-flight ones finish (deadline-bounded) BEFORE diff --git a/packages/daemon/src/mcp/ops/messaging.ts b/packages/daemon/src/mcp/ops/messaging.ts index 2b1dd84da..1b2fc712b 100644 --- a/packages/daemon/src/mcp/ops/messaging.ts +++ b/packages/daemon/src/mcp/ops/messaging.ts @@ -54,13 +54,33 @@ const integrationIdField = optionalString('integrationId') * supported indefinitely: every published example and every warm ACP session teaches it, and * the object form only adds delivery options on top of it. */ const AGENT_ID = z.string(AGENT_TARGET_SHAPE_ERROR).min(1, 'sendMessage: `toAgent` must be a non-empty agent id') -const AGENT_TARGET_OBJECT = z.strictObject( - { - agentId: requiredString('agentId'), - needsReply: z.boolean('sendMessage: `toAgent.needsReply` must be a boolean').nullish() - }, - branchKeyError('agent target `toAgent`', ['agentId', 'needsReply']) -) +/** `toAgent.deadlineMs` bounds. The floor only rules out values that would fire before the child + * can plausibly start; the ceiling is a day, past which a wake is noise rather than a recovery. */ +export const REPLY_DEADLINE_MIN_MS = 1_000 +export const REPLY_DEADLINE_MAX_MS = 86_400_000 + +const AGENT_TARGET_OBJECT = z + .strictObject( + { + agentId: requiredString('agentId'), + needsReply: z.boolean('sendMessage: `toAgent.needsReply` must be a boolean').nullish(), + deadlineMs: z + .int('sendMessage: `toAgent.deadlineMs` must be an integer number of milliseconds') + .min( + REPLY_DEADLINE_MIN_MS, + `sendMessage: \`toAgent.deadlineMs\` must be between ${REPLY_DEADLINE_MIN_MS} and ${REPLY_DEADLINE_MAX_MS}` + ) + .max( + REPLY_DEADLINE_MAX_MS, + `sendMessage: \`toAgent.deadlineMs\` must be between ${REPLY_DEADLINE_MIN_MS} and ${REPLY_DEADLINE_MAX_MS}` + ) + .nullish() + }, + branchKeyError('agent target `toAgent`', ['agentId', 'needsReply', 'deadlineMs']) + ) + .refine((target) => target.deadlineMs == null || target.needsReply === true, { + error: 'sendMessage: `toAgent.deadlineMs` requires `needsReply: true` — there is no report to wait for' + }) const AGENT_TARGET = z.union([AGENT_ID, AGENT_TARGET_OBJECT]) /** `toUser`: one id works for every delivery form; a non-empty array is reserved for one visible @@ -98,12 +118,16 @@ export const SEND_MESSAGE_BRANCHES = { } /** Normalize `toAgent`. `undefined` ⇒ this is not an agent target. */ -function parseAgentTarget(value: unknown): { toAgent?: string; needsReply?: boolean } { +function parseAgentTarget(value: unknown): { toAgent?: string; needsReply?: boolean; deadlineMs?: number } { if (value === undefined || value === null) return {} if (typeof value === 'string') return { toAgent: parseArgs(AGENT_ID, value) } if (typeof value !== 'object' || Array.isArray(value)) throw new Error(AGENT_TARGET_SHAPE_ERROR) const target = parseArgs(AGENT_TARGET_OBJECT, value) - return { toAgent: target.agentId, ...(target.needsReply === true ? { needsReply: true } : {}) } + return { + toAgent: target.agentId, + ...(target.needsReply === true ? { needsReply: true } : {}), + ...(target.deadlineMs != null ? { deadlineMs: target.deadlineMs } : {}) + } } /** Normalize `toUser` to the id list both delivery forms work from. */ @@ -151,6 +175,9 @@ export interface MessageAgentReq { * has failed (`toAgent.needsReply`). The daemon turns this into a standing directive on the * child's session — it is NOT part of the delivered message text. */ needsReply?: boolean + /** Wake the caller after this many ms if the `needsReply` report has not arrived. Silence is + * otherwise not an event, so an awaiting caller can never re-prompt on its own (#800). */ + replyDeadlineMs?: number /** * send-message-routing-rework.md §3.2: the daemon-minted id shared by this wake and the * visible post that accompanied it. Present only on the paired `toAgent + channel` form; @@ -183,6 +210,9 @@ export interface MessageAgentResult { delivered: boolean targetSession: string 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 } /** @@ -208,6 +238,13 @@ export interface ReplyToSessionReq { text: string /** Optional correlationId override (advanced). Normally inherited from the origin turn. */ correlationId?: string + /** + * Daemon-internal origin, for a reply the DAEMON itself sends on a child's behalf. Never + * reachable from the tool surface. The #800 deadline needs it: it fires when a child may + * never have started, so there is no session row and no active turn to authorize against — + * but the daemon recorded the parent when it armed the deadline. + */ + trustedOriginSessionId?: string } /** The result of a SessionTarget reply. `delivered:false` carries a typed reason @@ -390,7 +427,7 @@ export async function sendMessage( // user DM; providing `channel` selects a channel-root post. Branch-specific validation // below keeps ignored/mixed fields out even when a caller bypasses the advertised JSON // Schema (as unit tests and older clients can). - const { toAgent, needsReply } = parseAgentTarget(args.toAgent) + const { toAgent, needsReply, deadlineMs } = parseAgentTarget(args.toAgent) const toUsers = parseUserTargets(args.toUser) const channel = parseArgs(channelField, args.channel) if (toAgent === undefined && toUsers === undefined && channel === undefined) { @@ -437,6 +474,7 @@ export async function sendMessage( text: message, channel: channel ?? ctx.channel, ...(needsReply ? { needsReply: true } : {}), + ...(deadlineMs !== undefined ? { replyDeadlineMs: deadlineMs } : {}), // §3.1: no `channel` ⇒ the postless form, whose child is headless. ...(channel === undefined ? { postless: true } : {}) } @@ -681,13 +719,25 @@ export async function sendMessage( ...(wake !== undefined ? { wake } : {}), ...(post !== undefined ? { post } : {}), ...(childSessionId !== undefined ? { childSessionId } : {}), + // A requested deadline that was NOT armed changes the advice, not just a field: telling the + // caller to end its turn and wait is exactly the indefinite strand this feature prevents. ...(childSessionId !== undefined && needsReply - ? { - reply: { requested: true, state: 'awaiting' as const }, - nextAction: 'finish-turn-and-wait' as const, - message: - 'Message delivered. The agent will reply by waking this session in a later turn. End this turn and wait; do not retry or ask it to repeat the work.' - } + ? wake?.deadlineIgnored !== undefined + ? { + reply: { requested: true, state: 'awaiting' as const }, + deadlineIgnored: wake.deadlineIgnored, + nextAction: 'wait' as const, + message: + `Message delivered, but NO deadline was armed (${wake.deadlineIgnored}) — nothing will wake you ` + + 'if the agent never replies. Do not wait indefinitely: check on it yourself with ' + + '`viewSessionStatus` on `childSessionId`, and decide when to give up or proceed without its answer.' + } + : { + reply: { requested: true, state: 'awaiting' as const }, + nextAction: 'finish-turn-and-wait' as const, + message: + 'Message delivered. The agent will reply by waking this session in a later turn. End this turn and wait; do not retry or ask it to repeat the work.' + } : {}), ...(notice !== undefined ? { notice } : {}) } diff --git a/packages/daemon/src/mcp/tools.ts b/packages/daemon/src/mcp/tools.ts index f545eff0d..83d9a36c1 100644 --- a/packages/daemon/src/mcp/tools.ts +++ b/packages/daemon/src/mcp/tools.ts @@ -138,6 +138,20 @@ function buildSendMessageTool(platforms: string[]): ToolDescriptor { 'is told to report back into this session (done or failed) when it completes. Defaults to ' + 'false, which is fire-and-forget — the peer’s answer stays in its own conversation and you ' + 'learn nothing, not even that it failed.' + }, + deadlineMs: { + type: 'integer', + minimum: 1000, + maximum: 86400000, + description: + 'Optional, and only with `needsReply: true`. Wake THIS session after this many milliseconds ' + + 'if the peer’s report has not arrived, so you can re-prompt, escalate, or proceed without ' + + 'it. Without it, a peer that simply never answers produces no event at all and you wait ' + + 'forever. The wake says only that nothing arrived — it never invents a reply. A report that ' + + 'arrives first cancels it. Use it whenever you are collecting answers you intend to act on, ' + + 'and allow generous time for the peer to do the work — tens of seconds to minutes is normal. ' + + 'If the result comes back with `deadlineIgnored`, no deadline was armed and nothing will ' + + 'wake you — check on the peer yourself with `viewSessionStatus` instead.' } }, ['agentId'] diff --git a/packages/daemon/src/store/local-store.ts b/packages/daemon/src/store/local-store.ts index 81513b980..a21271e92 100644 --- a/packages/daemon/src/store/local-store.ts +++ b/packages/daemon/src/store/local-store.ts @@ -260,6 +260,29 @@ export interface SessionRecord { needsParentReply?: number | null } +/** + * One armed `needsReply` deadline (#800). Keyed by the CHILD session key and kept in its own + * table rather than on `sessions`, because the wake is armed at CALL time — when a cold child + * has no session row yet, and may never get one. That case (a child that never starts) is + * exactly what the deadline exists to surface. + */ +export interface ParentReplyDeadlineRecord { + childSessionKey: string + parentSessionId: string + /** The AWAITING agent. The wake dispatches into ITS session, so its duty holder owns the timer. */ + parentAgentId: string + childAgentId: string + /** Child coordinates, captured at arm time: the wake must work even when the child never + * started, so they cannot be read back off a `sessions` row that may not exist. */ + platform: string + channel: string + thread: string + transportScope?: string | null + deliveryId?: string | null + deadline: number + createdAt: number +} + export type PermissionRequestStatus = 'pending' | 'allowed' | 'denied' | 'expired' /** Secret-masked editor approval metadata. The live ACP resolver stays in memory; @@ -952,6 +975,21 @@ export class LocalStore { CREATE TABLE IF NOT EXISTS session_mutes ( key TEXT PRIMARY KEY ); + -- #800 needsReply deadlines. Stands alone for the same reason as session_mutes: it is + -- armed at CALL time, when the child's sessions row may not exist and may never exist. + CREATE TABLE IF NOT EXISTS parent_reply_deadline ( + childSessionKey TEXT PRIMARY KEY, + parentSessionId TEXT NOT NULL, + parentAgentId TEXT NOT NULL, + childAgentId TEXT NOT NULL, + platform TEXT NOT NULL, + channel TEXT NOT NULL, + thread TEXT NOT NULL, + transportScope TEXT, + deliveryId TEXT, + deadline INTEGER NOT NULL, + createdAt INTEGER NOT NULL + ); -- Per-session memory-capture gate (session-visibility.md §5.1). Keyed by -- (agentId, acpSessionId), NOT the logical session key: the CP addresses -- sessions by the id it knows, and its push can arrive before (or after a @@ -5469,6 +5507,59 @@ export class LocalStore { .run(deadline, updatedAt, orchestrationId) } + /** Arm (or re-arm) one child's parent-reply deadline. */ + async upsertParentReplyDeadline(record: ParentReplyDeadlineRecord): Promise { + await this.db + .prepare( + `INSERT INTO parent_reply_deadline + (childSessionKey, parentSessionId, parentAgentId, childAgentId, platform, channel, thread, transportScope, + deliveryId, deadline, createdAt) + VALUES (@childSessionKey, @parentSessionId, @parentAgentId, @childAgentId, @platform, @channel, @thread, @transportScope, + @deliveryId, @deadline, @createdAt) + ON CONFLICT(childSessionKey) DO UPDATE SET + parentSessionId=excluded.parentSessionId, parentAgentId=excluded.parentAgentId, + childAgentId=excluded.childAgentId, + platform=excluded.platform, channel=excluded.channel, thread=excluded.thread, + transportScope=excluded.transportScope, + deliveryId=excluded.deliveryId, deadline=excluded.deadline, createdAt=excluded.createdAt` + ) + .run({ + ...record, + transportScope: record.transportScope ?? null, + deliveryId: record.deliveryId ?? null + } as unknown as SqlParams) + } + + /** Disarm — the report arrived, or the obligation is gone. Idempotent. */ + async deleteParentReplyDeadline(childSessionKey: string): Promise { + await this.db.prepare('DELETE FROM parent_reply_deadline WHERE childSessionKey = ?').run(childSessionKey) + } + + async getParentReplyDeadline(childSessionKey: string): Promise { + return (await this.db + .prepare('SELECT * FROM parent_reply_deadline WHERE childSessionKey = ?') + .get(childSessionKey)) as ParentReplyDeadlineRecord | undefined + } + + /** Every armed deadline — the startup / duty-change re-arm set. */ + async listParentReplyDeadlines(): Promise { + return (await this.db + .prepare('SELECT * FROM parent_reply_deadline ORDER BY deadline ASC') + .all()) as unknown as ParentReplyDeadlineRecord[] + } + + /** CAS fire claim, mirroring {@link claimOrchestrationDeadline}: delete the row iff it is still + * the armed one, so an arriving report and the timer can never both wake the parent. */ + async claimParentReplyDeadline(childSessionKey: string, deadline: number): Promise { + return ( + ( + await this.db + .prepare('DELETE FROM parent_reply_deadline WHERE childSessionKey = ? AND deadline = ?') + .run(childSessionKey, deadline) + ).changes === 1 + ) + } + /** CAS fire claim: clear the deadline iff it is still the armed one — every member sharing the * store may hold a timer for it, and exactly one of them gets `true`. */ async claimOrchestrationDeadline(orchestrationId: string, deadline: number, updatedAt: number): Promise { diff --git a/packages/daemon/src/store/postgres-dialect.ts b/packages/daemon/src/store/postgres-dialect.ts index 3461fa5fb..fc4a358b2 100644 --- a/packages/daemon/src/store/postgres-dialect.ts +++ b/packages/daemon/src/store/postgres-dialect.ts @@ -34,7 +34,9 @@ export const canonicalColumns = [ 'callMeta', 'capsJson', 'channelId', + 'childAgentId', 'childSessionId', + 'childSessionKey', 'claimedAt', 'completedAt', 'connectionId', @@ -47,6 +49,7 @@ export const canonicalColumns = [ 'createdAt', 'defaultModel', 'defaultPermissionMode', + 'deliveryId', 'deliveryReason', 'dispatchId', 'dreamId', @@ -97,7 +100,9 @@ export const canonicalColumns = [ 'originSessionId', 'ownerId', 'outputModeOverride', + 'parentAgentId', 'parentId', + 'parentSessionId', 'payloadBytes', 'payloadHash', 'permissionModeOverride', diff --git a/packages/daemon/test/mcp-ops.test.ts b/packages/daemon/test/mcp-ops.test.ts index 4834c1e95..2188b7238 100644 --- a/packages/daemon/test/mcp-ops.test.ts +++ b/packages/daemon/test/mcp-ops.test.ts @@ -1335,6 +1335,58 @@ describe('executeTool: sendMessage (wake / reply)', () => { expect(calls[0]!.text).toBe('take this over') }) + it('forwards toAgent.deadlineMs as the trusted reply deadline', async () => { + const { deps: d, calls } = wakeDeps() + await executeTool( + ctx, + 'sendMessage', + { toAgent: { agentId: 'peer-1', needsReply: true, deadlineMs: 60_000 }, message: 'vote now' }, + d + ) + expect(calls[0]!.replyDeadlineMs).toBe(60_000) + expect(calls[0]!.text).toBe('vote now') + }) + + it('tells the caller NOT to just wait when a requested deadline was not armed', async () => { + const { deps: d } = wakeDeps() + const inner = d.messageAgent! + d.messageAgent = async (req) => ({ ...(await inner(req)), deadlineIgnored: 'target_on_another_daemon' }) + const res = (await executeTool( + ctx, + 'sendMessage', + { toAgent: { agentId: 'peer-1', needsReply: true, deadlineMs: 60_000 }, message: 'vote now' }, + d + )) as Record + expect(res.deadlineIgnored).toBe('target_on_another_daemon') + // The whole point: never the "end your turn and wait" advice, which would strand it. + expect(res.nextAction).toBe('wait') + expect(String(res.message)).toContain('NO deadline was armed') + expect(String(res.message)).toContain('viewSessionStatus') + }) + + it('rejects a deadline without needsReply, and one outside the accepted range', async () => { + const { deps: d } = wakeDeps() + await expect( + executeTool(ctx, 'sendMessage', { toAgent: { agentId: 'peer-1', deadlineMs: 60_000 }, message: 'x' }, d) + ).rejects.toThrow(/requires `needsReply: true`/) + await expect( + executeTool( + ctx, + 'sendMessage', + { toAgent: { agentId: 'peer-1', needsReply: true, deadlineMs: 5 }, message: 'x' }, + d + ) + ).rejects.toThrow(/must be between/) + await expect( + executeTool( + ctx, + 'sendMessage', + { toAgent: { agentId: 'peer-1', needsReply: true, deadlineMs: 1.5 }, message: 'x' }, + d + ) + ).rejects.toThrow(/integer number of milliseconds/) + }) + it('leaves needsReply absent for the bare-string form and for an explicit false', async () => { const { deps: d, calls } = wakeDeps() await executeTool(ctx, 'sendMessage', { toAgent: 'peer-1', message: 'a' }, d) diff --git a/packages/daemon/test/mcp-tools.test.ts b/packages/daemon/test/mcp-tools.test.ts index 98ab3e57b..30f76c926 100644 --- a/packages/daemon/test/mcp-tools.test.ts +++ b/packages/daemon/test/mcp-tools.test.ts @@ -255,7 +255,7 @@ describe('toolsForIntegrations', () => { // published example teaches, and the object form only layers delivery options onto it. expect(branches).toHaveLength(2) expect(branches[0]!.type).toBe('string') - expect(Object.keys(branches[1]!.properties!)).toEqual(['agentId', 'needsReply']) + expect(Object.keys(branches[1]!.properties!)).toEqual(['agentId', 'needsReply', 'deadlineMs']) expect(branches[1]!.required).toEqual(['agentId']) expect(branches[1]!.additionalProperties).toBe(false) }) diff --git a/packages/daemon/test/parent-reply-deadline.test.ts b/packages/daemon/test/parent-reply-deadline.test.ts new file mode 100644 index 000000000..11d4dcef2 --- /dev/null +++ b/packages/daemon/test/parent-reply-deadline.test.ts @@ -0,0 +1,266 @@ +/** + * The #800 needsReply deadline: silence becomes an event. + * + * #984's inferred reply already covers a child that ENDS its turn without reporting. It cannot + * cover a child that never runs, never finishes, or whose wake is gated — there is no turn end + * to hang the inference on, so the awaiting parent is never woken at all. That is the gap these + * tests pin. + */ +import { describe, expect, it, vi } from 'vitest' +import { Daemon } from '../src/daemon.js' +import type { MessageAgentReq } from '../src/mcp/ops.js' +import { sessionKey } from '../src/store/local-store.js' +import { fakeCpClient, scaffold, seedCallPolicy, settle } from './webchat-continuation-fixture.js' +import { callDaemonTool, daemonMcpBinding } from '../../../evals/games/mcp-client.js' + +const WAIT = { timeout: 10_000 } +const CALLER = 'bot-parent' +const CHILD = 'bot-child' + +function callReq(over: Partial = {}): MessageAgentReq { + return { + callerAgentId: CALLER, + platform: 'webchat', + callerChannel: 'wc-parent-1', + callerThread: '100.1', + toAgentId: CHILD, + text: 'Cast your vote. Reply with just the name.', + channel: 'wc-parent-1', + thread: '100.1', + postless: true, + needsReply: true, + ...over + } +} + +/** Boot a daemon whose CHILD host behaves per `childReply`. `root` is reusable so a restart + * can be exercised against the SAME store. */ +async function boot( + childReply: (text: string, chunk: (t: string) => void) => Promise | string, + root = scaffold([CALLER, CHILD]) +) { + const prompts = new Map([ + [CALLER, []], + [CHILD, []] + ]) + const bindings = new Map() + let sessions = 0 + const factory = (agent: { id: string }, onUpdate: (sid: string, u: unknown) => void) => ({ + start: vi.fn(async () => {}), + newSession: vi.fn(async (_cwd: string, mcpServers?: unknown) => { + const sid = `acp-${agent.id}-${++sessions}` + const binding = daemonMcpBinding(mcpServers) + if (binding) bindings.set(sid, binding) + return sid + }), + hasSession: vi.fn(() => true), + prompt: vi.fn(async (sid: string, blocks: { text?: string }[]) => { + const text = blocks.map((b) => b.text ?? '').join('\n') + prompts.get(agent.id)!.push(text) + const chunk = (t: string) => + onUpdate(sid, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: t } }) + if (agent.id === CHILD) { + chunk(await childReply(text, chunk)) + } else { + chunk('parent acknowledges.') + } + return { stopReason: 'end_turn' } + }), + cancel: vi.fn(async () => {}), + stop: vi.fn(async () => {}) + }) + const daemon = new Daemon({ root, hostFactory: factory as never }) + await daemon.start() + ;(daemon as any).cpClient = fakeCpClient() + seedCallPolicy(daemon, [CALLER, CHILD]) + await (daemon as any).store.upsertSession({ + key: sessionKey('webchat', 'wc-parent-1', '100.1', CALLER), + agentId: CALLER, + platform: 'webchat', + channel: 'wc-parent-1', + thread: '100.1', + acpSessionId: 'acp-parent-origin-1', + state: 'idle', + lastDeliveredTs: null, + updatedAt: Date.now() + }) + const call = (req: MessageAgentReq) => (daemon as any).collab.messageAgent(req) as Promise<{ delivered: boolean }> + const parentPrompts = () => prompts.get(CALLER)! + const store = () => (daemon as any).store + return { daemon, root, prompts, bindings, call, parentPrompts, store } +} + +/** A child whose turn never ends — the shape #984's turn-final inference cannot reach. */ +function hangingChild() { + let release: () => void = () => undefined + const gate = new Promise((resolve) => { + release = resolve + }) + return { behavior: async () => (await gate, 'finally done.'), release: () => release() } +} + +describe('needsReply deadline (#800)', () => { + it('fires on silence: a child that never finishes wakes the parent with a notice, not an answer', async () => { + const child = hangingChild() + const run = await boot(child.behavior) + try { + expect((await run.call(callReq({ replyDeadlineMs: 120 }))).delivered).toBe(true) + await vi.waitFor(() => expect(run.parentPrompts().length).toBeGreaterThanOrEqual(1), WAIT) + const parentInput = run.parentPrompts().join('\n') + expect(parentInput).toContain('[needsReply deadline]') + expect(parentInput).toContain('No report arrived') + expect(parentInput).toContain(CHILD) + // Never fabricates a reply. + expect(parentInput).toContain('this notice is NOT its answer') + // The claim consumed the durable row, so nothing can fire twice. + expect(await run.store().listParentReplyDeadlines()).toHaveLength(0) + } finally { + child.release() + await settle() + await run.daemon.stop() + } + }, 30_000) + + it('an arriving report cancels it — the parent is woken once, by the report', async () => { + const run = await boot(async (text) => { + const sessionId = [...text.matchAll(/"sessionId":"([^"]+)"/g)].map((m) => m[1]).find((v) => !v!.startsWith('<')) + const binding = [...run.bindings.entries()].find(([sid]) => sid.includes(CHILD))?.[1] + if (!binding || !sessionId) return `cannot report: ${Boolean(binding)}/${sessionId}` + const result = await callDaemonTool(binding, 'sendMessage', { sessionId, message: 'VOTE: player-3.' }) + return result.ok ? 'reported.' : `report failed: ${result.error}` + }) + try { + // Long enough that the deadline cannot fire on its own under load: cancellation is proved + // by the durable row and the live timer both being gone, not by outliving a short timer. + expect((await run.call(callReq({ replyDeadlineMs: 30_000 }))).delivered).toBe(true) + await vi.waitFor(() => expect(run.parentPrompts().join('\n')).toContain('VOTE: player-3.'), WAIT) + await settle() + expect(await run.store().listParentReplyDeadlines()).toHaveLength(0) + expect((run.daemon as any).collab.parentReplyDeadlines.size).toBe(0) + expect(run.parentPrompts().join('\n')).not.toContain('[needsReply deadline]') + expect(run.parentPrompts()).toHaveLength(1) + } finally { + await run.daemon.stop() + } + }, 30_000) + + it('exactly once under the fire/report race: a second fire of the same deadline is a no-op', async () => { + const child = hangingChild() + const run = await boot(child.behavior) + try { + // Long enough that only the explicit fires below can run. + expect((await run.call(callReq({ replyDeadlineMs: 60_000 }))).delivered).toBe(true) + const [row] = await run.store().listParentReplyDeadlines() + expect(row).toBeDefined() + const key = row.childSessionKey + await (run.daemon as any).collab.fireParentReplyDeadline(key) + await (run.daemon as any).collab.fireParentReplyDeadline(key) + await vi.waitFor(() => expect(run.parentPrompts().length).toBeGreaterThanOrEqual(1), WAIT) + await settle() + const deadlineWakes = run.parentPrompts().filter((p: string) => p.includes('[needsReply deadline]')) + expect(deadlineWakes).toHaveLength(1) + } finally { + child.release() + await settle() + await run.daemon.stop() + } + }, 30_000) + + it('survives a restart: the re-arm reads the durable row and still wakes the parent', async () => { + // Arm on the first daemon, then let it go down WITHOUT the child ever reporting. Releasing + // the hung turn would settle the obligation (#984 infers the reply and disarms), so the + // child stays hung and the row is carried across the restart. + const first = hangingChild() + const run = await boot(first.behavior) + const root = run.root + try { + // Short enough to come due across the restart, long enough not to fire before the stop. + expect((await run.call(callReq({ replyDeadlineMs: 1_000 }))).delivered).toBe(true) + expect(await run.store().listParentReplyDeadlines()).toHaveLength(1) + } finally { + await run.daemon.stop() + first.release() + } + + // A fresh daemon over the SAME store re-arms from the durable row alone. + const second = hangingChild() + const restarted = await boot(second.behavior, root) + try { + await vi.waitFor(() => expect(restarted.parentPrompts().length).toBeGreaterThanOrEqual(1), WAIT) + expect(restarted.parentPrompts().join('\n')).toContain('[needsReply deadline]') + expect(await restarted.store().listParentReplyDeadlines()).toHaveLength(0) + } finally { + second.release() + await settle() + await restarted.daemon.stop() + } + }, 45_000) + + it('a report that FAILED to deliver does not disarm it — the parent still got nothing', async () => { + const child = hangingChild() + const run = await boot(child.behavior) + try { + expect((await run.call(callReq({ replyDeadlineMs: 60_000 }))).delivered).toBe(true) + const [row] = await run.store().listParentReplyDeadlines() + expect(row).toBeDefined() + // A terminal delivery failure of the child's report reached nobody, so the obligation + // is still open and the deadline must survive. + await (run.daemon as any).collab.markChildParentReply(row.childSessionKey, row.parentSessionId, 'failed') + expect(await run.store().listParentReplyDeadlines()).toHaveLength(1) + await (run.daemon as any).collab.fireParentReplyDeadline(row.childSessionKey) + await vi.waitFor(() => expect(run.parentPrompts().join('\n')).toContain('[needsReply deadline]'), WAIT) + } finally { + child.release() + await settle() + await run.daemon.stop() + } + }, 30_000) + + it('is PARENT-owned: a caller whose duty is held elsewhere refuses instead of arming a dud', async () => { + const child = hangingChild() + const run = await boot(child.behavior) + try { + // The wake dispatches into the CALLER's session, so the caller's duty holder must fire it. + // This member no longer serves the caller, so arming here would be a silent no-op. + ;(run.daemon as any).collab.host.servesAgent = (agentId: string) => agentId !== CALLER + const res = (await run.call(callReq({ replyDeadlineMs: 60_000 }))) as Record + expect(res.delivered).toBe(true) + expect(res.deadlineIgnored).toBe('caller_duty_elsewhere') + expect(await run.store().listParentReplyDeadlines()).toHaveLength(0) + } finally { + child.release() + await settle() + await run.daemon.stop() + } + }, 30_000) + + it('a child served elsewhere still gets a deadline — only the caller must be held here', async () => { + const child = hangingChild() + const run = await boot(child.behavior) + try { + ;(run.daemon as any).collab.host.servesAgent = (agentId: string) => agentId !== CHILD + const res = (await run.call(callReq({ replyDeadlineMs: 120 }))) as Record + expect(res.delivered).toBe(true) + expect(res.deadlineIgnored).toBeUndefined() + await vi.waitFor(() => expect(run.parentPrompts().join('\n')).toContain('[needsReply deadline]'), WAIT) + } finally { + child.release() + await settle() + await run.daemon.stop() + } + }, 30_000) + + it('a call without a deadline arms nothing', async () => { + const child = hangingChild() + const run = await boot(child.behavior) + try { + expect((await run.call(callReq())).delivered).toBe(true) + await settle() + expect(await run.store().listParentReplyDeadlines()).toHaveLength(0) + } finally { + child.release() + await settle() + await run.daemon.stop() + } + }, 30_000) +})