agent-relay-server 0.124.0 → 0.126.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 (169) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +9 -5
  3. package/public/assets/{MessageBubble-SQK8-Mzp.js → MessageBubble-B6YgulnX.js} +2 -2
  4. package/public/assets/{MessageBubble-SQK8-Mzp.js.map → MessageBubble-B6YgulnX.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CS-aow4S.js → PlanGraphPanel--DNxHn1T.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CS-aow4S.js.map → PlanGraphPanel--DNxHn1T.js.map} +1 -1
  7. package/public/assets/{activity-W31XvhC2.js → activity-Fo5aLYD6.js} +2 -2
  8. package/public/assets/{activity-W31XvhC2.js.map → activity-Fo5aLYD6.js.map} +1 -1
  9. package/public/assets/agent-profiles-C3p0xclG.js +2 -0
  10. package/public/assets/{agent-profiles-DXysA9To.js.map → agent-profiles-C3p0xclG.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-BxUopALO.js → agent-type-icon-BmBR-9CH.js} +2 -2
  12. package/public/assets/{agent-type-icon-BxUopALO.js.map → agent-type-icon-BmBR-9CH.js.map} +1 -1
  13. package/public/assets/{agents-B5GRNRnN.js → agents-_GBQyF5b.js} +2 -2
  14. package/public/assets/{agents-B5GRNRnN.js.map → agents-_GBQyF5b.js.map} +1 -1
  15. package/public/assets/{analytics-DqAuUAwX.js → analytics-BZtRycyL.js} +2 -2
  16. package/public/assets/{analytics-DqAuUAwX.js.map → analytics-BZtRycyL.js.map} +1 -1
  17. package/public/assets/{automation-DYlgHYcb.js → automation-Cf_Ewi9f.js} +2 -2
  18. package/public/assets/{automation-DYlgHYcb.js.map → automation-Cf_Ewi9f.js.map} +1 -1
  19. package/public/assets/{badge-XEru6wto.js → badge-BCvp7LK3.js} +2 -2
  20. package/public/assets/{badge-XEru6wto.js.map → badge-BCvp7LK3.js.map} +1 -1
  21. package/public/assets/{branch-state-badge-Mf0Ow77K.js → branch-state-badge-Cscjqf-E.js} +2 -2
  22. package/public/assets/{branch-state-badge-Mf0Ow77K.js.map → branch-state-badge-Cscjqf-E.js.map} +1 -1
  23. package/public/assets/{button-C3CQSr4p.js → button-DyHjsHY3.js} +2 -2
  24. package/public/assets/{button-C3CQSr4p.js.map → button-DyHjsHY3.js.map} +1 -1
  25. package/public/assets/{card-C5XTIPvq.js → card-BoqYxpVW.js} +2 -2
  26. package/public/assets/{card-C5XTIPvq.js.map → card-BoqYxpVW.js.map} +1 -1
  27. package/public/assets/{channels-DVRkVeky.js → channels-DAtOnA6o.js} +2 -2
  28. package/public/assets/{channels-DVRkVeky.js.map → channels-DAtOnA6o.js.map} +1 -1
  29. package/public/assets/chat-BdbIWZpQ.js +2 -0
  30. package/public/assets/chat-BdbIWZpQ.js.map +1 -0
  31. package/public/assets/{connectors-Cqubkw9C.js → connectors-B2grC-hl.js} +2 -2
  32. package/public/assets/{connectors-Cqubkw9C.js.map → connectors-B2grC-hl.js.map} +1 -1
  33. package/public/assets/{copy-button-Bil2CjzT.js → copy-button-C67FUG3E.js} +2 -2
  34. package/public/assets/{copy-button-Bil2CjzT.js.map → copy-button-C67FUG3E.js.map} +1 -1
  35. package/public/assets/{display-GFbGUG0O.js → display-aTnwsqQh.js} +3 -3
  36. package/public/assets/{display-GFbGUG0O.js.map → display-aTnwsqQh.js.map} +1 -1
  37. package/public/assets/{dist-V3wu3BHw.js → dist-BD4Xc13v.js} +2 -2
  38. package/public/assets/{dist-V3wu3BHw.js.map → dist-BD4Xc13v.js.map} +1 -1
  39. package/public/assets/{dist-C5z05Vkz.js → dist-BFDfbfNC.js} +2 -2
  40. package/public/assets/{dist-C5z05Vkz.js.map → dist-BFDfbfNC.js.map} +1 -1
  41. package/public/assets/{dist-CjyfQThq.js → dist-BJKxqIQd.js} +2 -2
  42. package/public/assets/{dist-CjyfQThq.js.map → dist-BJKxqIQd.js.map} +1 -1
  43. package/public/assets/{dist-B8tD9ZcR.js → dist-BNB3fTO-.js} +2 -2
  44. package/public/assets/{dist-B8tD9ZcR.js.map → dist-BNB3fTO-.js.map} +1 -1
  45. package/public/assets/{dist-CFtFIhu3.js → dist-CHKra2JV.js} +2 -2
  46. package/public/assets/{dist-CFtFIhu3.js.map → dist-CHKra2JV.js.map} +1 -1
  47. package/public/assets/{dist-r8wyUTkK.js → dist-CQMQ5iku.js} +2 -2
  48. package/public/assets/{dist-r8wyUTkK.js.map → dist-CQMQ5iku.js.map} +1 -1
  49. package/public/assets/{dist-DKmTXfpP.js → dist-C_VOBSaK.js} +2 -2
  50. package/public/assets/{dist-DKmTXfpP.js.map → dist-C_VOBSaK.js.map} +1 -1
  51. package/public/assets/{dist-CGaHpxnN.js → dist-D5w04z0l.js} +2 -2
  52. package/public/assets/{dist-CGaHpxnN.js.map → dist-D5w04z0l.js.map} +1 -1
  53. package/public/assets/{dist-4VwhnLtj.js → dist-DG75bvPp.js} +2 -2
  54. package/public/assets/{dist-4VwhnLtj.js.map → dist-DG75bvPp.js.map} +1 -1
  55. package/public/assets/{dist-CrUl2aQp.js → dist-G8YBD-wp.js} +2 -2
  56. package/public/assets/{dist-CrUl2aQp.js.map → dist-G8YBD-wp.js.map} +1 -1
  57. package/public/assets/{dist-BJaV2BD4.js → dist-f6MW74eX.js} +2 -2
  58. package/public/assets/{dist-BJaV2BD4.js.map → dist-f6MW74eX.js.map} +1 -1
  59. package/public/assets/{es2015-5DTJe35n.js → es2015-a6fysEVz.js} +2 -2
  60. package/public/assets/{es2015-5DTJe35n.js.map → es2015-a6fysEVz.js.map} +1 -1
  61. package/public/assets/{formatted-body-impl-L1EMnNW6.js → formatted-body-impl-gOLBKeyW.js} +2 -2
  62. package/public/assets/{formatted-body-impl-L1EMnNW6.js.map → formatted-body-impl-gOLBKeyW.js.map} +1 -1
  63. package/public/assets/formatted-body-jjhFXTap.js +3 -0
  64. package/public/assets/{formatted-body-BYQ-SPtj.js.map → formatted-body-jjhFXTap.js.map} +1 -1
  65. package/public/assets/index-CgH1BZwq.js +26 -0
  66. package/public/assets/index-CgH1BZwq.js.map +1 -0
  67. package/public/assets/index-DHzXsQot.css +2 -0
  68. package/public/assets/{input-CXMbJ848.js → input-Rc4oWv4q.js} +2 -2
  69. package/public/assets/{input-CXMbJ848.js.map → input-Rc4oWv4q.js.map} +1 -1
  70. package/public/assets/{insights-DwZxq1Dn.js → insights-CTW85An9.js} +2 -2
  71. package/public/assets/{insights-DwZxq1Dn.js.map → insights-CTW85An9.js.map} +1 -1
  72. package/public/assets/{integrations-9G5TJnnU.js → integrations-9c6IdRU4.js} +2 -2
  73. package/public/assets/{integrations-9G5TJnnU.js.map → integrations-9c6IdRU4.js.map} +1 -1
  74. package/public/assets/{lib-BcUW1l-W.js → lib-BZ6WPg2t.js} +2 -2
  75. package/public/assets/{lib-BcUW1l-W.js.map → lib-BZ6WPg2t.js.map} +1 -1
  76. package/public/assets/lucide-react-NXHPIMsX.js +10 -0
  77. package/public/assets/lucide-react-NXHPIMsX.js.map +1 -0
  78. package/public/assets/{maintenance-COcmUDnf.js → maintenance-D5PEygZz.js} +2 -2
  79. package/public/assets/{maintenance-COcmUDnf.js.map → maintenance-D5PEygZz.js.map} +1 -1
  80. package/public/assets/{managed-agents-D9-dgMeX.js → managed-agents-pkpAzZy8.js} +2 -2
  81. package/public/assets/{managed-agents-D9-dgMeX.js.map → managed-agents-pkpAzZy8.js.map} +1 -1
  82. package/public/assets/{markdown-preview-impl-DH372bL-.js → markdown-preview-impl-DMzFE-Lo.js} +2 -2
  83. package/public/assets/{markdown-preview-impl-DH372bL-.js.map → markdown-preview-impl-DMzFE-Lo.js.map} +1 -1
  84. package/public/assets/{memory-B9hRK8sk.js → memory-BnquVYQz.js} +2 -2
  85. package/public/assets/{memory-B9hRK8sk.js.map → memory-BnquVYQz.js.map} +1 -1
  86. package/public/assets/{messages-BHkHlXzF.js → messages-DgxLlndC.js} +2 -2
  87. package/public/assets/{messages-BHkHlXzF.js.map → messages-DgxLlndC.js.map} +1 -1
  88. package/public/assets/{orchestrators-CXy3SDSn.js → orchestrators-BaWxeAlK.js} +2 -2
  89. package/public/assets/{orchestrators-CXy3SDSn.js.map → orchestrators-BaWxeAlK.js.map} +1 -1
  90. package/public/assets/{overview-D2yRyCQq.js → overview-Kvs-j2gg.js} +2 -2
  91. package/public/assets/{overview-D2yRyCQq.js.map → overview-Kvs-j2gg.js.map} +1 -1
  92. package/public/assets/{pairs-5tXdHylI.js → pairs-eDzbqcER.js} +2 -2
  93. package/public/assets/{pairs-5tXdHylI.js.map → pairs-eDzbqcER.js.map} +1 -1
  94. package/public/assets/{projects-CquHTMo1.js → projects-iDkxAGBW.js} +2 -2
  95. package/public/assets/{projects-CquHTMo1.js.map → projects-iDkxAGBW.js.map} +1 -1
  96. package/public/assets/{prompt-settings-Bn5mmi9w.js → prompt-settings-CjcqBG_r.js} +2 -2
  97. package/public/assets/{prompt-settings-Bn5mmi9w.js.map → prompt-settings-CjcqBG_r.js.map} +1 -1
  98. package/public/assets/{react-dom-BQICbUuF.js → react-dom-8yaD9iJG.js} +2 -2
  99. package/public/assets/{react-dom-BQICbUuF.js.map → react-dom-8yaD9iJG.js.map} +1 -1
  100. package/public/assets/{registry-C3sB7qv5.js → registry-C1DSnjjg.js} +2 -2
  101. package/public/assets/{registry-C3sB7qv5.js.map → registry-C1DSnjjg.js.map} +1 -1
  102. package/public/assets/{security-CmD-YE-_.js → security-VbEe_jwm.js} +2 -2
  103. package/public/assets/{security-CmD-YE-_.js.map → security-VbEe_jwm.js.map} +1 -1
  104. package/public/assets/{select-C0UlhK2k.js → select-CVfwgfKl.js} +2 -2
  105. package/public/assets/{select-C0UlhK2k.js.map → select-CVfwgfKl.js.map} +1 -1
  106. package/public/assets/{settings-DrqMG5nG.js → settings-ujA9hZW1.js} +4 -4
  107. package/public/assets/{settings-DrqMG5nG.js.map → settings-ujA9hZW1.js.map} +1 -1
  108. package/public/assets/store-D-Frk_EC.js +9 -0
  109. package/public/assets/store-D-Frk_EC.js.map +1 -0
  110. package/public/assets/{task-chip-BHM-e3I8.js → task-chip-CeujtXYT.js} +2 -2
  111. package/public/assets/{task-chip-BHM-e3I8.js.map → task-chip-CeujtXYT.js.map} +1 -1
  112. package/public/assets/{tasks-ChQjtXHu.js → tasks-BtbKaa-b.js} +2 -2
  113. package/public/assets/{tasks-ChQjtXHu.js.map → tasks-BtbKaa-b.js.map} +1 -1
  114. package/public/assets/{teams-itjNlDLp.js → teams-CrVDQ99v.js} +2 -2
  115. package/public/assets/{teams-itjNlDLp.js.map → teams-CrVDQ99v.js.map} +1 -1
  116. package/public/assets/{terminal-viewer-impl-B9M6s2vB.js → terminal-viewer-impl-Aays_e7-.js} +2 -2
  117. package/public/assets/{terminal-viewer-impl-B9M6s2vB.js.map → terminal-viewer-impl-Aays_e7-.js.map} +1 -1
  118. package/public/assets/{types-CRptbSYh.js → types-BqeSkWrv.js} +2 -2
  119. package/public/assets/types-BqeSkWrv.js.map +1 -0
  120. package/public/assets/{use-keyboard-viewport-CAa7IQTm.js → use-keyboard-viewport-cGEmrGE7.js} +2 -2
  121. package/public/assets/{use-keyboard-viewport-CAa7IQTm.js.map → use-keyboard-viewport-cGEmrGE7.js.map} +1 -1
  122. package/public/assets/{user-avatar-kYPfLgXp.js → user-avatar-qbo-B3dQ.js} +2 -2
  123. package/public/assets/{user-avatar-kYPfLgXp.js.map → user-avatar-qbo-B3dQ.js.map} +1 -1
  124. package/public/assets/{work-queue-Kw0uMkEF.js → work-queue-DDSGZO4Z.js} +2 -2
  125. package/public/assets/{work-queue-Kw0uMkEF.js.map → work-queue-DDSGZO4Z.js.map} +1 -1
  126. package/public/assets/{workspaces-CR6f7G0R.js → workspaces-CoDz-iZd.js} +2 -2
  127. package/public/assets/{workspaces-CR6f7G0R.js.map → workspaces-CoDz-iZd.js.map} +1 -1
  128. package/public/index.html +26 -26
  129. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  130. package/runner/plugins/claude/hooks/relay-status.sh +11 -1
  131. package/runner/plugins/claude/hooks/stop.sh +6 -1
  132. package/runner/plugins/claude/hooks/user-prompt-submit.sh +6 -1
  133. package/runner/src/adapter.ts +5 -1
  134. package/runner/src/adapters/claude-transcript.ts +52 -58
  135. package/runner/src/adapters/codex-agent-message-capture.ts +33 -31
  136. package/runner/src/adapters/codex.ts +30 -12
  137. package/runner/src/control-server.ts +9 -4
  138. package/runner/src/response-capture-report.ts +4 -1
  139. package/runner/src/runner-core.ts +108 -46
  140. package/runner/src/runner-helpers.ts +31 -0
  141. package/src/agent-lifecycle-events.ts +8 -0
  142. package/src/bus.ts +0 -24
  143. package/src/db/agents.ts +15 -1
  144. package/src/db/messages.ts +150 -43
  145. package/src/maintenance/jobs.ts +8 -2
  146. package/src/mcp/tools-workspace.ts +4 -0
  147. package/src/routes/agent-sessions.ts +10 -7
  148. package/src/routes/agents.ts +0 -4
  149. package/src/routes/orchestrator.ts +5 -2
  150. package/src/routes/settings.ts +31 -2
  151. package/src/services/restart-instructions.ts +36 -0
  152. package/src/services/send-message.ts +7 -18
  153. package/src/services/shutdown-agent.ts +6 -0
  154. package/src/services/task-entity.ts +4 -0
  155. package/src/services/transient-agent-reaper.ts +40 -1
  156. package/src/services/work-finality.ts +25 -0
  157. package/src/settings/registry.ts +14 -3
  158. package/public/assets/agent-profiles-DXysA9To.js +0 -2
  159. package/public/assets/chat-Clz6qOtn.js +0 -2
  160. package/public/assets/chat-Clz6qOtn.js.map +0 -1
  161. package/public/assets/formatted-body-BYQ-SPtj.js +0 -3
  162. package/public/assets/index-0uIGXT14.js +0 -25
  163. package/public/assets/index-0uIGXT14.js.map +0 -1
  164. package/public/assets/index-DOJ7KidA.css +0 -2
  165. package/public/assets/lucide-react-CoU_Xts2.js +0 -10
  166. package/public/assets/lucide-react-CoU_Xts2.js.map +0 -1
  167. package/public/assets/store-zFLzd0Jd.js +0 -9
  168. package/public/assets/store-zFLzd0Jd.js.map +0 -1
  169. package/public/assets/types-CRptbSYh.js.map +0 -1
