agent-relay-server 0.121.5 → 0.122.1

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 (179) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +6 -6
  3. package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
  4. package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
  7. package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
  8. package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
  10. package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
  12. package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
  13. package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
  14. package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
  15. package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
  16. package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
  17. package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
  18. package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
  20. package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
  21. package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
  22. package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
  23. package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
  24. package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
  25. package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
  26. package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
  27. package/public/assets/display-CJzSoTMq.js +3 -0
  28. package/public/assets/display-CJzSoTMq.js.map +1 -0
  29. package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
  30. package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
  32. package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
  33. package/public/assets/index-D0xMQQb_.js +25 -0
  34. package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
  35. package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
  36. package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
  37. package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
  38. package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
  39. package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
  40. package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
  41. package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
  42. package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
  43. package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
  44. package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
  45. package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
  46. package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
  47. package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
  48. package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
  49. package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
  50. package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
  51. package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
  52. package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
  53. package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
  54. package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
  55. package/public/assets/projects-Dzdf-gkK.js +2 -0
  56. package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
  57. package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
  58. package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
  59. package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
  60. package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
  61. package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
  62. package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
  63. package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
  64. package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
  65. package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
  66. package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
  67. package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
  68. package/public/assets/store-BPrVuKiv.js.map +1 -0
  69. package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
  70. package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
  71. package/public/assets/teams-DWv6UwFV.js +2 -0
  72. package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
  73. package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
  74. package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
  75. package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
  76. package/public/assets/types-BeJUSfDj.js.map +1 -0
  77. package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
  78. package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
  79. package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
  80. package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
  81. package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
  82. package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
  83. package/public/index.html +6 -6
  84. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  85. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
  86. package/runner/src/adapter.ts +53 -18
  87. package/runner/src/adapters/claude-delivery.ts +127 -0
  88. package/runner/src/adapters/claude-quota-harvest.ts +92 -0
  89. package/runner/src/adapters/claude-session-probe.ts +225 -0
  90. package/runner/src/adapters/claude-status-detectors.ts +147 -0
  91. package/runner/src/adapters/claude-transcript-tail.ts +128 -0
  92. package/runner/src/adapters/claude-transcript.ts +414 -0
  93. package/runner/src/adapters/claude.ts +672 -0
  94. package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
  95. package/runner/src/adapters/codex-client.ts +279 -0
  96. package/runner/src/adapters/codex.ts +1345 -0
  97. package/runner/src/attachment-cache.ts +189 -0
  98. package/runner/src/busy-reconciler.ts +102 -0
  99. package/runner/src/claim-tracker.ts +140 -0
  100. package/runner/src/claude-prompt-gates.ts +268 -0
  101. package/runner/src/config.ts +1 -4
  102. package/runner/src/context-probe-state.ts +6 -0
  103. package/runner/src/continuation-archive.ts +179 -0
  104. package/runner/src/control-server.ts +639 -0
  105. package/runner/src/index.ts +396 -0
  106. package/runner/src/launch-assembly.ts +508 -0
  107. package/runner/src/liveness.ts +50 -0
  108. package/runner/src/logger.ts +99 -0
  109. package/runner/src/mcp-outbox.ts +120 -0
  110. package/runner/src/message-body-config/config.ts +3 -0
  111. package/runner/src/message-body-config.ts +1 -0
  112. package/runner/src/native-self-resume.ts +202 -0
  113. package/runner/src/outbox.ts +342 -0
  114. package/runner/src/process-meta.ts +9 -0
  115. package/runner/src/profile-home.ts +260 -0
  116. package/runner/src/profile-projection.ts +305 -0
  117. package/runner/src/providers.ts +20 -0
  118. package/runner/src/provisioning.ts +314 -0
  119. package/runner/src/quota.ts +40 -0
  120. package/runner/src/rate-limit.ts +189 -0
  121. package/runner/src/relay-injection-events.ts +102 -0
  122. package/runner/src/relay-instructions.ts +60 -0
  123. package/runner/src/relay-mcp-proxy.ts +383 -0
  124. package/runner/src/relay-mcp.ts +156 -0
  125. package/runner/src/reply-obligation-cache.ts +136 -0
  126. package/runner/src/response-capture-report.ts +63 -0
  127. package/runner/src/runner-core.ts +2409 -0
  128. package/runner/src/runner-helpers.ts +435 -0
  129. package/runner/src/runner-insights.ts +105 -0
  130. package/runner/src/runner.ts +14 -0
  131. package/runner/src/session-destroy.ts +22 -0
  132. package/runner/src/session-insights.ts +118 -0
  133. package/runner/src/session-scratch.ts +271 -0
  134. package/runner/src/template-resolver.ts +29 -0
  135. package/runner/src/version.ts +43 -0
  136. package/src/agent-card-projection.ts +5 -1
  137. package/src/agent-command-activity.ts +39 -0
  138. package/src/automations/reconcile.ts +2 -2
  139. package/src/cli/workspace.ts +3 -1
  140. package/src/config.ts +10 -1
  141. package/src/db/agents.ts +118 -37
  142. package/src/db/message-reads.ts +50 -0
  143. package/src/db/messages.ts +48 -9
  144. package/src/db/migrations.ts +54 -0
  145. package/src/db/provider-quota-burn.ts +96 -67
  146. package/src/db/provider-quotas.ts +29 -7
  147. package/src/db/scheduled-timers.ts +21 -0
  148. package/src/db/schema.ts +1 -0
  149. package/src/db/tasks.ts +33 -3
  150. package/src/db/work-evidence.ts +10 -7
  151. package/src/event-loop-lag.ts +15 -0
  152. package/src/gate-resolver.ts +19 -1
  153. package/src/maintenance/jobs.ts +23 -6
  154. package/src/managed-policy.ts +5 -12
  155. package/src/mcp-plan-tools.ts +13 -1
  156. package/src/project-instructions.ts +36 -2
  157. package/src/quota-advisory.ts +6 -4
  158. package/src/quota-band-transition.ts +23 -14
  159. package/src/routes/agent-sessions.ts +39 -7
  160. package/src/routes/agents.ts +3 -2
  161. package/src/routes/tasks.ts +16 -4
  162. package/src/routes/workspaces.ts +35 -10
  163. package/src/services/plan-graph.ts +27 -9
  164. package/src/services/register-agent.ts +12 -12
  165. package/src/services/send-message.ts +43 -15
  166. package/src/services/spawn-agent.ts +3 -0
  167. package/src/spawn-command.ts +8 -0
  168. package/src/spawn-targets.ts +19 -34
  169. package/src/sse.ts +25 -3
  170. package/src/workspace-merge.ts +25 -5
  171. package/src/workspace-orphans.ts +13 -6
  172. package/public/assets/display-exQDWum3.js +0 -3
  173. package/public/assets/display-exQDWum3.js.map +0 -1
  174. package/public/assets/index-D9OogaB5.js +0 -25
  175. package/public/assets/projects-DdPNPFJI.js +0 -2
  176. package/public/assets/prompt-settings-BpZse7-X.js +0 -2
  177. package/public/assets/store-DKTLubBT.js.map +0 -1
  178. package/public/assets/teams-B8f2pGJe.js +0 -2
  179. package/public/assets/types-uVOXgvMT.js.map +0 -1
