agent-relay-server 0.124.0 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-SQK8-Mzp.js → MessageBubble-BYeYFteG.js} +2 -2
  4. package/public/assets/{MessageBubble-SQK8-Mzp.js.map → MessageBubble-BYeYFteG.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CS-aow4S.js → PlanGraphPanel-iAXvkZKW.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CS-aow4S.js.map → PlanGraphPanel-iAXvkZKW.js.map} +1 -1
  7. package/public/assets/{activity-W31XvhC2.js → activity-Du9SRJt5.js} +2 -2
  8. package/public/assets/{activity-W31XvhC2.js.map → activity-Du9SRJt5.js.map} +1 -1
  9. package/public/assets/agent-profiles-DXDQnrlH.js +2 -0
  10. package/public/assets/{agent-profiles-DXysA9To.js.map → agent-profiles-DXDQnrlH.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-BxUopALO.js → agent-type-icon-B3i0rndK.js} +2 -2
  12. package/public/assets/{agent-type-icon-BxUopALO.js.map → agent-type-icon-B3i0rndK.js.map} +1 -1
  13. package/public/assets/{agents-B5GRNRnN.js → agents-CWOdKrxP.js} +2 -2
  14. package/public/assets/{agents-B5GRNRnN.js.map → agents-CWOdKrxP.js.map} +1 -1
  15. package/public/assets/{analytics-DqAuUAwX.js → analytics-YcY59Hv9.js} +2 -2
  16. package/public/assets/{analytics-DqAuUAwX.js.map → analytics-YcY59Hv9.js.map} +1 -1
  17. package/public/assets/{automation-DYlgHYcb.js → automation-CItTse7F.js} +2 -2
  18. package/public/assets/{automation-DYlgHYcb.js.map → automation-CItTse7F.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-Mf0Ow77K.js → branch-state-badge-QjTktTYj.js} +2 -2
  20. package/public/assets/{branch-state-badge-Mf0Ow77K.js.map → branch-state-badge-QjTktTYj.js.map} +1 -1
  21. package/public/assets/{channels-DVRkVeky.js → channels-BSFTsbhT.js} +2 -2
  22. package/public/assets/{channels-DVRkVeky.js.map → channels-BSFTsbhT.js.map} +1 -1
  23. package/public/assets/chat-CRzFSFWe.js +2 -0
  24. package/public/assets/chat-CRzFSFWe.js.map +1 -0
  25. package/public/assets/{connectors-Cqubkw9C.js → connectors-C7d5dlYz.js} +2 -2
  26. package/public/assets/{connectors-Cqubkw9C.js.map → connectors-C7d5dlYz.js.map} +1 -1
  27. package/public/assets/{display-GFbGUG0O.js → display-aTnwsqQh.js} +3 -3
  28. package/public/assets/{display-GFbGUG0O.js.map → display-aTnwsqQh.js.map} +1 -1
  29. package/public/assets/{formatted-body-BYQ-SPtj.js → formatted-body-BuLfVMeD.js} +3 -3
  30. package/public/assets/{formatted-body-BYQ-SPtj.js.map → formatted-body-BuLfVMeD.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-L1EMnNW6.js → formatted-body-impl-DO-8giS6.js} +2 -2
  32. package/public/assets/{formatted-body-impl-L1EMnNW6.js.map → formatted-body-impl-DO-8giS6.js.map} +1 -1
  33. package/public/assets/index-C9X_0QBt.css +2 -0
  34. package/public/assets/index-De1tR7J4.js +26 -0
  35. package/public/assets/index-De1tR7J4.js.map +1 -0
  36. package/public/assets/{insights-DwZxq1Dn.js → insights-Ci3VuA5Y.js} +2 -2
  37. package/public/assets/{insights-DwZxq1Dn.js.map → insights-Ci3VuA5Y.js.map} +1 -1
  38. package/public/assets/{integrations-9G5TJnnU.js → integrations-BboxWQQD.js} +2 -2
  39. package/public/assets/{integrations-9G5TJnnU.js.map → integrations-BboxWQQD.js.map} +1 -1
  40. package/public/assets/{maintenance-COcmUDnf.js → maintenance-CO1miJIJ.js} +2 -2
  41. package/public/assets/{maintenance-COcmUDnf.js.map → maintenance-CO1miJIJ.js.map} +1 -1
  42. package/public/assets/{managed-agents-D9-dgMeX.js → managed-agents-r4oYK3Fm.js} +2 -2
  43. package/public/assets/{managed-agents-D9-dgMeX.js.map → managed-agents-r4oYK3Fm.js.map} +1 -1
  44. package/public/assets/{markdown-preview-impl-DH372bL-.js → markdown-preview-impl-5CCM9iyL.js} +2 -2
  45. package/public/assets/{markdown-preview-impl-DH372bL-.js.map → markdown-preview-impl-5CCM9iyL.js.map} +1 -1
  46. package/public/assets/{memory-B9hRK8sk.js → memory-BJJS07W-.js} +2 -2
  47. package/public/assets/{memory-B9hRK8sk.js.map → memory-BJJS07W-.js.map} +1 -1
  48. package/public/assets/{messages-BHkHlXzF.js → messages-RT_dfEsL.js} +2 -2
  49. package/public/assets/{messages-BHkHlXzF.js.map → messages-RT_dfEsL.js.map} +1 -1
  50. package/public/assets/{orchestrators-CXy3SDSn.js → orchestrators-8hyl8hW1.js} +2 -2
  51. package/public/assets/{orchestrators-CXy3SDSn.js.map → orchestrators-8hyl8hW1.js.map} +1 -1
  52. package/public/assets/{overview-D2yRyCQq.js → overview-CUJ9QmW_.js} +2 -2
  53. package/public/assets/{overview-D2yRyCQq.js.map → overview-CUJ9QmW_.js.map} +1 -1
  54. package/public/assets/{pairs-5tXdHylI.js → pairs-CzkzMGZq.js} +2 -2
  55. package/public/assets/{pairs-5tXdHylI.js.map → pairs-CzkzMGZq.js.map} +1 -1
  56. package/public/assets/{projects-CquHTMo1.js → projects-CAuLWBU5.js} +2 -2
  57. package/public/assets/{projects-CquHTMo1.js.map → projects-CAuLWBU5.js.map} +1 -1
  58. package/public/assets/{prompt-settings-Bn5mmi9w.js → prompt-settings-BnB90R7F.js} +2 -2
  59. package/public/assets/{prompt-settings-Bn5mmi9w.js.map → prompt-settings-BnB90R7F.js.map} +1 -1
  60. package/public/assets/{registry-C3sB7qv5.js → registry-B8TInqru.js} +2 -2
  61. package/public/assets/{registry-C3sB7qv5.js.map → registry-B8TInqru.js.map} +1 -1
  62. package/public/assets/{security-CmD-YE-_.js → security-DG_VVZUW.js} +2 -2
  63. package/public/assets/{security-CmD-YE-_.js.map → security-DG_VVZUW.js.map} +1 -1
  64. package/public/assets/{select-C0UlhK2k.js → select-BJ8G38_V.js} +2 -2
  65. package/public/assets/{select-C0UlhK2k.js.map → select-BJ8G38_V.js.map} +1 -1
  66. package/public/assets/{settings-DrqMG5nG.js → settings-CK_T9z5w.js} +4 -4
  67. package/public/assets/{settings-DrqMG5nG.js.map → settings-CK_T9z5w.js.map} +1 -1
  68. package/public/assets/{store-zFLzd0Jd.js → store-Dl9Z8YUS.js} +4 -4
  69. package/public/assets/{store-zFLzd0Jd.js.map → store-Dl9Z8YUS.js.map} +1 -1
  70. package/public/assets/{tasks-ChQjtXHu.js → tasks-Briui9Mp.js} +2 -2
  71. package/public/assets/{tasks-ChQjtXHu.js.map → tasks-Briui9Mp.js.map} +1 -1
  72. package/public/assets/{teams-itjNlDLp.js → teams-Ck4EVV3R.js} +2 -2
  73. package/public/assets/{teams-itjNlDLp.js.map → teams-Ck4EVV3R.js.map} +1 -1
  74. package/public/assets/{terminal-viewer-impl-B9M6s2vB.js → terminal-viewer-impl-66ZjVN1v.js} +2 -2
  75. package/public/assets/{terminal-viewer-impl-B9M6s2vB.js.map → terminal-viewer-impl-66ZjVN1v.js.map} +1 -1
  76. package/public/assets/{types-CRptbSYh.js → types-BqeSkWrv.js} +2 -2
  77. package/public/assets/{types-CRptbSYh.js.map → types-BqeSkWrv.js.map} +1 -1
  78. package/public/assets/{user-avatar-kYPfLgXp.js → user-avatar-BOy9o2xK.js} +2 -2
  79. package/public/assets/{user-avatar-kYPfLgXp.js.map → user-avatar-BOy9o2xK.js.map} +1 -1
  80. package/public/assets/{work-queue-Kw0uMkEF.js → work-queue-CnghFRG2.js} +2 -2
  81. package/public/assets/{work-queue-Kw0uMkEF.js.map → work-queue-CnghFRG2.js.map} +1 -1
  82. package/public/assets/{workspaces-CR6f7G0R.js → workspaces-BzmQnzSm.js} +2 -2
  83. package/public/assets/{workspaces-CR6f7G0R.js.map → workspaces-BzmQnzSm.js.map} +1 -1
  84. package/public/index.html +7 -7
  85. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  86. package/runner/plugins/claude/hooks/relay-status.sh +11 -1
  87. package/runner/plugins/claude/hooks/stop.sh +6 -1
  88. package/runner/plugins/claude/hooks/user-prompt-submit.sh +6 -1
  89. package/runner/src/adapter.ts +5 -1
  90. package/runner/src/adapters/claude-transcript.ts +52 -58
  91. package/runner/src/adapters/codex-agent-message-capture.ts +8 -36
  92. package/runner/src/adapters/codex.ts +30 -12
  93. package/runner/src/control-server.ts +9 -4
  94. package/runner/src/response-capture-report.ts +4 -1
  95. package/runner/src/runner-core.ts +75 -33
  96. package/src/agent-lifecycle-events.ts +8 -0
  97. package/src/bus.ts +0 -24
  98. package/src/db/agents.ts +15 -1
  99. package/src/db/messages.ts +114 -43
  100. package/src/maintenance/jobs.ts +8 -2
  101. package/src/mcp/tools-workspace.ts +4 -0
  102. package/src/routes/agents.ts +0 -4
  103. package/src/services/send-message.ts +7 -18
  104. package/src/services/shutdown-agent.ts +6 -0
  105. package/src/services/task-entity.ts +4 -0
  106. package/src/services/transient-agent-reaper.ts +40 -1
  107. package/src/services/work-finality.ts +25 -0
  108. package/public/assets/agent-profiles-DXysA9To.js +0 -2
  109. package/public/assets/chat-Clz6qOtn.js +0 -2
  110. package/public/assets/chat-Clz6qOtn.js.map +0 -1
  111. package/public/assets/index-0uIGXT14.js +0 -25
  112. package/public/assets/index-0uIGXT14.js.map +0 -1
  113. package/public/assets/index-DOJ7KidA.css +0 -2
