agent-relay-server 0.121.5 → 0.122.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 (131) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -4
  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/config.ts +1 -4
  88. package/src/agent-card-projection.ts +5 -1
  89. package/src/agent-command-activity.ts +39 -0
  90. package/src/automations/reconcile.ts +2 -2
  91. package/src/cli/workspace.ts +3 -1
  92. package/src/config.ts +10 -1
  93. package/src/db/agents.ts +118 -37
  94. package/src/db/message-reads.ts +50 -0
  95. package/src/db/messages.ts +48 -9
  96. package/src/db/migrations.ts +54 -0
  97. package/src/db/provider-quota-burn.ts +96 -67
  98. package/src/db/provider-quotas.ts +29 -7
  99. package/src/db/scheduled-timers.ts +21 -0
  100. package/src/db/schema.ts +1 -0
  101. package/src/db/tasks.ts +33 -3
  102. package/src/db/work-evidence.ts +10 -7
  103. package/src/event-loop-lag.ts +15 -0
  104. package/src/gate-resolver.ts +24 -1
  105. package/src/maintenance/jobs.ts +23 -6
  106. package/src/managed-policy.ts +5 -12
  107. package/src/mcp-plan-tools.ts +13 -1
  108. package/src/project-instructions.ts +36 -2
  109. package/src/quota-advisory.ts +6 -4
  110. package/src/quota-band-transition.ts +23 -14
  111. package/src/routes/agent-sessions.ts +39 -7
  112. package/src/routes/agents.ts +3 -2
  113. package/src/routes/tasks.ts +16 -4
  114. package/src/routes/workspaces.ts +35 -10
  115. package/src/services/plan-graph.ts +27 -9
  116. package/src/services/register-agent.ts +12 -12
  117. package/src/services/send-message.ts +43 -15
  118. package/src/services/spawn-agent.ts +3 -0
  119. package/src/spawn-command.ts +8 -0
  120. package/src/spawn-targets.ts +19 -34
  121. package/src/sse.ts +25 -3
  122. package/src/workspace-merge.ts +25 -5
  123. package/src/workspace-orphans.ts +13 -6
  124. package/public/assets/display-exQDWum3.js +0 -3
  125. package/public/assets/display-exQDWum3.js.map +0 -1
  126. package/public/assets/index-D9OogaB5.js +0 -25
  127. package/public/assets/projects-DdPNPFJI.js +0 -2
  128. package/public/assets/prompt-settings-BpZse7-X.js +0 -2
  129. package/public/assets/store-DKTLubBT.js.map +0 -1
  130. package/public/assets/teams-B8f2pGJe.js +0 -2
  131. package/public/assets/types-uVOXgvMT.js.map +0 -1