package/src/db/agents.ts CHANGED
@@ -145,7 +145,7 @@ function tagsWithProvider(input: RegisterAgentInput): string[] {
145
145
  }
146
146
 
147
147
 
148
- export function upsertAgent(input: RegisterAgentInput): AgentCard {
148
+ export function upsertAgent(input: RegisterAgentInput, opts?: { verifiedKeepJtis?: string[] }): AgentCard {
149
149
  const now = Date.now();
150
150
  const kind = inferAgentKind(input);
151
151
  if (kind === "channel") {
@@ -190,35 +190,48 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
190
190
  last_seen = $now
191
191
  `);
192
192
 
193
- stmt.run({
194
- $id: input.id,
195
- $name: input.name,
196
- $kind: kind,
197
- $label: input.label ?? null,
198
- $labelProvided: labelProvided ? 1 : 0,
199
- $tags: JSON.stringify(tags),
200
- $machine: input.machine ?? null,
201
- $rig: input.rig ?? null,
202
- $capabilities: JSON.stringify(input.capabilities ?? []),
203
- $ready: input.ready ? 1 : 0,
204
- $readyProvided: readyProvided ? 1 : 0,
205
- $status: input.status ?? "idle",
206
- $instanceId: input.instanceId ?? null,
207
- $instanceProvided: instanceProvided ? 1 : 0,
208
- $initialEpoch: instanceProvided ? 1 : 0,
209
- $providerCapabilities: input.providerCapabilities ? JSON.stringify(input.providerCapabilities) : null,
210
- $contextState: contextState ? JSON.stringify(contextState) : null,
211
- $quotaState: input.quota ? JSON.stringify(input.quota) : null,
212
- $meta: JSON.stringify(input.meta ?? {}),
213
- $lifecycle: input.lifecycle ?? "persistent",
214
- $lifecycleProvided: lifecycleProvided ? 1 : 0,
215
- $spawnedBy: input.spawnedBy ?? null,
216
- $teamId: input.teamId ?? null,
217
- $now: now,
218
- });
193
+ // #1057(2) — the takeover revoke fires on the SAME predicate as the epoch bump, by observing
194
+ // the bump itself (prior.epoch !== next.epoch) rather than re-deriving the SQL's instance-
195
+ // transition condition. The old guard in registerAgent required a truthy prior instanceId, so a
196
+ // NULL→instanceId takeover bumped the epoch yet skipped the revoke, stranding the prior token
197
+ // until the 24h prune. #1057(4) — write + revoke share one transaction: a revoke failure rolls
198
+ // the epoch bump back, so registration fails loudly with no committed bump / un-revoked token.
199
+ const agent = getDb().transaction(() => {
200
+ const prior = getAgent(input.id);
201
+ stmt.run({
202
+ $id: input.id,
203
+ $name: input.name,
204
+ $kind: kind,
205
+ $label: input.label ?? null,
206
+ $labelProvided: labelProvided ? 1 : 0,
207
+ $tags: JSON.stringify(tags),
208
+ $machine: input.machine ?? null,
209
+ $rig: input.rig ?? null,
210
+ $capabilities: JSON.stringify(input.capabilities ?? []),
211
+ $ready: input.ready ? 1 : 0,
212
+ $readyProvided: readyProvided ? 1 : 0,
213
+ $status: input.status ?? "idle",
214
+ $instanceId: input.instanceId ?? null,
215
+ $instanceProvided: instanceProvided ? 1 : 0,
216
+ $initialEpoch: instanceProvided ? 1 : 0,
217
+ $providerCapabilities: input.providerCapabilities ? JSON.stringify(input.providerCapabilities) : null,
218
+ $contextState: contextState ? JSON.stringify(contextState) : null,
219
+ $quotaState: input.quota ? JSON.stringify(input.quota) : null,
220
+ $meta: JSON.stringify(input.meta ?? {}),
221
+ $lifecycle: input.lifecycle ?? "persistent",
222
+ $lifecycleProvided: lifecycleProvided ? 1 : 0,
223
+ $spawnedBy: input.spawnedBy ?? null,
224
+ $teamId: input.teamId ?? null,
225
+ $now: now,
226
+ });
227
+ const next = getAgent(input.id)!;
228
+ if (prior && next.epoch !== prior.epoch) {
229
+ revokeSupersededRuntimeTokens(prior, opts?.verifiedKeepJtis ?? [], now);
230
+ }
231
+ return next;
232
+ })();
219
233
  if (contextState) recordContextSnapshot(input.id, contextState, now);
220
234
 
221
- const agent = getAgent(input.id)!;
222
235
  seedContinuationObjectiveFromSpawnPrompt(agent.id, stringValue(agent.meta?.spawnRequestId), DEFAULT_SELF_RESUME_CONFIG, now);
223
236
  if (agent.kind === "channel") upsertChannelForAgent(agent);
224
237
  evaluatePoolBindings();
@@ -363,15 +376,49 @@ export function clearAgentMetaKeys(id: string, keys: string[]): boolean {
363
376
  return getDb().query("UPDATE agents SET meta = ? WHERE id = ?").run(JSON.stringify(meta), id).changes > 0;
364
377
  }
365
378
 
379
+ /**
380
+ * #1059 — decide whether a process-exit certificate belongs to an agent row's CURRENT
381
+ * instance. A late/duplicate orchestrator exit report for a SUPERSEDED session (the agent id
382
+ * re-registered as a fresh instance after the report was generated) carries the prior session's
383
+ * `spawnRequestId`; the live successor's meta carries the new one. When both are known and
384
+ * differ, the certificate is for a dead predecessor and must never mark the successor gone.
385
+ * Unknown on either side (legacy / unmanaged sessions that never carried a spawnRequestId) can't
386
+ * be disproven, so it is treated as matching — preserving the pre-#1059 fast-exit path.
387
+ *
388
+ * The single home for the death-certificate/instance identity check, shared by the stamp site
389
+ * (recordAgentExitDiagnostics, below) and the reaper's death predicate (isOwnerProcessConfirmedGone
390
+ * in workspace-merge.ts) so the two can't drift.
391
+ */
392
+ export function exitCertificateMatchesInstance(
393
+ currentMeta: Record<string, unknown> | undefined,
394
+ certificate: { spawnRequestId?: unknown } | undefined,
395
+ ): boolean {
396
+ const current = currentMeta ? stringValue(currentMeta.spawnRequestId) : undefined;
397
+ const cert = certificate ? stringValue(certificate.spawnRequestId) : undefined;
398
+ if (current && cert && current !== cert) return false;
399
+ return true;
400
+ }
401
+
366
402
  export function recordAgentExitDiagnostics(id: string, diagnostics: ManagedSessionExitDiagnostics): AgentCard | null {
367
403
  const agent = getAgent(id);
368
404
  if (!agent) return null;
405
+ // #1059 — drop a stale/duplicate exit report for a SUPERSEDED instance. The agent id
406
+ // re-registered as a fresh session (new spawnRequestId) after this report was generated;
407
+ // stamping its predecessor's certificate here would poison the LIVE successor's row (false
408
+ // offline flip + false death notification via the caller + a certificate the dead-owner reaper
409
+ // would later act on — the #1054 reopener). Returning null makes the caller skip both the status
410
+ // emit and notifyAgentOffline, exactly as for an absent row.
411
+ if (!exitCertificateMatchesInstance(isRecord(agent.meta) ? agent.meta : undefined, diagnostics)) return null;
369
412
  const now = Date.now();
370
413
  const merged = {
371
414
  ...(agent.meta ?? {}),
372
415
  lastError: diagnostics.lastError,
373
416
  lastExit: diagnostics,
374
417
  lastExitAt: diagnostics.detectedAt || now,
418
+ // #1057(1) — RELAY-clock receipt of the process-confirmed death. The orphan-token sweep
419
+ // (revokeOrphanedRuntimeTokens) compares this against last_seen (also relay clock); the
420
+ // orchestrator-clock detectedAt in lastExitAt is not comparable across hosts.
421
+ lastExitReceivedAt: now,
375
422
  };
376
423
  // #636 — do NOT clobber last_seen with `now`: it is the agent's genuine last heartbeat, and the
377
424
  // death diagnosis derives the real heartbeat gap from it (the orchestrator exit path diagnoses
@@ -585,6 +632,35 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
585
632
  return deleted ? { ok: true } : { ok: false, error: "agent not found" };
586
633
  }
587
634
 
635
+ // #1057(1) — close the orphan-token window #1051 left open. A dead runner with no replacement
636
+ // used to keep its token valid until the 24h offline prune (~720× the pre-#1051 ~2min horizon).
637
+ // This sweep revokes ("stale", re-mintable — #484) the runtime tokens of agents whose process the
638
+ // orchestrator CONFIRMED dead (meta.lastExit, the #1054 signal — never a liveness inference, so
639
+ // the #1051 decouple holds) once a full grace has passed with no heartbeat since the relay
640
+ // received the report. The two guards make a late exit report harmless to a live replacement:
641
+ // - a replacement registration replaces meta wholesale (no lastExitReceivedAt) → excluded;
642
+ // - a replacement that got the late report stamped ONTO its row keeps heartbeating, pushing
643
+ // last_seen past the receipt (heartbeat also flips it off 'offline') → excluded.
644
+ // `lagGraceMs` mirrors the stale reaper's #1048/#1055 discipline: while the relay's own event
645
+ // loop was stalled, a live claimant's heartbeats may not have been processed yet, so the window
646
+ // widens by the observed lag rather than misreading the backlog as post-mortem silence.
647
+ export function revokeOrphanedRuntimeTokens(graceMs: number = STALE_TTL_MS, lagGraceMs = 0, now = Date.now()): Array<{ agentId: string; jtis: string[] }> {
648
+ const cutoff = now - graceMs - Math.max(0, lagGraceMs);
649
+ const rows = getDb()
650
+ .query("SELECT id, meta, last_seen FROM agents WHERE status = 'offline' AND meta LIKE '%lastExitReceivedAt%' AND id NOT IN ('user', 'system')")
651
+ .all() as Array<{ id: string; meta: string | null; last_seen: number }>;
652
+ const revoked: Array<{ agentId: string; jtis: string[] }> = [];
653
+ for (const row of rows) {
654
+ const meta = parseJson<Record<string, unknown>>(row.meta ?? "{}", {});
655
+ const receivedAt = typeof meta.lastExitReceivedAt === "number" ? meta.lastExitReceivedAt : undefined;
656
+ if (receivedAt === undefined || receivedAt > cutoff) continue;
657
+ if (row.last_seen > receivedAt) continue;
658
+ const jtis = revokeProviderRuntimeTokensByJti(runtimeTokenJtisFromMeta(meta), "stale", now);
659
+ if (jtis.length > 0) revoked.push({ agentId: row.id, jtis });
660
+ }
661
+ return revoked;
662
+ }
663
+
588
664
  // #484: `reason` is stamped on each revocation so a stale-on-restart revocation
589
665
  // (recoverable via orchestrator re-mint for a still-live process) is distinguishable
590
666
  // from a hard kill. Default "agent-removed" (hard) — callers that revoke a possibly-
@@ -598,17 +674,22 @@ export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now(), r
598
674
  // #1051 — the #484 restart-strand invariant, re-homed from heartbeat-lapse to instance
599
675
  // takeover. Staleness (a missed heartbeat) no longer revokes anything; instead, when a
600
676
  // FRESH runner process re-registers under an existing agent id — a new instanceId, which
601
- // bumps the agent epoch (see upsertAgent) — the PRIOR instance's runtime tokens are
602
- // revoked "stale" here. "stale" (not a hard kill) keeps them re-mintable via the
603
- // orchestrator (#484), so a live process that merely rotated instances still self-heals,
604
- // while an orphaned prior-instance token can no longer authenticate a request directly.
605
- // The incoming instance's own token (already stamped into the NEW meta) is preserved: a
606
- // same-instance reconnect (relay-restart / lag recovery) keeps its instanceId, does not
607
- // bump the epoch, and so never reaches this path — its token stays valid untouched.
608
- export function revokeSupersededRuntimeTokens(prior: AgentCard, current: AgentCard, now = Date.now()): string[] {
677
+ // bumps the agent epoch — the PRIOR instance's runtime tokens are revoked "stale" here,
678
+ // atomically with the bump (fired from inside upsertAgent). "stale" (not a hard kill)
679
+ // keeps them re-mintable via the orchestrator (#484), so a live process that merely
680
+ // rotated instances still self-heals, while an orphaned prior-instance token can no
681
+ // longer authenticate a request directly. A same-instance reconnect (relay-restart / lag
682
+ // recovery) keeps its instanceId, does not bump the epoch, and so never reaches this
683
+ // path — its token stays valid untouched.
684
+ // #1057(3) `keepJtis` must be SERVER-verified (the jti of the token that authenticated the
685
+ // registration, resolved to this very agent — see registerAgent). It is never derived from the
686
+ // incoming client meta: a takeover caller could otherwise list the prior jti in meta.auth.jtis
687
+ // and spare it from revocation. The incoming instance's own token needs no keep entry — only
688
+ // PRIOR jtis are ever revoked, so a fresh token is structurally never collateral.
689
+ export function revokeSupersededRuntimeTokens(prior: AgentCard, keepJtis: string[] = [], now = Date.now()): string[] {
609
690
  const priorJtis = runtimeTokenJtisFromMeta((prior.meta ?? {}) as Record<string, unknown>);
610
691
  if (priorJtis.length === 0) return [];
611
- const keep = new Set(runtimeTokenJtisFromMeta((current.meta ?? {}) as Record<string, unknown>));
692
+ const keep = new Set(keepJtis);
612
693
  const superseded = priorJtis.filter((jti) => !keep.has(jti));
613
694
  return revokeProviderRuntimeTokensByJti(superseded, "stale", now);
614
695
  }
@@ -523,6 +523,19 @@ export function listPendingReplyObligations(agentId: string, limit = 20): ReplyO
523
523
  *
524
524
  * Returns the inbound message id this outbound answers, or null when nothing should be auto-linked.
525
525
  */
526
+ /**
527
+ * #976 — the most recent message `fromId` addressed to `recipientId` (direct target OR resolved
528
+ * recipient). Used to anchor a suppressed standalone self→parent ack onto the conversation it
529
+ * belongs to, so a bare ack collapses onto the inbound it answers — mirroring the reply-path
530
+ * collapse (which returns the parent message being acked). Returns null when no such inbound exists.
531
+ */
532
+ export function latestInboundMessage(recipientId: string, fromId: string): Message | null {
533
+ const sql = `${MSG_SELECT} WHERE m.from_agent = ? AND (m.to_target = ? OR m.resolved_to_agent = ?) ORDER BY m.id DESC LIMIT 1`;
534
+ const rows = timedQuery("latestInboundMessage", () =>
535
+ getDb().query(sql).all(fromId, recipientId, recipientId) as any[]);
536
+ return rows.length ? rowToMessage(rows[0]) : null;
537
+ }
538
+
526
539
  export function channelObligationAnsweredBy(
527
540
  agentId: string,
528
541
  channelAgentId: string,
@@ -624,6 +637,43 @@ export function readSpawnReportVerbosity(spawnRequestId: string | undefined): Re
624
637
  }
625
638
  }
626
639
 
640
+ /**
641
+ * #1062 — the RAW caller/parent brief a spawner supplied, read from the persisted `agent.spawn`
642
+ * command params by spawnRequestId. Mirrors {@link readSpawnReportVerbosity}: the dashboard
643
+ * (non-policy) restart path recovers this and re-threads it as `callerSystemPromptAppend` so a
644
+ * restarted spawned child keeps its parent's task brief while the rest of the projection
645
+ * (project/profile/scoped memory) is re-composed fresh. Returns undefined when absent/unparseable
646
+ * (pre-#1062 spawns never persisted it → the restart simply re-projects without a caller brief).
647
+ *
648
+ * #1067 — recovers across ANY number of dashboard restarts, not just one. #1065 rotates the
649
+ * spawnRequestId on every restart and never writes an agent.spawn row for the successor, so keying
650
+ * only on the original agent.spawn row lost the brief from the 2nd restart onward (the predecessor
651
+ * id was then the fresh id, which has no spawn row). The dashboard restart path re-persists the raw
652
+ * brief under each fresh id on the restart command's `restartSpawn.callerSystemPromptAppend`
653
+ * (agent-sessions.ts), so this lookup also matches an `agent.restart` row whose rotated id equals
654
+ * the query id — every generation has its own recoverable copy and the brief follows the agent.
655
+ */
656
+ export function readSpawnCallerSystemPromptAppend(spawnRequestId: string | undefined): string | undefined {
657
+ if (!spawnRequestId) return undefined;
658
+ try {
659
+ const row = getDb().query(`
660
+ SELECT coalesce(
661
+ json_extract(params, '$.callerSystemPromptAppend'),
662
+ json_extract(params, '$.restartSpawn.callerSystemPromptAppend')
663
+ ) AS append
664
+ FROM commands
665
+ WHERE (type = 'agent.spawn' AND correlation_id = ?)
666
+ OR (type = 'agent.restart' AND json_extract(params, '$.restartSpawn.spawnRequestId') = ?)
667
+ ORDER BY created_at DESC
668
+ LIMIT 1
669
+ `).get(spawnRequestId, spawnRequestId) as { append: string | null } | undefined;
670
+ const append = row?.append;
671
+ return typeof append === "string" && append.trim() ? append : undefined;
672
+ } catch {
673
+ return undefined;
674
+ }
675
+ }
676
+
627
677
  export function markRead(messageId: number, agentId: string): boolean {
628
678
  const exists = getDb().query("SELECT 1 FROM messages WHERE id = ?").get(messageId);
629
679
  if (!exists) return false;
@@ -1,6 +1,15 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
- import { isRecord, stringValue, isMechanicalMessageKind, isReportVerbosity, type ReportVerbosity } from "agent-relay-sdk";
3
+ import {
4
+ isRecord,
5
+ isTurnFinalSession,
6
+ stringValue,
7
+ isMechanicalMessageKind,
8
+ isReportVerbosity,
9
+ type MessagePayload,
10
+ type MessageWorkFinality,
11
+ type ReportVerbosity,
12
+ } from "agent-relay-sdk";
4
13
  import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSignalKey } from "../lifecycle-signal-registry";
5
14
  import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
6
15
  import { parseJson } from "../utils";
@@ -108,6 +117,19 @@ function sessionStepId(payload: Record<string, unknown> | undefined): string | u
108
117
  return stringValue(session?.stepId);
109
118
  }
110
119
 
120
+ function normalizeMessagePayload(payload: MessagePayload | undefined): MessagePayload {
121
+ const original = payload ?? {};
122
+ let next: MessagePayload = original;
123
+ for (const key of ["session", "responseCapture"] as const) {
124
+ const value = next[key];
125
+ if (!isTurnFinalSession(value)) continue;
126
+ if (value.turnFinal === true && value.final === true) continue;
127
+ if (next === original) next = { ...original };
128
+ next[key] = { ...value, turnFinal: true, final: true };
129
+ }
130
+ return next;
131
+ }
132
+
111
133
  /**
112
134
  * Codex session-mirror upsert (#parity): a provider step that emits more than once — a
113
135
  * tool's running→completed lifecycle, a reasoning/response row that streams — carries a
@@ -200,7 +222,7 @@ function responseCaptureMeta(input: Message | SendMessageInput): Record<string,
200
222
  }
201
223
 
202
224
  function isFinalSessionResponse(session: Record<string, unknown>): boolean {
203
- return session.type === "response" && session.final === true;
225
+ return session.type === "response" && isTurnFinalSession(session);
204
226
  }
205
227
 
206
228
  function childStreamForSession(session: Record<string, unknown>): ChildStream | null {
@@ -373,14 +395,19 @@ function childIsBusy(child: AgentCard): boolean {
373
395
  // narration flood by the legacy tail budget, never slicing the block we keep.
374
396
  const REPORT_UP_DISTILL_THRESHOLD = 800;
375
397
  const REPORT_UP_TAIL_MAX = 800;
376
- const REPORT_UP_ELISION = "[…mid-work narration trimmed by report-up…]";
398
+ // #1058 distillation only ever runs on a FINAL turn (promoteLineageCapturedResponse gates on
399
+ // isFinalSessionResponse), so the old "[…mid-work narration trimmed…]" label was a lie: it read as
400
+ // if the real report were still coming when THIS is the deliverable. The elision marks that only the
401
+ // LEADING narration was dropped, states plainly that this is the final report, and points at the
402
+ // untrimmed turn via relay_get_message so the coordinator never has to guess whether the child is done.
403
+ const reportUpElision = (messageId: number): string => `[…leading narration trimmed by report-up — this IS the final report; full turn: relay_get_message #${messageId}…]`;
377
404
  const REPORT_UP_HEADING = /^#{1,6}\s/m;
378
405
 
379
- function distillLineageReportBody(body: string): string {
406
+ function distillLineageReportBody(body: string, messageId: number): string {
380
407
  const trimmed = body.trim();
381
408
  if (trimmed.length <= REPORT_UP_DISTILL_THRESHOLD) return body;
382
409
  const heading = REPORT_UP_HEADING.exec(trimmed);
383
- if (heading) return heading.index === 0 ? body : `${REPORT_UP_ELISION}\n\n${trimmed.slice(heading.index).trim()}`;
410
+ if (heading) return heading.index === 0 ? body : `${reportUpElision(messageId)}\n\n${trimmed.slice(heading.index).trim()}`;
384
411
  const blocks = trimmed.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
385
412
  const kept: string[] = [];
386
413
  let size = 0;
@@ -394,7 +421,7 @@ function distillLineageReportBody(body: string): string {
394
421
  // A single oversized trailing block (unsegmented narration) — hard-cap to its own tail.
395
422
  if (tail.length > REPORT_UP_TAIL_MAX) tail = tail.slice(tail.length - REPORT_UP_TAIL_MAX).replace(/^\S*\s/, "").trimStart();
396
423
  if (tail.length >= trimmed.length) return body; // nothing meaningful trimmed
397
- return `${REPORT_UP_ELISION}\n\n${tail}`;
424
+ return `${reportUpElision(messageId)}\n\n${tail}`;
398
425
  }
399
426
 
400
427
  // #1037 — a quiet child's turn-final response that arrives while the child is still busy is a
@@ -451,7 +478,10 @@ export function listRecentFinalResponseCaptures(sinceMs: number, limit = 1000):
451
478
  WHERE kind = 'session'
452
479
  AND created_at >= ?
453
480
  AND json_extract(payload, '$.session.type') = 'response'
454
- AND json_extract(payload, '$.session.final') = 1
481
+ AND (
482
+ json_extract(payload, '$.session.turnFinal') = 1
483
+ OR json_extract(payload, '$.session.final') = 1
484
+ )
455
485
  GROUP BY from_agent
456
486
  ) latest ON latest.latest_id = m.id
457
487
  ORDER BY m.id DESC
@@ -545,7 +575,7 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
545
575
  kind: "chat",
546
576
  // #1037 — distill the turn-final report-up to a concise result; verbose child-stream captures
547
577
  // (an explicit subscriber that asked for the raw stream) are forwarded untouched.
548
- body: stream === "responses" ? distillLineageReportBody(message.body) : message.body,
578
+ body: stream === "responses" ? distillLineageReportBody(message.body, message.id) : message.body,
549
579
  replyExpected: false,
550
580
  idempotencyKey,
551
581
  payload: stream === "responses"
@@ -560,6 +590,14 @@ export function promoteLineageCapturedResponse(message: Message): { message: Mes
560
590
  return result;
561
591
  }
562
592
 
593
+ export function setMessageWorkFinality(messageId: number, finality: MessageWorkFinality): Message | null {
594
+ const message = getMessage(messageId);
595
+ if (!message) return null;
596
+ const payload: MessagePayload = { ...(message.payload ?? {}), finality };
597
+ getDb().query("UPDATE messages SET payload = ? WHERE id = ?").run(JSON.stringify(payload), messageId);
598
+ return getMessage(messageId);
599
+ }
600
+
563
601
  // Event time may be queued-then-backfilled, so it can legitimately be older than the
564
602
  // receive time — but it must be a sane epoch-ms value. Returns null (column stays NULL, so
565
603
  // readers fall back to created_at) for absent/invalid values or anything more than a minute
@@ -586,7 +624,8 @@ export function sendMessagesBatchWithResult(inputs: SendMessageInput[]): SendMes
586
624
 
587
625
  function sendMessageWithResultInternal(input: SendMessageInput, wrapTransaction: boolean): SendMessageResult {
588
626
  const now = Date.now();
589
- const payload = input.payload ?? {};
627
+ const payload = normalizeMessagePayload(input.payload);
628
+ input.payload = payload;
590
629
  const attachmentRefs = cleanAttachmentRefs(payload);
591
630
 
592
631
  if (!getAgent(input.from)) {
@@ -148,6 +148,54 @@ function backfillProjectRootRemoteUrls(): void {
148
148
  }
149
149
  }
150
150
 
151
+ function dualStampLegacyTurnFinality(): void {
152
+ const messagesTable = getDb().query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get();
153
+ if (!messagesTable) return;
154
+ const messageCols = getDb().query("PRAGMA table_info(messages)").all() as Array<{ name: string }>;
155
+ const messageColNames = new Set(messageCols.map((col) => col.name));
156
+ if (!messageColNames.has("payload") || !messageColNames.has("created_at")) return;
157
+ getDb().run(`
158
+ UPDATE messages
159
+ SET payload = json_set(
160
+ payload,
161
+ '$.finality',
162
+ json_object(
163
+ 'windowId', 'legacy:' || id,
164
+ 'source', 'edge-fallback',
165
+ 'groupOrder', 0,
166
+ 'markedAt', coalesce(occurred_at, created_at)
167
+ )
168
+ )
169
+ WHERE kind = 'session'
170
+ AND json_extract(payload, '$.session.final') = 1
171
+ AND json_type(payload, '$.session.turnFinal') IS NULL
172
+ AND json_type(payload, '$.finality') IS NULL
173
+ `);
174
+ getDb().run(`
175
+ UPDATE messages
176
+ SET payload = json_set(payload, '$.session.turnFinal', json('true'))
177
+ WHERE json_extract(payload, '$.session.final') = 1
178
+ AND json_type(payload, '$.session.turnFinal') IS NULL
179
+ `);
180
+ getDb().run(`
181
+ UPDATE messages
182
+ SET payload = json_set(payload, '$.session.final', json('true'))
183
+ WHERE json_extract(payload, '$.session.turnFinal') = 1
184
+ AND json_type(payload, '$.session.final') IS NULL
185
+ `);
186
+ getDb().run(`
187
+ UPDATE messages
188
+ SET payload = json_set(payload, '$.responseCapture.turnFinal', json('true'))
189
+ WHERE json_extract(payload, '$.responseCapture.final') = 1
190
+ AND json_type(payload, '$.responseCapture.turnFinal') IS NULL
191
+ `);
192
+ getDb().run(`
193
+ UPDATE messages
194
+ SET payload = json_set(payload, '$.responseCapture.final', json('true'))
195
+ WHERE json_extract(payload, '$.responseCapture.turnFinal') = 1
196
+ AND json_type(payload, '$.responseCapture.final') IS NULL
197
+ `);
198
+ }
151
199
 
152
200
  // Incremental schema migrations + one-shot backfills, applied after the base DDL
153
201
  // in initDb(). Ordering matters: each block guards on the current column/table
@@ -280,6 +328,7 @@ export function applyMigrations(): void {
280
328
  if (!colNames.includes("occurred_at")) {
281
329
  getDb().run("ALTER TABLE messages ADD COLUMN occurred_at INTEGER");
282
330
  }
331
+ dualStampLegacyTurnFinality();
283
332
  getDb().query(
284
333
  "UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
285
334
  ).run(Date.now(), CLAIM_LEASE_MS);
@@ -822,6 +871,11 @@ export function applyMigrations(): void {
822
871
  if (!providerQuotaCols.includes("quota_advisory_state")) {
823
872
  getDb().run("ALTER TABLE provider_quotas ADD COLUMN quota_advisory_state TEXT NOT NULL DEFAULT '{}'");
824
873
  }
874
+ // #1074 — persist quota-band hysteresis state so a relay restart doesn't re-fire already-sent
875
+ // band-degradation notifications to the whole fleet.
876
+ if (!providerQuotaCols.includes("quota_band_state")) {
877
+ getDb().run("ALTER TABLE provider_quotas ADD COLUMN quota_band_state TEXT NOT NULL DEFAULT '{}'");
878
+ }
825
879
  getDb().run(`
826
880
  INSERT INTO provider_quotas (
827
881
  provider, account_key, quota_state, source_agent_ids, last_updated_at,