@@ -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,126 @@ 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);
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);
436
447
  }
437
448
 
438
- function clearDeferredLineageReport(childId: string): void {
439
- deferredLineageReports.delete(childId);
440
- }
441
-
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
+ const held: Array<{ original: Message; session: Record<string, unknown> }> = [];
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
+ held.push({ original, session });
492
+ }
493
+ const selected = source === "edge-fallback" ? selectEdgeFallbackLineageReports(held) : held;
494
+ let groupOrder = 0;
495
+ for (const { original, session } of selected) {
496
+ const finality: MessageWorkFinality = { windowId, source, groupOrder, markedAt };
497
+ const result = forwardLineageStreamCapture(original, session, "responses", child, parent, finality);
498
+ if (!result) continue;
499
+ groupOrder++;
500
+ forwarded.push(result);
501
+ const finalizedOriginal = setMessageWorkFinality(original.id, finality);
502
+ if (finalizedOriginal) finalizedOriginals.push(finalizedOriginal);
503
+ }
504
+ return { forwarded, finalizedOriginals };
505
+ }
506
+
507
+ function selectEdgeFallbackLineageReports<T extends { original: Message }>(held: T[]): T[] {
508
+ if (held.length <= 1) return held;
509
+ let bestIndex = -1;
510
+ let bestScore = -Infinity;
511
+ for (let i = 0; i < held.length; i++) {
512
+ const score = edgeFallbackReportScore(held[i]!.original.body);
513
+ if (score > bestScore || (score === bestScore && i > bestIndex)) {
514
+ bestScore = score;
515
+ bestIndex = i;
516
+ }
517
+ }
518
+ return bestScore > 0 && bestIndex >= 0 ? [held[bestIndex]!] : [held.at(-1)!];
519
+ }
520
+
521
+ const EDGE_FALLBACK_REPORT_BOUNDARY = /(^|\n)(#{1,6}\s|\*\*(summary|what changed|rca|fix|tests?|gates?|verification|risk)\b|(?:summary|what changed|rca|fix|tests?|gates?|verification|risk):)/i;
522
+ const EDGE_FALLBACK_REPORT_STRUCTURE = /(^|\n)\s*[-*]\s+\S|`[^`]+`|\b(typecheck|bun test|tests? pass(?:ed)?|gates? green|rca|verified|implemented)\b/i;
523
+ const EDGE_FALLBACK_GENERIC_CODA = /^(?:done|landed(?: on main)?|committed|merged|ready|no further action|nothing further|all set)\b|(?:\bno further action needed\b|\bnothing further needed\b)$/i;
524
+ const EDGE_FALLBACK_NARRATION = /^(?:i(?:'|’)ll|i(?:'|’)m|let me|checking|running|reading|found\b|now\b)/i;
525
+
526
+ function edgeFallbackReportScore(body: string): number {
527
+ const trimmed = body.trim();
528
+ if (!trimmed) return -10;
529
+ if (trimmed.length <= 180 && EDGE_FALLBACK_GENERIC_CODA.test(trimmed) && !EDGE_FALLBACK_REPORT_BOUNDARY.test(trimmed)) return -5;
530
+ let score = 0;
531
+ if (trimmed.length >= 120) score += 2;
532
+ if (EDGE_FALLBACK_REPORT_BOUNDARY.test(trimmed)) score += 6;
533
+ if (EDGE_FALLBACK_REPORT_STRUCTURE.test(trimmed)) score += 3;
534
+ if (EDGE_FALLBACK_NARRATION.test(trimmed)) score -= 3;
535
+ return score;
459
536
  }
460
537
 
461
538
  /**
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).
539
+ * #1040/#1041 — the persisted turn-final response captures needed to rebuild the in-memory held
540
+ * group after a relay restart. Bounded by a recency window (a report is only ever held while its
541
+ * work window is open, so it is inherently recent). The caller re-runs promotion per capture, which
542
+ * is idempotent: an already-forwarded capture is a no-op (idempotency key), and everything else
543
+ * re-enters the held ring exactly as it stood pre-restart, awaiting the next work-finality signal.
471
544
  */
472
545
  export function listRecentFinalResponseCaptures(sinceMs: number, limit = 1000): Message[] {
473
546
  const rows = getDb().query(`