@@ -1,8 +1,8 @@
1
1
  import type { AgentProfile, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
2
- import { isRecord } from "agent-relay-sdk";
2
+ import { isRecord } from "agent-relay-sdk/types/guards";
3
3
  import type { LivenessInputs } from "./liveness";
4
4
  import type { SessionEvent } from "./session-insights";
5
- import { messageBodyMaxCharsFromEnv } from "./config";
5
+ import { messageBodyMaxCharsFromEnv } from "./message-body-config";
6
6
  import { relayReplyReminderText } from "./relay-instructions";
7
7
  import { renderRunnerTemplate } from "./template-resolver";
8
8
 
@@ -250,6 +250,53 @@ export function providerMessageBodyMaxChars(): number {
250
250
  return DEFAULT_PROVIDER_MESSAGE_BODY_MAX_CHARS;
251
251
  }
252
252
 
253
+ export interface DeliveredBodyPreview {
254
+ body: string;
255
+ truncated: boolean;
256
+ /** Retrieval-guidance lines (empty when not truncated). Call sites may append provider extras. */
257
+ guidance: string[];
258
+ }
259
+
260
+ // #1058 — one home for the delivered-body cap. Head-only truncation silently ate the TAIL, which is
261
+ // exactly where a report-up final puts its verdict / SHIP recommendation. Preserve BOTH ends: keep a
262
+ // head slice for opening context and a tail slice for the conclusion, eliding the middle and pointing
263
+ // at the untrimmed body. The tail is favored (it holds the verdict). Slices snap to whitespace so we
264
+ // don't deliver a half-word at either seam. Grep before adding another truncation site — this is it.
265
+ export function previewDeliveredBody(
266
+ rawBody: string,
267
+ messageId: number,
268
+ maxChars: number = providerMessageBodyMaxChars(),
269
+ ): DeliveredBodyPreview {
270
+ if (rawBody.length <= maxChars) return { body: rawBody, truncated: false, guidance: [] };
271
+ const fetchLines = [
272
+ `Read full: agent-relay get-message ${messageId}`,
273
+ `Body only: agent-relay get-message ${messageId} --body`,
274
+ ];
275
+ const marker = (elided: number) => `[… ${elided} chars elided from the middle — full report: relay_get_message #${messageId} …]`;
276
+ // Reserve room for the marker and its two blank-line joins; upper-bound the marker length by using
277
+ // the (always-larger) full body length so the reserved budget can never underflow the real marker.
278
+ const budget = maxChars - marker(rawBody.length).length - 4;
279
+ if (budget < 200) {
280
+ // Cap too small to keep a useful head — front-truncate, preserving the tail/verdict whole.
281
+ const tail = rawBody.slice(rawBody.length - maxChars).replace(/^\S*\s+/, "").trimStart();
282
+ return {
283
+ body: tail,
284
+ truncated: true,
285
+ guidance: [`[truncated: showing last ${tail.length} of ${rawBody.length} chars — head trimmed, tail/verdict preserved]`, ...fetchLines],
286
+ };
287
+ }
288
+ const headChars = Math.floor(budget * 0.35);
289
+ const tailChars = budget - headChars;
290
+ const head = rawBody.slice(0, headChars).replace(/\s+\S*$/, "").trimEnd();
291
+ const tail = rawBody.slice(rawBody.length - tailChars).replace(/^\S*\s+/, "").trimStart();
292
+ const elided = rawBody.length - head.length - tail.length;
293
+ return {
294
+ body: `${head}\n\n${marker(elided)}\n\n${tail}`,
295
+ truncated: true,
296
+ guidance: [`[truncated: showing first ${head.length} + last ${tail.length} of ${rawBody.length} chars — tail/verdict preserved]`, ...fetchLines],
297
+ };
298
+ }
299
+
253
300
  function attachmentRefs(message: Message): Record<string, unknown>[] {
254
301
  const payloadRefs = message.payload?.attachments;
255
302
  const topLevelRefs = (message as Message & { attachments?: unknown }).attachments;
@@ -375,23 +422,11 @@ export function providerMessageText(messages: Message[]): string {
375
422
  const subject = message.subject ? `Subject: ${message.subject}\n` : "";
376
423
  const isMemoryContext = isMemoryInjection(message);
377
424
  const canReferenceMessage = isPersistedRelayMessage(message) && !isMemoryContext && !isReactionNotification(message);
378
- const shouldPreview = canReferenceMessage && message.body.length > maxChars;
379
- const preview = shouldPreview
380
- ? {
381
- body: message.body.slice(0, maxChars),
382
- truncated: true,
383
- }
384
- : {
385
- body: message.body,
386
- truncated: false,
387
- };
425
+ const preview = canReferenceMessage
426
+ ? previewDeliveredBody(message.body, message.id, maxChars)
427
+ : { body: message.body, truncated: false, guidance: [] };
388
428
  const truncationGuidance = preview.truncated
389
- ? [
390
- `[truncated: showing first ${maxChars} of ${message.body.length} chars]`,
391
- `Read full: agent-relay get-message ${message.id}`,
392
- `Body only: agent-relay get-message ${message.id} --body`,
393
- `Long reply CLI fallback: agent-relay /reply ${message.id} --body-file response.md`,
394
- ].join("\n")
429
+ ? [...preview.guidance, `Long reply CLI fallback: agent-relay /reply ${message.id} --body-file response.md`].join("\n")
395
430
  : undefined;
396
431
  if (isMemoryContext) {
397
432
  return [
@@ -6,6 +6,7 @@ import { DEFAULT_RELAY_URL, RELAY_TOKEN_HEADER, errMessage, stringValue } from "
6
6
  import type { SettingEntry, WorkspaceMetadata } from "agent-relay-sdk";
7
7
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
8
8
  import { getManifest } from "agent-relay-providers";
9
+ export { messageBodyMaxCharsFromEnv } from "./message-body-config";
9
10
 
10
11
  interface ProviderConfig {
11
12
  command: string;
@@ -241,10 +242,6 @@ export function providerHomeRootFromEnv(): string {
241
242
  return process.env.AGENT_RELAY_PROVIDER_HOME_ROOT || join(homedir(), ".agent-relay", "provider-homes");
242
243
  }
243
244
 
244
- export function messageBodyMaxCharsFromEnv(): string | undefined {
245
- return process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
246
- }
247
-
248
245
  export function loadProviderConfig(provider: string, home = agentRelayHome()): LoadedProviderConfig {
249
246
  const path = join(providersDir(home), `${provider}.json`);
250
247
  const raw = readJson(path);
@@ -9,6 +9,7 @@
9
9
  import { isRecord } from "agent-relay-sdk";
10
10
  import { attachBranchState } from "./agent-branch-state";
11
11
  import { deriveAgentBusyHealth } from "./agent-busy-health";
12
+ import { attachCommandActivity } from "./agent-command-activity";
12
13
  import { attachIssueRepo } from "./agent-issue-repo";
13
14
  import type { AgentCard } from "./types";
14
15
 
@@ -28,8 +29,11 @@ const OMITTED_STATUS_WORKSPACE_KEYS = new Set([
28
29
  /** Enrich an AgentCard with the derived fields the dashboard expects on every surface. */
29
30
  export function projectAgentCard(agent: AgentCard): AgentCard {
30
31
  const projected = attachIssueRepo(attachBranchState(agent));
32
+ // busy-health derives from the RAW provider status, so read it before the #1004 command-busy
33
+ // overlay — a running background command must not make the agent look stuck-busy.
31
34
  const busyHealth = deriveAgentBusyHealth(projected);
32
- return busyHealth ? { ...projected, meta: { ...(projected.meta ?? {}), busyHealth } } : projected;
35
+ const enriched = busyHealth ? { ...projected, meta: { ...(projected.meta ?? {}), busyHealth } } : projected;
36
+ return attachCommandActivity(enriched);
33
37
  }
34
38
 
35
39
  function projectStatusMeta(meta: AgentCard["meta"]): AgentCard["meta"] | undefined {
@@ -0,0 +1,39 @@
1
+ // #1004 — the single home for the running-command → busy DISPLAY rule.
2
+ //
3
+ // A background command scheduled via relay_schedule_timer runs on the creating agent's behalf
4
+ // (on an orchestrator). The provider reports that agent `idle` the instant its own turn ends,
5
+ // so the still-running command is invisible on the dashboard — it reads idle while a real shell
6
+ // command (e.g. a release) executes. Reflect it as `busy` for the command's lifetime.
7
+ //
8
+ // Every agent-card serialization boundary routes through here — the SSE projection
9
+ // (agent-card-projection.ts) AND the REST reads (routes/agents.ts) — mirroring how
10
+ // attachBranchState/attachIssueRepo enrich at those same boundaries, so live ticks and polls
11
+ // agree. A prompt-wakeup timer (no command) is a plain future alarm and never triggers this.
12
+ //
13
+ // Override only a resting `idle`/`online`: never mask a genuine offline/stale/error, and never
14
+ // fabricate work the provider itself already reports (busy stays busy). The busy-ness is derived
15
+ // live from the timer/command rows (the source of truth via agentHasRunningCommandTimer), not a
16
+ // duplicated status flag — so it self-heals when the command settles and can't leak a
17
+ // permanently-busy agent. It is a display overlay only: the stored provider status the reconcilers
18
+ // read (getAgent/listAgents) is untouched, so stuck-busy health and lifecycle gating are unaffected.
19
+ import { agentHasRunningCommandTimer, agentsWithRunningCommandTimers } from "./db";
20
+ import type { AgentCard } from "./types";
21
+
22
+ function applyCommandBusy(agent: AgentCard, running: boolean): AgentCard {
23
+ if (!running) return agent;
24
+ if (agent.status !== "idle" && agent.status !== "online") return agent;
25
+ return { ...agent, status: "busy" };
26
+ }
27
+
28
+ /** Overlay running-command busy on a single card (SSE projection + REST get-by-id). */
29
+ export function attachCommandActivity(agent: AgentCard): AgentCard {
30
+ return applyCommandBusy(agent, agentHasRunningCommandTimer(agent.id));
31
+ }
32
+
33
+ /** Batch variant for list endpoints: one query, then map (REST get-many). */
34
+ export function attachCommandActivities(agents: AgentCard[]): AgentCard[] {
35
+ if (agents.length === 0) return agents;
36
+ const running = agentsWithRunningCommandTimers();
37
+ if (running.size === 0) return agents;
38
+ return agents.map((agent) => applyCommandBusy(agent, running.has(agent.id)));
39
+ }
@@ -202,7 +202,7 @@ function maybeFailNeverReadyOfflineTarget(run: AutomationRun, task: Task, now: n
202
202
  automationTargetNeverReady: true,
203
203
  agentId,
204
204
  },
205
- });
205
+ }, { privileged: true });
206
206
  const updatedTask = taskResult.task ?? getTask(task.id) ?? task;
207
207
  updateRun(run.id, {
208
208
  status: "failed",
@@ -248,7 +248,7 @@ function maybeTimeoutRuntimeBudget(run: AutomationRun, task: Task, now: number):
248
248
  maxRuntimeMs: budget.maxRuntimeMs,
249
249
  deadlineAt: deadline,
250
250
  },
251
- });
251
+ }, { privileged: true });
252
252
  const updatedTask = taskResult.task ?? getTask(task.id) ?? task;
253
253
  const command = requestOnDemandAgentShutdown(run, updatedTask, now, "runtime-budget-timeout");
254
254
  updateRun(run.id, {
@@ -150,7 +150,9 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
150
150
  }
151
151
 
152
152
  if (action === "cleanup-stale") {
153
- const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute, ...(idExplicit && id ? { workspaceId: id } : {}) });
153
+ // #965 --force discards an abandoned offline-owner worktree even when its git state is a
154
+ // conflict/unlanded (the land-safety gate is right for live work, wrong for a dead branch).
155
+ const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute, force, ...(idExplicit && id ? { workspaceId: id } : {}) });
154
156
  console.log(JSON.stringify(result, null, 2));
155
157
  return;
156
158
  }