@@ -16,7 +16,7 @@ import { readRunnerContextProbeState } from "./context-probe-state";
16
16
  import { startControlServer, type ControlServer, type ResolvedPermissionPrompt } from "./control-server";
17
17
  import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureReplyRoute } from "./reply-obligation-cache";
18
18
  import { Outbox, type OutboxRecord } from "./outbox";
19
- import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractFinalAssistantMessageAfterEntry, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
19
+ import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, extractAssistantTurnForPrompt, countTranscriptEntries, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
20
20
  import { IncrementalClaudeTranscriptTail } from "./adapters/claude-transcript-tail";
21
21
  import { getManifest } from "agent-relay-providers";
22
22
  import { profileUsesProviderHostGlobals } from "./profile-home";
@@ -1291,6 +1291,10 @@ export class AgentRunner {
1291
1291
  }
1292
1292
  if (status === "busy" && reason === "provider-turn") {
1293
1293
  if (!this.currentTurnId) {
1294
+ // For Claude, `update.id` carries the native prompt_id the UserPromptSubmit
1295
+ // hook reported (relay-status.sh → /status) — the same id the Stop hook and
1296
+ // transcript use for this turn. Falls back to a random mint only when no
1297
+ // native id was supplied (e.g. a provider/path that doesn't report one).
1294
1298
  this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
1295
1299
  this.currentTurnStartedAt = Date.now();
1296
1300
  this.compactionMidTurn = false;
@@ -1423,7 +1427,7 @@ export class AgentRunner {
1423
1427
  // isPreFlush: true (#435/#499) — mid-turn PreToolUse boundary. Drain the live
1424
1428
  // tail and force-flush session messages before showing the blocking control;
1425
1429
  // the entry cursor lets Stop capture skip already-emitted text.
1426
- private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void> {
1430
+ private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean }): Promise<void> {
1427
1431
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1428
1432
 
1429
1433
  if (input.isPreFlush) {
@@ -1443,7 +1447,13 @@ export class AgentRunner {
1443
1447
  return;
1444
1448
  }
1445
1449
 
1446
- const turnId = this.currentTurnId;
1450
+ // Claude's Stop hook reports its own native `prompt_id` — the same UUID the
1451
+ // transcript stamps on every entry of this turn (extractAssistantTurnForPrompt /
1452
+ // resolveEntryPromptId in claude-transcript.ts). It's the authoritative turn
1453
+ // identifier; currentTurnId should already equal it (seeded from the same
1454
+ // prompt_id at turn start — see setProviderStatus / handleUserPrompt), but this
1455
+ // prefers the freshest source so finalization is correct even if the two drift.
1456
+ const turnId = input.promptId ?? this.currentTurnId;
1447
1457
  this.stopReasoningTail();
1448
1458
  // Optional correlation for threading + obligation clearing — never a capture gate.
1449
1459
  let replyToMessageId: number | undefined, replyTarget = "user";
@@ -1464,32 +1474,41 @@ export class AgentRunner {
1464
1474
  const flushCount = this.narrativeFlushEntryCount;
1465
1475
  this.narrativeFlushEntryCount = 0;
1466
1476
 
1467
- // The Stop hook can fire before the final assistant entry is flushed to disk.
1468
- // Claude writes thinking and text as separate entries (both with end_turn), so
1469
- // the transcript can "look complete" while the text entry is still pending.
1470
- // Retry until both the transcript has an end_turn AND extraction yields text.
1471
- let body = "";
1472
- let jsonl: string | undefined;
1473
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1474
- if (jsonl !== undefined) {
1475
- for (let attempt = 0; attempt < 5; attempt++) {
1476
- if (attempt > 0) {
1477
- await new Promise((r) => setTimeout(r, 100));
1478
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { break; }
1479
- }
1480
- if (!transcriptLooksComplete(jsonl)) continue;
1481
- // If a pre-flush already emitted entries 1..flushCount, resume after that mark to avoid double-emit (#435/#499).
1482
- const extract = this.options.providerConfig.chatCaptureMode === "full"
1483
- ? (j: string) => (flushCount > 0 ? extractLastAssistantTurnAfterEntry(j, flushCount) : extractLastAssistantTurn(j))
1484
- : (j: string) => (flushCount > 0 ? extractFinalAssistantMessageAfterEntry(j, flushCount) : extractFinalAssistantMessage(j));
1485
- body = extract(jsonl);
1486
- if (body) break;
1477
+ // PRIMARY capture source: the Stop hook's own last_assistant_message. Claude
1478
+ // reports it synchronously in the hook payload itself, so there is no
1479
+ // transcript file-read race to retry against — this text is authoritative and
1480
+ // complete the moment the hook fires. The transcript is read at most ONCE
1481
+ // (never retried/looped) and only to enrich "full" mode with the turn's
1482
+ // intermediate narration; if that single read comes up empty (the file simply
1483
+ // hasn't caught up yet) the hook payload text still carries the turn.
1484
+ let body = extractHookAssistantMessage(input.lastAssistantMessage);
1485
+ if (this.options.providerConfig.chatCaptureMode === "full" && input.transcriptPath) {
1486
+ let jsonl: string | undefined;
1487
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1488
+ if (jsonl !== undefined && transcriptLooksComplete(jsonl)) {
1489
+ // Deterministic slicing via prompt_id→parentUuid chaining when the native id is
1490
+ // known; only fall back to the legacy "last real user prompt" heuristic when that
1491
+ // deterministic slice comes up EMPTY, not merely when turnId is falsy an old
1492
+ // Claude (<2.1.196) mints a truthy random turnId that never resolves via promptId
1493
+ // chaining, so branching on truthiness alone left this fallback unreachable and
1494
+ // silently dropped intermediate narration in full mode (#1086 review, Finding F1).
1495
+ let full = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
1496
+ if (!full) full = flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl);
1497
+ if (full) body = full;
1487
1498
  }
1488
1499
  }
1489
- // Fallback: last_assistant_message from the Stop hook payload, which bypasses
1490
- // the transcript file race entirely.
1491
- if (!body && input.lastAssistantMessage) {
1492
- body = extractHookAssistantMessage(input.lastAssistantMessage);
1500
+ // Last-resort safety net for the default "final" mode (#1086 review, Finding F2):
1501
+ // last_assistant_message is an undocumented Claude hook field. If a future Claude
1502
+ // version renames or drops it, do ONE (never retried same one-shot discipline as the
1503
+ // "full" mode enrichment above) transcript read rather than silently losing every
1504
+ // capture in the mode nearly every provider config runs in.
1505
+ if (!body && this.options.providerConfig.chatCaptureMode === "final" && input.transcriptPath) {
1506
+ let jsonl: string | undefined;
1507
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1508
+ if (jsonl !== undefined) {
1509
+ const fallback = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
1510
+ body = fallback || (flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl));
1511
+ }
1493
1512
  }
1494
1513
  // A pure tool-use turn with no closing text is fine to skip — its reasoning and
1495
1514
  // tool steps already carried the visibility into chat.
@@ -1499,7 +1518,18 @@ export class AgentRunner {
1499
1518
  }
1500
1519
 
1501
1520
  this.sessionLog(`response captured for turn ${turnId ?? "?"} (${body.length} chars${replyToMessageId ? `, replyTo #${replyToMessageId}` : ", no replyTo"})`);
1502
- await publishCapturedResponse({ publishSessionEvent: (event) => this.publishSessionEvent(event), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: "provider", final: true, ...(turnId ? { turnId } : {}) } });
1521
+ await publishCapturedResponse({
1522
+ publishSessionEvent: (event) => this.publishSessionEvent(event),
1523
+ outbox: this.sessionOutbox,
1524
+ provider: this.options.provider,
1525
+ from: this.agentId,
1526
+ to: replyTarget,
1527
+ body,
1528
+ replyTo: replyToMessageId,
1529
+ // A Stop-hook-driven capture is by construction a definitive, provider-native
1530
+ // turn completion — Claude's own lifecycle hook fired, not a reconstructed guess.
1531
+ session: { type: "response", origin: "provider", final: true, confidence: "definitive", endedBy: "completed", ...(turnId ? { turnId } : {}) },
1532
+ });
1503
1533
  // The agent's reply may have cleared an obligation — refresh the snapshot so the next
1504
1534
  // turn-end doesn't re-prompt for a message already answered (#196).
1505
1535
  if (replyToMessageId) this.obligationCache.markDirty();
@@ -1609,10 +1639,15 @@ export class AgentRunner {
1609
1639
  // it into the dashboard chat so both surfaces stay in sync, and kick off reasoning
1610
1640
  // tailing for the turn. Skips prompts the runner itself injected (chat box, relay
1611
1641
  // deliveries) so those aren't double-posted.
1612
- private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
1642
+ private async handleUserPrompt(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void> {
1613
1643
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1614
1644
  if (!this.currentTurnId) {
1615
- this.currentTurnId = crypto.randomUUID();
1645
+ // Claude's own prompt_id (from the UserPromptSubmit hook payload) is the
1646
+ // native, stable turn identifier — the same id the Stop hook reports and the
1647
+ // transcript stamps on every entry of this turn. Falls back to a random mint
1648
+ // only for the rare case prompt_id wasn't available (see the ZS base schema
1649
+ // note: absent until the very first user input of the process lifetime).
1650
+ this.currentTurnId = input.promptId ?? crypto.randomUUID();
1616
1651
  this.currentTurnStartedAt = Date.now();
1617
1652
  }
1618
1653
  const text = input.prompt.trim();
@@ -1739,6 +1774,14 @@ export class AgentRunner {
1739
1774
  // Dashboard prompt injection is already answered by this captured App Server
1740
1775
  // response. User obligations still belong to the session mirror; spawner
1741
1776
  // obligations (#413) route to the spawn-parent with replyTo to clear the debt.
1777
+ // #1083: a turn-final capture is ALWAYS mirrored, regardless of reply route — an
1778
+ // autonomous Codex turn with no pending prompt and no eligible obligation used to
1779
+ // hit `if (!route) return` here and vanish before ever reaching publishCapturedResponse,
1780
+ // so it was neither shown in the dashboard nor eligible for report-up. The route only
1781
+ // decides whether an explicit reply/obligation-clear also happens; when there's none,
1782
+ // replyToMessageId stays undefined and the capture still lands as a plain session-mirror
1783
+ // response (enqueueCapturedResponseReport's own `!replyTo` gate then correctly skips
1784
+ // report-up — there's no spawner obligation to report up against).
1742
1785
  let replyToMessageId: number | undefined, replyTarget = "user";
1743
1786
  const pendingPrompt = this.pendingPromptMessageId;
1744
1787
  if (pendingPrompt) {
@@ -1746,10 +1789,9 @@ export class AgentRunner {
1746
1789
  this.pendingPromptMessageId = undefined;
1747
1790
  } else {
1748
1791
  const route = responseCaptureReplyRoute(this.obligationCache.get(), this.currentTurnStartedAt, { suppressUser: true });
1749
- if (!route) return; // The session mirror will satisfy the user obligation; don't double-post (#196).
1750
- replyToMessageId = route.replyToMessageId; replyTarget = route.to;
1792
+ if (route) { replyToMessageId = route.replyToMessageId; replyTarget = route.to; }
1751
1793
  }
1752
- await publishCapturedResponse({ publishSessionEvent: (sessionEvent) => this.publishSessionEvent(sessionEvent), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}) } });
1794
+ await publishCapturedResponse({ publishSessionEvent: (sessionEvent) => this.publishSessionEvent(sessionEvent), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}), ...(event.confidence ? { confidence: event.confidence } : {}), ...(event.endedBy ? { endedBy: event.endedBy } : {}) } });
1753
1795
  if (replyToMessageId) this.obligationCache.markDirty();
1754
1796
  return;
1755
1797
  }
@@ -4,6 +4,7 @@ import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSign
4
4
  import { deliverableTaskIdsBySpawnRequestId, getAgent, markAgentReadyNotified, recordCrashReport, recordTerminalEvent } from "./db";
5
5
  import { crashReportFor } from "./agent-death-diagnosis";
6
6
  import { renderTemplate } from "./prompt-resolver";
7
+ import { markAgentWorkFinal } from "./services/work-finality";
7
8
  import type { AgentCard, AgentDeathDiagnosis } from "./types";
8
9
 
9
10
  /** One-line cause tag appended to a death notification body, e.g. " · why: Clean exit (exit code 0)". */
@@ -187,6 +188,13 @@ export function notifyAgentOffline(childId: string, reason: string, diagnosis?:
187
188
  const child = getAgent(childId);
188
189
  const parent = child?.spawnedBy;
189
190
  if (!child || !parent) return;
191
+ // #1084 crash-final-loss fix-forward — this is the platform confirming the child is gone for
192
+ // good (past the reconnect/stale grace, or an explicit exit-diagnostics report). A genuine final
193
+ // report captured+held in the ring before the crash must go out now or it is lost forever: no
194
+ // later signal (relay_workspace_ready, task→done) is coming from a process that no longer exists.
195
+ // Idempotent (flushWorkFinalReports no-ops once the ring is empty), so a redundant call from a
196
+ // second offline classification of the same agent is harmless.
197
+ markAgentWorkFinal(childId, "edge-fallback");
190
198
  // Classify from the live set OR the durable readiness stamp. The `delete` always runs (cleanup of
191
199
  // the in-memory set); the persisted stamp covers a child that became ready before a relay restart.
192
200
  const wasReady = readyFired.delete(childId) || everReady(child);
package/src/bus.ts CHANGED
@@ -15,7 +15,6 @@ import { agentProviderTimelineEvent, auditRunnerTimelineEvent } from "./provider
15
15
  import { ServiceAuthError } from "./services/errors";
16
16
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
17
17
  import { flushQueuedDirectMessages } from "./services/managed-running";
18
- import { flushIdleLineageReport } from "./services/send-message";
19
18
  import { isAgentUnavailable } from "./db/agent-predicates";
20
19
  import { applyCommandToRecipe } from "./recipe-runner";
21
20
  import { enqueueContinuationInjectionAfterSelfResume } from "./self-resume";
@@ -106,10 +105,6 @@ export function busHandleClose(ws: BusWebSocket): void {
106
105
  if (!conn.transportReconnect) {
107
106
  setStatus(conn.agentId, "stale", { instanceId: conn.instanceId, epoch: conn.epoch });
108
107
  emitAgentStatusEvent(conn.agentId);
109
- // #1040 — a WS close is a busy→exit edge (the runner crashed/dropped before its stop-hook
110
- // status frame). Flush any held turn-final report-up so it still reaches the spawner instead
111
- // of dying with the socket. No-op when nothing was deferred.
112
- flushIdleLineageReport(conn.agentId);
113
108
  }
114
109
  }
115
110
  }
@@ -134,9 +129,6 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs(), lagGraceMs = 0
134
129
  // (a fresh instanceId → epoch bump), never on a heartbeat lapse.
135
130
  setStatus(row.id, "offline");
136
131
  emitAgentStatusEvent(row.id);
137
- // #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
138
- // clean idle transition. Flush any held turn-final report-up before the agent ages out.
139
- flushIdleLineageReport(row.id);
140
132
  const tasks = orphanTasksForAgent(row.id);
141
133
  for (const task of tasks) {
142
134
  orphanedTasks.push(task);
@@ -172,16 +164,6 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
172
164
  heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
173
165
  if (frame.payload.status) {
174
166
  setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
175
- // #1040 — a heartbeat frame can carry the busy→idle transition instead of a `status`
176
- // frame. Flush the held report-up on THIS edge too, else `before.status` is already
177
- // non-busy at the next status frame → the flush never fires → the child deadlocks idle
178
- // with its verdict held and the coordinator waits forever.
179
- // #1037 (re-regression) — gate on a genuine at-rest `idle`, NOT merely "not busy". A
180
- // real turn-end always reports `idle`; a transient non-idle status (a stray `online`,
181
- // a malformed frame) is NOT the end of the child's work and must not flush its held
182
- // mid-work capture upstream. Terminal exit edges (WS-close/stale sweep) keep their
183
- // unconditional flush for the #1040 delivery guarantee — this only tightens the LIVE edge.
184
- if (current?.status === "busy" && frame.payload.status === "idle") flushIdleLineageReport(conn.agentId);
185
167
  }
186
168
  emitAgentStatusEvent(conn.agentId);
187
169
  // #416 — flush queued messages if the agent transitioned from unavailable to available.
@@ -226,12 +208,6 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
226
208
  emitAgentStatusEvent(conn.agentId);
227
209
  // #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
228
210
  probeWorkspaceOnTurnEnd(before, after);
229
- // #1037 — the same busy→idle turn-end edge flushes a quiet child's deferred turn-final
230
- // report-up to its spawner (held back while the child was mid-work). Gate on a genuine
231
- // at-rest `idle`: a real turn-end reports `idle`, whereas a transient non-idle status
232
- // (stray `online`, reconnect) is not the child going to rest and must not flush its held
233
- // mid-work capture. Terminal exit edges keep the #1040 unconditional flush.
234
- if (before?.status === "busy" && after && after.status === "idle") flushIdleLineageReport(conn.agentId);
235
211
  // #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
236
212
  // marks the offline edge with `terminalProviderExit`; the owning service turns that into a
237
213
  // terminal event + #636 diagnosis (and clears the hold on a genuine resume).
package/src/db/agents.ts CHANGED
@@ -566,7 +566,20 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS, lagGraceMs = 0): s
566
566
 
567
567
  export { settleSingleTargetOnDemandTasks } from "./task-maintenance.ts";
568
568
 
569
- export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
569
+ export function pruneOfflineAgents(
570
+ maxOfflineMs: number = DAY_MS,
571
+ opts?: {
572
+ /**
573
+ * #1084 crash-final-loss fix-forward — defense-in-depth for a held work-final ring that
574
+ * somehow survived to prune time (the primary flush is `notifyAgentOffline`, fired when the
575
+ * agent first goes offline). Called per pruned agent id BEFORE its row is deleted, so a
576
+ * caller can flush any still-held final report while `getAgent(id)` can still resolve it —
577
+ * kept a plain callback (not a direct import) so this db-layer module stays free of
578
+ * service/SSE-layer dependencies.
579
+ */
580
+ beforeDelete?: (agentId: string) => void;
581
+ },
582
+ ): string[] {
570
583
  const cutoff = Date.now() - maxOfflineMs;
571
584
  return getDb().transaction(() => {
572
585
  const rows = getDb()
@@ -597,6 +610,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
597
610
 
598
611
  for (const row of rows) {
599
612
  // Offline past the prune horizon and being deleted — a hard kill, not recoverable.
613
+ opts?.beforeDelete?.(row.id);
600
614
  revokeRuntimeTokensForAgent(row.id, now, "pruned");
601
615
  closeOpenPairsForAgent(row.id, now);
602
616
  removeSubscriptionsForAgent(row.id);
@@ -1,6 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import {
4
+ hasWorkFinality,
4
5
  isRecord,
5
6
  isTurnFinalSession,
6
7
  stringValue,
@@ -8,6 +9,7 @@ import {
8
9
  isReportVerbosity,
9
10
  type MessagePayload,
10
11
  type MessageWorkFinality,
12
+ type MessageWorkFinalitySource,
11
13
  type ReportVerbosity,
12
14
  } from "agent-relay-sdk";
13
15
  import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSignalKey } from "../lifecycle-signal-registry";
@@ -364,11 +366,6 @@ function reportVerbosityOf(child: AgentCard): ReportVerbosity {
364
366
  return isReportVerbosity(value) ? value : "quiet";
365
367
  }
366
368
 
367
- /** The child is mid-turn (not yet idle/awaiting-input). The authoritative "still working" signal. */
368
- function childIsBusy(child: AgentCard): boolean {
369
- return child.status === "busy";
370
- }
371
-
372
369
  // #1037 (re-regression) — the report-up must reach the spawner as a CONCISE result, not the full
373
370
  // turn transcript. In `chatCaptureMode: "full"` (and for rambling multi-block recaps) the captured
374
371
  // turn-final body is the ENTIRE turn's narration — every "Let me read X… now Y… now Z…" segment
@@ -424,50 +421,90 @@ function distillLineageReportBody(body: string, messageId: number): string {
424
421
  return `${reportUpElision(messageId)}\n\n${tail}`;
425
422
  }
426
423
 
427
- // #1037 — a quiet child's turn-final response that arrives while the child is still busy is a
428
- // mid-task yield, not the end of its work. Instead of shipping it upstream (mid-work narration) or
429
- // dropping it (a genuine last turn whose idle status simply hasn't landed yet), we HOLD the latest
430
- // one per child and flush it when the child transitions to idle (see flushDeferredLineageReport,
431
- // driven off the bus turn-end edge). Coalescing on childId means only the final turn's report lands.
432
- const deferredLineageReports = new Map<string, number>();
424
+ // #1084 — a turn-final response is TURN-final (this provider turn ended), not WORK-final (the
425
+ // agent's work is done). Forwarding every one upstream is the false-final flood (#1078/#1058/#1032/
426
+ // #976): a child idling briefly between two subtasks looks identical, on the wire, to a child that
427
+ // just finished. So a `responses`-stream turn-final is never delivered on capture it is HELD per
428
+ // child in a small ring (the last few closing turns; see LINEAGE_FINAL_GROUP_CAP) until an explicit
429
+ // work-finality signal closes the window (see flushWorkFinalReports in ../services/work-finality.ts,
430
+ // anchored to relay_workspace_ready / task→done / agent teardown-reap — never a busy→idle bus edge).
431
+ const LINEAGE_FINAL_GROUP_CAP = 3;
432
+ // #1084 MEDIUM fix-forward — the ring above is capped by COUNT, not recency: after 5 steer cycles
433
+ // a 3-slot ring holds whatever is still sitting there regardless of age, so a close could flush up
434
+ // to 2 stale mid-work narrations from earlier cycles alongside (or instead of) the real closing
435
+ // message(s). `flushWorkFinalReports` gates each held entry on its age *relative to the close*, so
436
+ // only captures from the completing sequence — not leftover narration from a much earlier cycle —
437
+ // ride along in the delivered group. Generous relative to LINEAGE_REPORT_DUPLICATE_WINDOW_MS (2min)
438
+ // so a genuinely multi-turn closing sequence is never mistaken for stale narration.
439
+ const LINEAGE_FINAL_GROUP_RECENCY_MS = 10 * 60 * 1000;
440
+ const deferredLineageReports = new Map<string, number[]>();
433
441
 
434
442
  function deferLineageReport(childId: string, messageId: number): void {
435
- deferredLineageReports.set(childId, messageId);
436
- }
437
-
438
- function clearDeferredLineageReport(childId: string): void {
439
- deferredLineageReports.delete(childId);
443
+ const queue = deferredLineageReports.get(childId) ?? [];
444
+ queue.push(messageId);
445
+ while (queue.length > LINEAGE_FINAL_GROUP_CAP) queue.shift();
446
+ deferredLineageReports.set(childId, queue);
440
447
  }
441
448
 
442
- /** Test seam: clear the #1037 deferred report-up hold between tests. */
449
+ /** Test seam: clear the held work-final group between tests. */
443
450
  export function __resetDeferredLineageReports(): void {
444
451
  deferredLineageReports.clear();
445
452
  }
446
453
 
454
+ export interface WorkFinalFlushResult {
455
+ /** The chat report(s) forwarded (or found already-forwarded) to the spawner, oldest first. */
456
+ forwarded: Array<{ message: Message; created: boolean }>;
457
+ /** The original session-mirror captures retroactively tagged `payload.finality` (for the
458
+ * `message.finalized` SSE upgrade of already-rendered bubbles). */
459
+ finalizedOriginals: Message[];
460
+ }
461
+
447
462
  /**
448
- * #1037flush a child's deferred turn-final report to its spawner. Called when the child
449
- * transitions out of busy (idle/awaiting-input): re-runs the promotion, which now passes the idle
450
- * gate and delivers. No-op when nothing was deferred. Returns the promoted message, or null.
463
+ * #1084close a child's work window: flush its held turn-final group to its spawner as ONE
464
+ * work-final unit sharing a fresh `windowId`, tagged with `source` (the explicit signal that closed
465
+ * the window) and an incrementing `groupOrder`. No-op when nothing was held. This is the ONLY path
466
+ * that delivers a `responses`-stream capture to the spawner (besides the `verbose` bypass in
467
+ * promoteLineageCapturedResponse) — call it from the finality service, never from a bus status edge.
451
468
  */
452
- export function flushDeferredLineageReport(childId: string): { message: Message; created: boolean } | null {
453
- const messageId = deferredLineageReports.get(childId);
454
- if (messageId === undefined) return null;
469
+ export function flushWorkFinalReports(childId: string, source: MessageWorkFinalitySource): WorkFinalFlushResult {
470
+ const queue = deferredLineageReports.get(childId);
471
+ if (!queue || queue.length === 0) return { forwarded: [], finalizedOriginals: [] };
455
472
  deferredLineageReports.delete(childId);
456
- const message = getMessage(messageId);
457
- if (!message) return null;
458
- return promoteLineageCapturedResponse(message);
473
+ const child = getAgent(childId);
474
+ const parent = child?.spawnedBy;
475
+ if (!child || !parent) return { forwarded: [], finalizedOriginals: [] };
476
+
477
+ const windowId = randomUUID();
478
+ const markedAt = Date.now();
479
+ const recencyCutoff = markedAt - LINEAGE_FINAL_GROUP_RECENCY_MS;
480
+ const forwarded: Array<{ message: Message; created: boolean }> = [];
481
+ const finalizedOriginals: Message[] = [];
482
+ let groupOrder = 0;
483
+ for (const messageId of queue) {
484
+ const original = getMessage(messageId);
485
+ if (!original) continue;
486
+ // #1084 MEDIUM — drop stale mid-work narration the count-capped ring is still holding from an
487
+ // earlier steer cycle; only captures from the completing sequence ride along in this group.
488
+ if ((original.occurredAt ?? original.createdAt) < recencyCutoff) continue;
489
+ const session = sessionMeta(original);
490
+ if (!session) continue;
491
+ const finality: MessageWorkFinality = { windowId, source, groupOrder, markedAt };
492
+ const result = forwardLineageStreamCapture(original, session, "responses", child, parent, finality);
493
+ if (!result) continue;
494
+ groupOrder++;
495
+ forwarded.push(result);
496
+ const finalizedOriginal = setMessageWorkFinality(messageId, finality);
497
+ if (finalizedOriginal) finalizedOriginals.push(finalizedOriginal);
498
+ }
499
+ return { forwarded, finalizedOriginals };
459
500
  }
460
501
 
461
502
  /**
462
- * #1040/#1041 — the persisted turn-final response captures needed to rebuild the in-memory
463
- * deferred-report hold after a relay restart. Bounded by a recency window (a report is only ever
464
- * held while its child was mid-turn, so it is inherently recent). Grouped to the single newest
465
- * (highest-id) final capture per from_agent child *before* the row cap is applied, so that: (a) an
466
- * older mid-task final that #1037 coalesced away persisted but never given an idempotency key —
467
- * is never resurrected alongside the real verdict, and (b) a chatty child's own history can't push
468
- * another child's final out of the cap. The caller re-runs promotion, which is idempotent: an
469
- * already-delivered capture is a no-op, an idle child's lost report is delivered, and a still-busy
470
- * child simply re-defers (holding the newest one, matching the live #1037 coalescing contract).
503
+ * #1040/#1041 — the persisted turn-final response captures needed to rebuild the in-memory held
504
+ * group after a relay restart. Bounded by a recency window (a report is only ever held while its
505
+ * work window is open, so it is inherently recent). The caller re-runs promotion per capture, which
506
+ * is idempotent: an already-forwarded capture is a no-op (idempotency key), and everything else
507
+ * re-enters the held ring exactly as it stood pre-restart, awaiting the next work-finality signal.
471
508
  */
472
509
  export function listRecentFinalResponseCaptures(sinceMs: number, limit = 1000): Message[] {
473
510
  const rows = getDb().query(`
@@ -529,17 +566,47 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
529
566
  // #1037 verbose: forward every child stream without an explicit per-stream subscription.
530
567
  if (stream !== "responses" && verbosity !== "verbose" && !childStreamSubscribed({ parentId: parent, child, stream })) return null;
531
568
 
532
- // #1037the report-up delivery gate. In `quiet` (default), a turn-final response that arrives
533
- // while the child is still busy is mid-task narration: defer it (flushed on the idle transition)
534
- // rather than waking the spawner mid-work. `normal`/`verbose` forward every turn-final at once.
535
- if (stream === "responses" && verbosity === "quiet" && childIsBusy(child)) {
569
+ // #1084a `responses`-stream turn-final is TURN-final, not WORK-final: hold it (see
570
+ // deferLineageReport above) rather than forwarding, regardless of the child's busy/idle state
571
+ // only an explicit work-finality signal (flushWorkFinalReports) delivers it upstream. `verbose` is
572
+ // an explicit opt-in firehose (a caller that asked for every turn) and bypasses the hold, matching
573
+ // its pre-#1084 behavior.
574
+ if (stream === "responses" && verbosity !== "verbose") {
536
575
  deferLineageReport(child.id, message.id);
537
576
  return null;
538
577
  }
539
578
 
579
+ return forwardLineageStreamCapture(message, session, stream, child, parent);
580
+ }
581
+
582
+ /**
583
+ * Forward one captured child-stream event to its spawner as a `chat` report-up. Shared by the
584
+ * `verbose` immediate bypass (no `finality`) and `flushWorkFinalReports` (tags the forwarded report
585
+ * — and, via the caller, the original capture — with the closing work window's `finality`).
586
+ */
587
+ function forwardLineageStreamCapture(
588
+ message: Message,
589
+ session: Record<string, unknown>,
590
+ stream: ChildStream,
591
+ child: AgentCard,
592
+ parent: string,
593
+ finality?: MessageWorkFinality,
594
+ ): { message: Message; created: boolean } | null {
540
595
  const idempotencyKey = lineageStreamReportKey(message, session, stream);
541
596
  const existing = findMessageByIdempotencyKey(message.from, idempotencyKey);
542
- if (existing) return { message: existing, created: false };
597
+ if (existing) {
598
+ // #1084 MEDIUM (Lens-3 retroactive-finality) fix-forward — an already-forwarded report (e.g. a
599
+ // `verbose` bypass, or a duplicate suppressed on an earlier attempt) must still pick up the
600
+ // finality tag when its window later closes, or its dashboard bubble never upgrades from
601
+ // ordinary mid-turn narration to a closing message. Only patch when it isn't already tagged —
602
+ // an existing tag reflects an earlier, equally-valid window close and must not be overwritten.
603
+ if (finality && !hasWorkFinality(existing.payload)) {
604
+ const updatedPayload: MessagePayload = { ...(existing.payload ?? {}), finality };
605
+ getDb().query("UPDATE messages SET payload = ? WHERE id = ?").run(JSON.stringify(updatedPayload), existing.id);
606
+ return { message: getMessage(existing.id) ?? existing, created: false };
607
+ }
608
+ return { message: existing, created: false };
609
+ }
543
610
  if (stream === "responses") {
544
611
  const turnId = captureTurnId(session);
545
612
  if (turnId) {
@@ -559,12 +626,17 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
559
626
  since: message.createdAt - LINEAGE_REPORT_DUPLICATE_WINDOW_MS,
560
627
  });
561
628
  if (duplicate && !responseCaptureMeta(duplicate)) {
629
+ const updatedPayload: MessagePayload = {
630
+ ...(duplicate.payload ?? {}),
631
+ responseCapture: { ...session },
632
+ ...(finality ? { finality } : {}),
633
+ };
562
634
  getDb().query(`
563
635
  UPDATE messages
564
636
  SET idempotency_key = coalesce(idempotency_key, ?),
565
637
  payload = ?
566
638
  WHERE id = ?
567
- `).run(idempotencyKey, JSON.stringify({ ...(duplicate.payload ?? {}), responseCapture: { ...session } }), duplicate.id);
639
+ `).run(idempotencyKey, JSON.stringify(updatedPayload), duplicate.id);
568
640
  return { message: getMessage(duplicate.id) ?? duplicate, created: false };
569
641
  }
570
642
  }
@@ -579,12 +651,11 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
579
651
  replyExpected: false,
580
652
  idempotencyKey,
581
653
  payload: stream === "responses"
582
- ? { responseCapture: { ...session } }
654
+ ? { responseCapture: { ...session }, ...(finality ? { finality } : {}) }
583
655
  : { childStreamCapture: { stream, ...session } },
584
656
  occurredAt: message.occurredAt ?? message.createdAt,
585
657
  });
586
658
  if (stream === "responses" && result.created) {
587
- clearDeferredLineageReport(child.id);
588
659
  stampReportUpLifecycleSignal(parent, child);
589
660
  }
590
661
  return result;
@@ -48,6 +48,7 @@ import {
48
48
  import { routeNotification } from "../notification-router";
49
49
  import { pruneExpiredTokenRecords } from "../token-db";
50
50
  import { reapTransientAgents } from "../services/transient-agent-reaper";
51
+ import { markAgentWorkFinal } from "../services/work-finality";
51
52
  import { reapUnregisteredSpawnCommands } from "../services/spawn-registration-watchdog";
52
53
  import { resumeRateLimitedAgents } from "../services/rate-limit-resume";
53
54
  import { collectCodexWhamQuota } from "../codex-wham-quota";
@@ -334,7 +335,12 @@ export const definitions: MaintenanceJobDefinition[] = [
334
335
  intervalMs: REAP_INTERVAL_MS,
335
336
  runOnStart: false,
336
337
  handler() {
337
- const prunedAgentIds = pruneOfflineAgents(OFFLINE_PRUNE_MS);
338
+ // #1084 crash-final-loss fix-forward — defense-in-depth: `notifyAgentOffline` is the primary
339
+ // flush point when an agent first goes offline, so this is normally a no-op by the time an
340
+ // agent reaches prune horizon; kept here in case any offline transition ever bypasses it.
341
+ const prunedAgentIds = pruneOfflineAgents(OFFLINE_PRUNE_MS, {
342
+ beforeDelete: (id) => markAgentWorkFinal(id, "edge-fallback"),
343
+ });
338
344
  for (const id of prunedAgentIds) emitAgentRemoved(id);
339
345
  return { prunedAgentIds };
340
346
  },
@@ -361,7 +367,7 @@ export const definitions: MaintenanceJobDefinition[] = [
361
367
  runOnStart: false,
362
368
  handler: () => reapOrphanedSessions(),
363
369
  },
364
- { id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => reapTransientAgents() },
370
+ { id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed; also flushes a persistent agent's held work-final report once it has settled idle (#1084 HIGH fix-forward).", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => reapTransientAgents() },
365
371
  {
366
372
  id: "lineage-report-startup-sweep",
367
373
  title: "Lineage report startup sweep",
@@ -5,6 +5,7 @@ import { AUTO_MERGE_POLICIES } from "../workspace-merge";
5
5
  import { describeWorkspacePhase, landReceipt, readyContract } from "../workspace-phase";
6
6
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "../workspace-actions";
7
7
  import { refreshWorkspaceGitState } from "../workspace-probe";
8
+ import { markAgentWorkFinal } from "../services/work-finality";
8
9
  import { optionalEnum } from "../validation";
9
10
  import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord } from "../types";
10
11
  import type { McpAuthContext, ToolDefinition } from "./types";
@@ -218,6 +219,9 @@ export async function relayWorkspaceMutation(auth: McpAuthContext, action: Works
218
219
  if (action === "ready") {
219
220
  payload.contract = readyContract(result.workspace);
220
221
  payload.guidance = describeWorkspacePhase(result.workspace);
222
+ // #1084 — relay_workspace_ready is an explicit, platform-owned completion signal: the caller
223
+ // is declaring its work window closed. Close it, forwarding only the held final group.
224
+ markAgentWorkFinal(result.workspace.ownerAgentId ?? callerAgentId(auth), "workspace-ready");
221
225
  }
222
226
  return payload;
223
227
  }
@@ -10,7 +10,6 @@ import { emitAgentRemoved, emitAgentStatus } from "../sse";
10
10
  import { notifyAgentReady } from "../agent-lifecycle-events";
11
11
  import { bindSpawnedTaskToReadyAgent } from "../services/register-agent";
12
12
  import { flushQueuedDirectMessages } from "../services/managed-running";
13
- import { flushIdleLineageReport } from "../services/send-message";
14
13
  import { isAgentUnavailable } from "../db/agent-predicates";
15
14
  import { getCompactionWatch } from "../compaction-watch";
16
15
  import { isRecord } from "agent-relay-sdk";
@@ -200,9 +199,6 @@ export const patchAgentStatus: Handler = async (req, params) => {
200
199
  const before = getAgent(params.id!);
201
200
  if (!setStatus(params.id!, body.status as (typeof VALID_AGENT_STATUSES)[number], guard)) return error("agent not found", 404);
202
201
  auditAgentStateTransition(params.id!, before, getAgent(params.id!));
203
- // #1040 — the HTTP status PATCH is also a busy→idle edge (a runner without a live bus socket).
204
- // Flush any held turn-final report-up so it reaches the spawner on this path too.
205
- if (before?.status === "busy" && body.status !== "busy") flushIdleLineageReport(params.id!);
206
202
  } catch (e) {
207
203
  if (e instanceof ValidationError) return error(e.message, 400);
208
204
  throw e;