@@ -529,17 +602,47 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
529
602
  // #1037 verbose: forward every child stream without an explicit per-stream subscription.
530
603
  if (stream !== "responses" && verbosity !== "verbose" && !childStreamSubscribed({ parentId: parent, child, stream })) return null;
531
604
 
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)) {
605
+ // #1084a `responses`-stream turn-final is TURN-final, not WORK-final: hold it (see
606
+ // deferLineageReport above) rather than forwarding, regardless of the child's busy/idle state
607
+ // only an explicit work-finality signal (flushWorkFinalReports) delivers it upstream. `verbose` is
608
+ // an explicit opt-in firehose (a caller that asked for every turn) and bypasses the hold, matching
609
+ // its pre-#1084 behavior.
610
+ if (stream === "responses" && verbosity !== "verbose") {
536
611
  deferLineageReport(child.id, message.id);
537
612
  return null;
538
613
  }
539
614
 
615
+ return forwardLineageStreamCapture(message, session, stream, child, parent);
616
+ }
617
+
618
+ /**
619
+ * Forward one captured child-stream event to its spawner as a `chat` report-up. Shared by the
620
+ * `verbose` immediate bypass (no `finality`) and `flushWorkFinalReports` (tags the forwarded report
621
+ * — and, via the caller, the original capture — with the closing work window's `finality`).
622
+ */
623
+ function forwardLineageStreamCapture(
624
+ message: Message,
625
+ session: Record<string, unknown>,
626
+ stream: ChildStream,
627
+ child: AgentCard,
628
+ parent: string,
629
+ finality?: MessageWorkFinality,
630
+ ): { message: Message; created: boolean } | null {
540
631
  const idempotencyKey = lineageStreamReportKey(message, session, stream);
541
632
  const existing = findMessageByIdempotencyKey(message.from, idempotencyKey);
542
- if (existing) return { message: existing, created: false };
633
+ if (existing) {
634
+ // #1084 MEDIUM (Lens-3 retroactive-finality) fix-forward — an already-forwarded report (e.g. a
635
+ // `verbose` bypass, or a duplicate suppressed on an earlier attempt) must still pick up the
636
+ // finality tag when its window later closes, or its dashboard bubble never upgrades from
637
+ // ordinary mid-turn narration to a closing message. Only patch when it isn't already tagged —
638
+ // an existing tag reflects an earlier, equally-valid window close and must not be overwritten.
639
+ if (finality && !hasWorkFinality(existing.payload)) {
640
+ const updatedPayload: MessagePayload = { ...(existing.payload ?? {}), finality };
641
+ getDb().query("UPDATE messages SET payload = ? WHERE id = ?").run(JSON.stringify(updatedPayload), existing.id);
642
+ return { message: getMessage(existing.id) ?? existing, created: false };
643
+ }
644
+ return { message: existing, created: false };
645
+ }
543
646
  if (stream === "responses") {
544
647
  const turnId = captureTurnId(session);
545
648
  if (turnId) {
@@ -559,12 +662,17 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
559
662
  since: message.createdAt - LINEAGE_REPORT_DUPLICATE_WINDOW_MS,
560
663
  });
561
664
  if (duplicate && !responseCaptureMeta(duplicate)) {
665
+ const updatedPayload: MessagePayload = {
666
+ ...(duplicate.payload ?? {}),
667
+ responseCapture: { ...session },
668
+ ...(finality ? { finality } : {}),
669
+ };
562
670
  getDb().query(`
563
671
  UPDATE messages
564
672
  SET idempotency_key = coalesce(idempotency_key, ?),
565
673
  payload = ?
566
674
  WHERE id = ?
567
- `).run(idempotencyKey, JSON.stringify({ ...(duplicate.payload ?? {}), responseCapture: { ...session } }), duplicate.id);
675
+ `).run(idempotencyKey, JSON.stringify(updatedPayload), duplicate.id);
568
676
  return { message: getMessage(duplicate.id) ?? duplicate, created: false };
569
677
  }
570
678
  }
@@ -579,12 +687,11 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
579
687
  replyExpected: false,
580
688
  idempotencyKey,
581
689
  payload: stream === "responses"
582
- ? { responseCapture: { ...session } }
690
+ ? { responseCapture: { ...session }, ...(finality ? { finality } : {}) }
583
691
  : { childStreamCapture: { stream, ...session } },
584
692
  occurredAt: message.occurredAt ?? message.createdAt,
585
693
  });
586
694
  if (stream === "responses" && result.created) {
587
- clearDeferredLineageReport(child.id);
588
695
  stampReportUpLifecycleSignal(parent, child);
589
696
  }
590
697
  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
  }
@@ -2,7 +2,7 @@
2
2
  import { RELAY_TOKEN_HEADER, isRecord, isSpawnProvider } from "agent-relay-sdk";
3
3
  import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeAgentRoute, authorizeRoute, canViewAgentRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, requestUserId, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
4
4
  import { arbitrateDrive, currentDriver, drivingMessage, releaseDrive, type DriveArbitration } from "../agent-drive-arbiter";
5
- import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, readSpawnCallerSystemPromptAppend, resolveAgentOwnership, sendMessage, withResolvedProvisioning } from "../db";
5
+ import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage, withResolvedProvisioning } from "../db";
6
6
  import { announcePresence, viewersForAgent } from "../dashboard-presence";
7
7
  import { buildManagedSpawnParams } from "../managed-policy";
8
8
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
@@ -19,8 +19,8 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../servic
19
19
  import { type AgentCard, type SpawnApprovalMode } from "../types";
20
20
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
21
21
  import { getManifest } from "agent-relay-providers";
22
- import { composeProfileSpawnInstructions } from "../project-instructions";
23
22
  import { relayToken } from "../config";
23
+ import { restartInstructionParamsForAgent } from "../services/restart-instructions";
24
24
 
25
25
  // 409 when a second human tries to drive an agent another user is already driving (#394). The
26
26
  // blocked driver learns who holds the wheel so the UI can show "X is driving" + offer "request".
@@ -133,6 +133,7 @@ export function restartSpawnParamsForAgent(
133
133
  const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
134
134
  const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
135
135
  const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
136
+ const projectedInstructions = restartInstructionParamsForAgent(agent, { spawnRequestId, cwd }) ?? {};
136
137
  // #1052-v2 — a dashboard-spawned agent has NO spawn policy, so its restart skips the
137
138
  // `buildManagedSpawnParams` policy branch above and lands here. Re-project the profile role
138
139
  // charter / project knowledge / scoped memory (the SAME projection a fresh spawn gets) so the
@@ -152,8 +153,6 @@ export function restartSpawnParamsForAgent(
152
153
  // params — the SAME field a fresh agent.spawn stashes it in, see spawn-agent.ts). The reader then
153
154
  // recovers it from either the original agent.spawn row OR a later agent.restart row, so each
154
155
  // generation has its own recoverable copy and the brief follows the agent across every rotation.
155
- const callerAppend = readSpawnCallerSystemPromptAppend(spawnRequestId);
156
- const composed = composeProfileSpawnInstructions({ cwd, operatorId: resolveAgentOwnership(agent.id).ownerUserId, ...(profileName ? { profileName } : {}), ...(agentProfile ? { profile: agentProfile } : {}), ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}) });
157
156
  const params = buildSpawnCommand({
158
157
  provider,
159
158
  modelParams: resolvedModel,
@@ -168,12 +167,12 @@ export function restartSpawnParamsForAgent(
168
167
  approvalMode: approvalMode ?? "guarded",
169
168
  permissionMode: approvalMode ?? "guarded",
170
169
  providerArgs: providerArgs.length ? providerArgs : undefined,
171
- ...(composed.systemPromptAppend ? { systemPromptAppend: composed.systemPromptAppend } : {}),
170
+ ...(typeof projectedInstructions.systemPromptAppend === "string" ? { systemPromptAppend: projectedInstructions.systemPromptAppend } : {}),
172
171
  // #1067 — re-persist the RAW brief (pre-composition) under this restart's fresh spawnRequestId so
173
172
  // the NEXT restart, whose predecessor id is this fresh id, can recover it. Without this the brief
174
173
  // survives only one restart (the original agent.spawn row is keyed on the very first id).
175
- ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}),
176
- ...(composed.relayInjectionEvents.length ? { relayInjectionEvents: composed.relayInjectionEvents } : {}),
174
+ ...(typeof projectedInstructions.callerSystemPromptAppend === "string" ? { callerSystemPromptAppend: projectedInstructions.callerSystemPromptAppend } : {}),
175
+ ...(Array.isArray(projectedInstructions.relayInjectionEvents) ? { relayInjectionEvents: projectedInstructions.relayInjectionEvents } : {}),
177
176
  policyName: policyName || undefined,
178
177
  headless: true,
179
178
  spawnRequestId: requestId,
@@ -298,6 +297,9 @@ export const postAgentAction: Handler = async (req, params) => {
298
297
  const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
299
298
  const metaSpawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
300
299
  const resumeId = action === "resume" ? latestResumeIdForAgent(agent) : undefined;
300
+ const restartInstructions = action === "restart" || action === "resume"
301
+ ? restartInstructionParamsForAgent(agent, { spawnRequestId: metaSpawnRequestId, cwd: metaString(agent.meta, "cwd") ?? orchestrator?.baseDir })
302
+ : undefined;
301
303
  if (action === "resume" && !orchestrator) return error("no online orchestrator available to resume agent", 409);
302
304
  if (action === "resume" && !resumeId) return error("no resume id recorded for agent", 422);
303
305
  const denied = authorizeRoute(req, {
@@ -319,6 +321,7 @@ export const postAgentAction: Handler = async (req, params) => {
319
321
  ...(metaPolicyName ? { policyName: metaPolicyName } : {}),
320
322
  ...(metaSpawnRequestId ? { spawnRequestId: metaSpawnRequestId } : {}),
321
323
  ...(action === "restart" || action === "resume" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId, { resumeId }) } : {}),
324
+ ...(restartInstructions ?? {}),
322
325
  ...(action === "compact" ? compactMemoryParams(agent) : {}),
323
326
  ...(resumeId ? { claudeResumeId: resumeId } : {}),
324
327
  requestedBy: "dashboard",
@@ -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;
@@ -4,7 +4,7 @@ import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, par
4
4
  import { notifyAgentOffline } from "../agent-lifecycle-events";
5
5
  import { diagnoseAgentDeath } from "../agent-death-diagnosis";
6
6
  import { VERSION } from "../config";
7
- import { ValidationError, deleteOrchestrator, getOrchestrator, listOrchestrators, orchestratorHeartbeat, recordAgentExitDiagnostics, setOrchestratorUpgradeState, updateManagedAgents, upsertOrchestrator } from "../db";
7
+ import { ValidationError, deleteOrchestrator, getAgent, getOrchestrator, listOrchestrators, orchestratorHeartbeat, recordAgentExitDiagnostics, setOrchestratorUpgradeState, updateManagedAgents, upsertOrchestrator } from "../db";
8
8
  import { auditEvent, authAuditMetadata, authorizeRoute, cleanJsonArray, cleanSafeNumber, dashboardAttribution, emitCommand, error, isRootCredentialRequest, json, parseBody, spawnRequestId, type Handler } from "./_shared";
9
9
  import { cleanMeta, cleanString, cleanStringArray, cleanWorkspaceMetadata, optionalEnum } from "../validation";
10
10
  import { createCommand, updateCommand } from "../commands-db";
@@ -13,6 +13,7 @@ import { getLifecycleManager } from "../lifecycle-manager";
13
13
  import { issueOrchestratorRuntimeToken } from "../runtime-tokens";
14
14
  import { isOrchestratorRegistrationAuthorized } from "../security";
15
15
  import { type ManagedAgent, type ManagedSessionExitDiagnostics, type OrchestratorRuntimeInput, type RegisterOrchestratorInput, type SpawnApprovalMode, type SpawnProvider } from "../types";
16
+ import { restartSpawnParamsForAgent } from "./agent-sessions";
16
17
 
17
18
  function cleanProtocolVersion(value: unknown): number | undefined {
18
19
  if (value === undefined) return undefined;
@@ -533,13 +534,15 @@ export const postOrchestratorAction: Handler = async (req, params) => {
533
534
  resource: { orchestratorId: orch.id, agentId, policyName, spawnRequestId },
534
535
  });
535
536
  if (denied) return denied;
537
+ const agent = action === "restart" && agentId ? getAgent(agentId) : undefined;
538
+ const restartSpawn = agent ? restartSpawnParamsForAgent(agent, orch, policyName, spawnRequestId) : undefined;
536
539
 
537
540
  const command = createCommand({
538
541
  type: action === "restart" ? "agent.restart" : "agent.shutdown",
539
542
  source: "system",
540
543
  target: orch.agentId,
541
544
  correlationId: spawnRequestId,
542
- params: { action, agentId, policyName, spawnRequestId, sessionName, tmuxSession, reason, requestedBy: "dashboard", requestedAt: Date.now(), orchestratorId: orch.id },
545
+ params: { action, agentId, policyName, spawnRequestId, sessionName, tmuxSession, reason, ...(restartSpawn ? { restartSpawn } : {}), requestedBy: "dashboard", requestedAt: Date.now(), orchestratorId: orch.id },
543
546
  });
544
547
  emitCommand(command);
545
548
  auditEvent({
@@ -10,7 +10,7 @@ import { isRequestAuthorizedFor } from "../security";
10
10
  import { cleanString } from "../validation";
11
11
  import { emitConfigChanged } from "../sse";
12
12
  import { error, json, normalizeConfigPathParam, parseBody, requestUserId, type Handler } from "./_shared";
13
- import { DEFAULT_USER_ID, isRecord, type ConfigEntry, type SettingDescriptor, type SettingEntry } from "agent-relay-sdk";
13
+ import { DEFAULT_USER_ID, isRecord, type ConfigEntry, type JSONSchema, type SettingDescriptor, type SettingEntry } from "agent-relay-sdk";
14
14
 
15
15
  type SettingEnvelope = SettingEntry;
16
16
 
@@ -91,7 +91,10 @@ async function putSetting(req: Request, namespaceRaw: string | undefined, keyRaw
91
91
  if (!validation.valid) return json({ error: "invalid setting value", details: validation.errors }, 400);
92
92
  if (descriptor.tier === "user") {
93
93
  const userId = requestUserId(req);
94
- const userEntry = setUserSetting(userId, namespace, key, parsed.body.value, updatedBy);
94
+ const valueToStore = descriptor.mergeMapKeys && isRecord(parsed.body.value)
95
+ ? mergeMapKeysIntoExisting(getUserSetting(userId, namespace, key)?.value, parsed.body.value, descriptor.schema)
96
+ : parsed.body.value;
97
+ const userEntry = setUserSetting(userId, namespace, key, valueToStore, updatedBy);
95
98
  emitConfigChanged(userEntry.namespace, userEntry.key, userEntry.version);
96
99
  return json(settingEnvelope(req, descriptor, userEntry), userEntry.version === 1 ? 201 : 200);
97
100
  }
@@ -156,3 +159,29 @@ function mergeSettingDefaults(defaults: unknown, value: unknown): unknown {
156
159
  }
157
160
  return merged;
158
161
  }
162
+
163
+ // A `mergeMapKeys` descriptor (#1124) is a dictionary keyed by caller-chosen ids (e.g. one row
164
+ // per conversation). A PUT only ever knows its own device's changed keys, never every other
165
+ // device's, so overwriting the whole stored value like a normal setting would erase them — this
166
+ // merges just the incoming keys on top of whatever is already stored instead. The merged result
167
+ // is then capped at the schema's `maxProperties` (when set) so per-key merges from unboundedly
168
+ // many devices can't grow the row forever; the entry with the OLDEST `updatedAt` is evicted first
169
+ // (missing/non-numeric `updatedAt`, e.g. a pre-#1124 row, sorts as oldest: 0).
170
+ function mergeMapKeysIntoExisting(existing: unknown, incoming: Record<string, unknown>, schema: JSONSchema): Record<string, unknown> {
171
+ const merged: Record<string, unknown> = isRecord(existing) ? { ...existing } : {};
172
+ for (const [key, item] of Object.entries(incoming)) merged[key] = item;
173
+
174
+ const cap = typeof schema.maxProperties === "number" ? schema.maxProperties : undefined;
175
+ const entries = Object.entries(merged);
176
+ if (cap !== undefined && entries.length > cap) {
177
+ entries.sort((a, b) => entryUpdatedAt(a[1]) - entryUpdatedAt(b[1]));
178
+ const overflow = entries.length - cap;
179
+ for (let i = 0; i < overflow; i += 1) delete merged[entries[i]![0]];
180
+ }
181
+ return merged;
182
+ }
183
+
184
+ function entryUpdatedAt(value: unknown): number {
185
+ if (isRecord(value) && typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt)) return value.updatedAt;
186
+ return 0;
187
+ }
@@ -0,0 +1,36 @@
1
+ import { isSpawnProvider } from "agent-relay-sdk";
2
+ import { getAgentProfile } from "../config-store";
3
+ import { readSpawnCallerSystemPromptAppend, resolveAgentOwnership, withResolvedProvisioning } from "../db";
4
+ import { composeProfileSpawnInstructions } from "../project-instructions";
5
+ import { applyDefaultSharedMcp } from "../shared-mcp";
6
+ import type { AgentCard } from "../types";
7
+
8
+ function metaString(meta: Record<string, unknown> | undefined, key: string): string | undefined {
9
+ const value = meta?.[key];
10
+ return typeof value === "string" ? value : undefined;
11
+ }
12
+
13
+ export function restartInstructionParamsForAgent(
14
+ agent: AgentCard,
15
+ opts: { spawnRequestId?: string; cwd?: string } = {},
16
+ ): Record<string, unknown> | undefined {
17
+ const provider = metaString(agent.meta, "provider");
18
+ if (!isSpawnProvider(provider)) return undefined;
19
+ const cwd = opts.cwd ?? metaString(agent.meta, "cwd");
20
+ const profileName = metaString(agent.meta, "profile");
21
+ const resolvedProfile = withResolvedProvisioning(profileName ? getAgentProfile(profileName)?.value : undefined, provider);
22
+ const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
23
+ const callerAppend = readSpawnCallerSystemPromptAppend(opts.spawnRequestId);
24
+ const composed = composeProfileSpawnInstructions({
25
+ ...(cwd ? { cwd } : {}),
26
+ operatorId: resolveAgentOwnership(agent.id).ownerUserId,
27
+ ...(profileName ? { profileName } : {}),
28
+ ...(agentProfile ? { profile: agentProfile } : {}),
29
+ ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}),
30
+ });
31
+ return {
32
+ ...(composed.systemPromptAppend ? { systemPromptAppend: composed.systemPromptAppend } : {}),
33
+ ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}),
34
+ ...(composed.relayInjectionEvents.length ? { relayInjectionEvents: composed.relayInjectionEvents } : {}),
35
+ };
36
+ }
@@ -24,7 +24,6 @@
24
24
  import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId, isTurnFinalSession } from "agent-relay-sdk";
25
25
  import {
26
26
  ValidationError,
27
- flushDeferredLineageReport,
28
27
  getAgent,
29
28
  getMessage,
30
29
  latestInboundMessage,
@@ -228,28 +227,18 @@ function prepareSendMessage(input: SendMessageInput, ctx: AuthContext, opts: Sen
228
227
  return { receipt, suppressedParent: suppressResultsOnlyAck(input) };
229
228
  }
230
229
 
231
- /**
232
- * #1037 — the child transitioned out of busy (idle/awaiting-input). Deliver its deferred turn-final
233
- * report-up (a quiet child's last turn, held back so mid-work narration never reached the spawner)
234
- * and fan it out over SSE. No-op when nothing was deferred. Driven off the bus turn-end edge.
235
- */
236
- export function flushIdleLineageReport(childId: string): void {
237
- const promoted = flushDeferredLineageReport(childId);
238
- if (promoted?.created) emitNewMessages([promoted.message]);
239
- }
240
-
241
230
  /** How far back the startup sweep re-scans for held-but-lost turn-final captures (#1040). A report
242
- * is only ever deferred while its child is mid-turn, so any capture lost across a restart is recent;
231
+ * is only ever held while its work window is open, so any capture lost across a restart is recent;
243
232
  * a day is a generous ceiling that still bounds the scan. */
244
233
  const LINEAGE_STARTUP_SWEEP_WINDOW_MS = 24 * 60 * 60 * 1000;
245
234
 
246
235
  /**
247
- * #1040 — startup re-promotion sweep. `deferredLineageReports` is an in-memory Map, so a relay
248
- * restart drops every held turn-final report-up. The captures are persisted, though, so re-run
249
- * promotion for recent final response captures: an idle child whose report was lost gets it
250
- * delivered now, an already-delivered one is a no-op (idempotency key), and a still-busy child
251
- * re-defers (re-arming the in-memory hold so a later idle edge flushes it). Idempotent and safe to
252
- * run periodically.
236
+ * #1040/#1084 — startup re-promotion sweep. The held-turn-final group (db/messages.ts) is an
237
+ * in-memory Map, so a relay restart drops it. The captures are persisted, though, so re-run
238
+ * promotion for recent final response captures: this just REBUILDS the held group (an
239
+ * already-forwarded capture is a no-op via its idempotency key; a `verbose` child's capture is
240
+ * delivered immediately, as it always is). Delivery to the spawner still only ever happens via an
241
+ * explicit work-finality signal (markAgentWorkFinal) — this sweep never flushes on its own.
253
242
  */
254
243
  export function sweepDeferredLineageReportsOnStartup(now: number = Date.now()): { promoted: number; scanned: number } {
255
244
  const captures = listRecentFinalResponseCaptures(now - LINEAGE_STARTUP_SWEEP_WINDOW_MS);
@@ -37,6 +37,7 @@ import { createCommand } from "../commands-db";
37
37
  import { emitCommandEvent } from "../command-events";
38
38
  import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
39
39
  import { emitActivityEvent, emitAgentStatus } from "../sse";
40
+ import { markAgentWorkFinal } from "./work-finality";
40
41
  import { stringValue } from "agent-relay-sdk";
41
42
  import type { AuthContext } from "./auth-context";
42
43
  import type { AgentCard, Command } from "../types";
@@ -211,6 +212,11 @@ export function shutdownAgent(input: ShutdownAgentInput, ctx: AuthContext): Shut
211
212
  },
212
213
  });
213
214
  emitCommandEvent(command, "command.requested");
215
+ // #1084 — an explicit shutdown is a platform-owned teardown signal: close the target's work
216
+ // window now rather than let a held final group die silently with the process. `edge-fallback`
217
+ // is the closest fit in the closed source enum — there is no cleaner workspace-ready/task-done
218
+ // signal backing an arbitrary teardown (which may target an agent with neither).
219
+ if (targetAgentId) markAgentWorkFinal(targetAgentId, "edge-fallback");
214
220
 
215
221
  // Lifecycle indicator — uniform across transports. The MCP path previously skipped
216
222
  // this, so an MCP-initiated shutdown left the agent with no "shutting down" signal in
@@ -44,6 +44,7 @@ import { emitRelayEvent } from "../events";
44
44
  import { routeNotification } from "../notification-router";
45
45
  import { isAgentExitDoneWhen, stageSatisfiesDoneWhen } from "./task-done-when";
46
46
  import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
47
+ import { markAgentWorkFinal } from "./work-finality";
47
48
 
48
49
  type CreateTaskEntityInput = CreateDeliverableTaskInput & {
49
50
  issueRefs?: string[];
@@ -140,6 +141,9 @@ export function setTaskEntityStatus(id: number, input: SetTaskStatusInput): Task
140
141
  });
141
142
  }
142
143
  if (isTerminalTaskStatus(detail.status)) removeSubscriptionsForTask(detail.id);
144
+ // #1084 — a task reaching `done` is an explicit, platform-owned completion signal for its
145
+ // owning agent's work window: close it, forwarding only the held final group.
146
+ if (before?.status !== "done" && detail.status === "done") markAgentWorkFinal(detail.ownerAgentId, "task-done");
143
147
  return detail;
144
148
  }
145
149