package/src/config.ts CHANGED
@@ -65,7 +65,16 @@ export const STUCK_BUSY_STARTUP_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_STAR
65
65
  export const STUCK_BUSY_RECONNECT_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_RECONNECT_MS", STUCK_BUSY_THRESHOLD_MS);
66
66
  export const STUCK_BUSY_SHUTDOWN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_SHUTDOWN_MS", STUCK_BUSY_THRESHOLD_MS);
67
67
  export const EVENT_LOOP_LAG_SAMPLE_MS = envPositiveInt("AGENT_RELAY_EVENT_LOOP_LAG_SAMPLE_MS", 1_000);
68
- export const EVENT_LOOP_LAG_WINDOW_SAMPLES = envPositiveInt("AGENT_RELAY_EVENT_LOOP_LAG_WINDOW_SAMPLES", 60);
68
+ // #1055 invariant: retain STRICTLY MORE than one reaper interval of lag history. The
69
+ // stale/bus reaper reads the cumulative lag grace once per REAP_INTERVAL_MS; if the ring
70
+ // held only one interval's worth (the old 60×1s == 60s == REAP_INTERVAL coincidence),
71
+ // the oldest stall samples could rotate out the instant before the first post-recovery
72
+ // reaper run and collapse the grace exactly when it's needed. Keep a half-interval margin
73
+ // (1.5× reaper interval) so recent overload always survives to the next reaper tick.
74
+ export const EVENT_LOOP_LAG_WINDOW_SAMPLES = envPositiveInt(
75
+ "AGENT_RELAY_EVENT_LOOP_LAG_WINDOW_SAMPLES",
76
+ Math.ceil((REAP_INTERVAL_MS / EVENT_LOOP_LAG_SAMPLE_MS) * 1.5),
77
+ );
69
78
  export const EVENT_LOOP_LAG_WARN_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_WARN_MS", 250);
70
79
  export const EVENT_LOOP_LAG_LOG_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_LOG_MS", EVENT_LOOP_LAG_WARN_MS);
71
80
  // #713 — provider-token silence ALONE is not a wedge signal for Claude (long turns, extended
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)) {