agent-relay-server 0.121.0 → 0.121.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 (126) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -4
  3. package/public/assets/{MessageBubble-BaPGFJxq.js → MessageBubble-CLqxCG9b.js} +2 -2
  4. package/public/assets/{MessageBubble-BaPGFJxq.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BXCVfmln.js → PlanGraphPanel-BkkToFEb.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BXCVfmln.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
  7. package/public/assets/{activity-D_shwAZt.js → activity-D-ocGQGs.js} +2 -2
  8. package/public/assets/{activity-D_shwAZt.js.map → activity-D-ocGQGs.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BrfVz5IV.js → agent-profiles-CRPgYTyb.js} +2 -2
  10. package/public/assets/{agent-profiles-BrfVz5IV.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
  11. package/public/assets/{agents-B5w-Z9Ic.js → agents-Chw5E_2q.js} +2 -2
  12. package/public/assets/{agents-B5w-Z9Ic.js.map → agents-Chw5E_2q.js.map} +1 -1
  13. package/public/assets/{analytics-Bm3D12UN.js → analytics-BmNDG0hR.js} +2 -2
  14. package/public/assets/{analytics-Bm3D12UN.js.map → analytics-BmNDG0hR.js.map} +1 -1
  15. package/public/assets/{automation-DOTL8BFs.js → automation-D7g90kTv.js} +2 -2
  16. package/public/assets/{automation-DOTL8BFs.js.map → automation-D7g90kTv.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-BlqeJZ5-.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
  18. package/public/assets/{branch-state-badge-BlqeJZ5-.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
  19. package/public/assets/{channels-BoiKfK6Q.js → channels-CLwPeEPi.js} +2 -2
  20. package/public/assets/{channels-BoiKfK6Q.js.map → channels-CLwPeEPi.js.map} +1 -1
  21. package/public/assets/{chat-PeU3iOaa.js → chat-Cgj7VKzc.js} +2 -2
  22. package/public/assets/{chat-PeU3iOaa.js.map → chat-Cgj7VKzc.js.map} +1 -1
  23. package/public/assets/{connectors-BvV8Hee2.js → connectors-Br6fiTny.js} +2 -2
  24. package/public/assets/{connectors-BvV8Hee2.js.map → connectors-Br6fiTny.js.map} +1 -1
  25. package/public/assets/{formatted-body-C1FgzlT1.js → formatted-body-CmkuD23M.js} +3 -3
  26. package/public/assets/{formatted-body-C1FgzlT1.js.map → formatted-body-CmkuD23M.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-BTpU1xH4.js → formatted-body-impl-DbdFmbwW.js} +2 -2
  28. package/public/assets/{formatted-body-impl-BTpU1xH4.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
  29. package/public/assets/{index-DZEahmyc.js → index-D9OogaB5.js} +6 -6
  30. package/public/assets/{index-DZEahmyc.js.map → index-D9OogaB5.js.map} +1 -1
  31. package/public/assets/{insights-BqmgSpQm.js → insights-qvaPqWCV.js} +2 -2
  32. package/public/assets/{insights-BqmgSpQm.js.map → insights-qvaPqWCV.js.map} +1 -1
  33. package/public/assets/{integrations-CuT92zQi.js → integrations-DYEGSqq_.js} +2 -2
  34. package/public/assets/{integrations-CuT92zQi.js.map → integrations-DYEGSqq_.js.map} +1 -1
  35. package/public/assets/{maintenance-lM7FeXVC.js → maintenance-C0HIEB-J.js} +2 -2
  36. package/public/assets/{maintenance-lM7FeXVC.js.map → maintenance-C0HIEB-J.js.map} +1 -1
  37. package/public/assets/{managed-agents-DaPNv3yY.js → managed-agents-DdS7aI38.js} +2 -2
  38. package/public/assets/{managed-agents-DaPNv3yY.js.map → managed-agents-DdS7aI38.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-Wia3Ho1s.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-Wia3Ho1s.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
  41. package/public/assets/{memory-B1v_zD-M.js → memory-CSwx7bz8.js} +2 -2
  42. package/public/assets/{memory-B1v_zD-M.js.map → memory-CSwx7bz8.js.map} +1 -1
  43. package/public/assets/{messages-BNOx3yK6.js → messages-Be9CmvDv.js} +2 -2
  44. package/public/assets/{messages-BNOx3yK6.js.map → messages-Be9CmvDv.js.map} +1 -1
  45. package/public/assets/{orchestrators-DWFy4asl.js → orchestrators-BgtvvHvo.js} +2 -2
  46. package/public/assets/{orchestrators-DWFy4asl.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
  47. package/public/assets/{overview-C_12cFPM.js → overview-BvhbPWtF.js} +2 -2
  48. package/public/assets/{overview-C_12cFPM.js.map → overview-BvhbPWtF.js.map} +1 -1
  49. package/public/assets/{pairs-DIc7gX83.js → pairs-CpXSOMx0.js} +2 -2
  50. package/public/assets/{pairs-DIc7gX83.js.map → pairs-CpXSOMx0.js.map} +1 -1
  51. package/public/assets/{projects-DPjvJUzT.js → projects-DdPNPFJI.js} +2 -2
  52. package/public/assets/{projects-DPjvJUzT.js.map → projects-DdPNPFJI.js.map} +1 -1
  53. package/public/assets/{prompt-settings-CtumNgG8.js → prompt-settings-BpZse7-X.js} +2 -2
  54. package/public/assets/{prompt-settings-CtumNgG8.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
  55. package/public/assets/{registry-DzUhgMq9.js → registry-pb1gTZEg.js} +2 -2
  56. package/public/assets/{registry-DzUhgMq9.js.map → registry-pb1gTZEg.js.map} +1 -1
  57. package/public/assets/{security-BJ3_u0uY.js → security-BgcX1n9D.js} +2 -2
  58. package/public/assets/{security-BJ3_u0uY.js.map → security-BgcX1n9D.js.map} +1 -1
  59. package/public/assets/{select-AWvlctyQ.js → select-DZPVpKPv.js} +2 -2
  60. package/public/assets/{select-AWvlctyQ.js.map → select-DZPVpKPv.js.map} +1 -1
  61. package/public/assets/{settings-5ofqi-bN.js → settings-hd5kzdRB.js} +2 -2
  62. package/public/assets/{settings-5ofqi-bN.js.map → settings-hd5kzdRB.js.map} +1 -1
  63. package/public/assets/store-DKTLubBT.js +9 -0
  64. package/public/assets/store-DKTLubBT.js.map +1 -0
  65. package/public/assets/{tasks-BEE9bBf7.js → tasks-C5d2LU5E.js} +2 -2
  66. package/public/assets/{tasks-BEE9bBf7.js.map → tasks-C5d2LU5E.js.map} +1 -1
  67. package/public/assets/{teams-C27H_Gwo.js → teams-B8f2pGJe.js} +2 -2
  68. package/public/assets/{teams-C27H_Gwo.js.map → teams-B8f2pGJe.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-WD2TboA7.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-WD2TboA7.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-CtmPK2hK.js → work-queue-9imc7aNK.js} +2 -2
  73. package/public/assets/{work-queue-CtmPK2hK.js.map → work-queue-9imc7aNK.js.map} +1 -1
  74. package/public/assets/{workspaces-B26EKv_H.js → workspaces-CLYc3yF3.js} +2 -2
  75. package/public/assets/{workspaces-B26EKv_H.js.map → workspaces-CLYc3yF3.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
  79. package/src/agent-issue-repo.ts +10 -3
  80. package/src/agent-lifecycle-events.ts +23 -1
  81. package/src/agent-ref.ts +18 -3
  82. package/src/bus-outbox.ts +44 -1
  83. package/src/bus.ts +28 -5
  84. package/src/channel-target.ts +4 -0
  85. package/src/cli/steward.ts +15 -14
  86. package/src/commands-db.ts +63 -9
  87. package/src/config.ts +50 -0
  88. package/src/db/claim-sql.ts +16 -0
  89. package/src/db/continuations.ts +44 -3
  90. package/src/db/message-reads.ts +34 -4
  91. package/src/db/messages.ts +128 -4
  92. package/src/db/pairs.ts +1 -0
  93. package/src/db/schema.ts +6 -0
  94. package/src/gate-resolver.ts +124 -0
  95. package/src/lifecycle-manager.ts +41 -10
  96. package/src/lifecycle-signal-registry.ts +87 -0
  97. package/src/maintenance/constants.ts +5 -1
  98. package/src/maintenance/jobs.ts +28 -2
  99. package/src/maintenance/workspaces.ts +75 -44
  100. package/src/managed-policy-escalation.ts +68 -3
  101. package/src/mcp/tools-spawn.ts +16 -6
  102. package/src/mcp-plan-tools.ts +2 -3
  103. package/src/notification-settings.ts +11 -0
  104. package/src/notification-types.ts +4 -0
  105. package/src/operator-alarm.ts +37 -0
  106. package/src/routes/agents.ts +4 -0
  107. package/src/routes/orchestrator-proxy.ts +13 -1
  108. package/src/routes/orchestrator.ts +4 -0
  109. package/src/security.ts +20 -0
  110. package/src/self-resume.ts +185 -21
  111. package/src/server.ts +2 -0
  112. package/src/services/plan-graph.ts +42 -12
  113. package/src/services/register-agent.ts +5 -1
  114. package/src/services/send-message.ts +36 -0
  115. package/src/services/spawn-agent.ts +21 -2
  116. package/src/services/task-semantic-events.ts +17 -1
  117. package/src/spawn-command.ts +3 -0
  118. package/src/spawn-targets.ts +26 -1
  119. package/src/sse.ts +13 -5
  120. package/src/workspace-auto-merge.ts +90 -34
  121. package/src/workspace-escalation.ts +52 -3
  122. package/src/workspace-human-escalation.ts +68 -0
  123. package/src/workspace-orphans.ts +11 -6
  124. package/src/workspace-phase.ts +22 -1
  125. package/public/assets/store-D5et8685.js +0 -9
  126. package/public/assets/store-D5et8685.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
2
  import { createCommand, listCommands } from "./commands-db";
3
3
  import { emitCommandEvent } from "./command-events";
4
+ import { MAX_BODY_BYTES, REAP_INTERVAL_MS } from "./config";
4
5
  import { getSelfResumeConfig } from "./config-store";
5
6
  import {
6
7
  archiveContinuationSegment,
@@ -14,7 +15,7 @@ import {
14
15
  import { emitRelayEvent } from "./events";
15
16
  import { routeNotification } from "./notification-router";
16
17
  import { renderTemplate } from "./prompt-resolver";
17
- import type { AgentCard, Command, ContinuationDecisionEntry, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeMode } from "./types";
18
+ import type { AgentCard, Command, CommandStatus, ContinuationDecisionEntry, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeMode } from "./types";
18
19
 
19
20
  interface CompactAndResumeInput {
20
21
  agentId: string;
@@ -89,7 +90,8 @@ export function compactAndResume(input: CompactAndResumeInput): CompactAndResume
89
90
  spentTokens: typeof agent.context?.tokensUsed === "number" ? agent.context.tokensUsed : 0,
90
91
  now,
91
92
  });
92
- const continuation = renderContinuation(updated);
93
+ if (updated.droppedLedgerEntries.length) archiveDroppedLedgerEntries(agent.id, generation, updated.droppedLedgerEntries, now);
94
+ const continuation = renderContinuation(updated.envelope);
93
95
  const command = mode === "native"
94
96
  ? createNativeCompact(agent.id, generation, continuation)
95
97
  : createClearThenInject(agent.id, generation, continuation);
@@ -100,18 +102,55 @@ export function compactAndResume(input: CompactAndResumeInput): CompactAndResume
100
102
  generation,
101
103
  commandId: command.id,
102
104
  continuationChars: continuation.length,
103
- ledgerEntries: updated.ledger.length,
105
+ ledgerEntries: updated.envelope.ledger.length,
104
106
  };
105
107
  }
106
108
 
107
- export function enqueueContinuationInjectionAfterSelfResume(command: Command): Command | null {
108
- if (command.status !== "succeeded") return null;
109
+ // #1023 — the ruledOut ledger bound (see updateContinuationAfterCompact) drops the
110
+ // oldest entries once the persisted ledger grows past its cap instead of letting a
111
+ // long-running agent render a multi-megabyte continuation. Dropped entries are not
112
+ // destroyed: they're archived alongside the workingState segments so relay_recall
113
+ // still finds them. Chunked well under archiveContinuationSegment's MAX_BODY_BYTES
114
+ // limit since a large drop batch could otherwise exceed a single segment's cap.
115
+ const DROPPED_LEDGER_ARCHIVE_CHUNK_BYTES = MAX_BODY_BYTES / 2;
116
+
117
+ function archiveDroppedLedgerEntries(agentId: string, generation: number, dropped: ContinuationLedgerEntry[], now: number): void {
118
+ let chunk: string[] = [];
119
+ let chunkBytes = 0;
120
+ const flush = () => {
121
+ if (!chunk.length) return;
122
+ archiveContinuationSegment({
123
+ agentId,
124
+ generation,
125
+ segment: `Ruled-out ledger entries dropped for size (search via relay_recall):\n${chunk.join("\n")}`,
126
+ now,
127
+ });
128
+ chunk = [];
129
+ chunkBytes = 0;
130
+ };
131
+ for (const entry of dropped) {
132
+ const line = renderLedgerLine(entry);
133
+ const lineBytes = new TextEncoder().encode(line).byteLength;
134
+ if (chunk.length && chunkBytes + lineBytes > DROPPED_LEDGER_ARCHIVE_CHUNK_BYTES) flush();
135
+ chunk.push(line);
136
+ chunkBytes += lineBytes;
137
+ }
138
+ flush();
139
+ }
140
+
141
+ interface PendingInjection {
142
+ continuation: string;
143
+ mode: string;
144
+ generation?: number;
145
+ }
146
+
147
+ function extractPendingInjection(command: Command): PendingInjection | null {
109
148
  const resume = isRecord(command.params.selfResume) ? command.params.selfResume : undefined;
110
149
  const mode = typeof resume?.mode === "string" ? resume.mode : undefined;
111
150
  const shouldInject =
112
151
  command.type === "agent.clearContext" && mode === "clear-inject"
113
152
  || command.type === "agent.compact" && mode === "native";
114
- if (!shouldInject) return null;
153
+ if (!shouldInject || !mode) return null;
115
154
  const continuation = typeof resume?.continuation === "string"
116
155
  ? resume.continuation
117
156
  : typeof command.params.instructions === "string"
@@ -119,6 +158,26 @@ export function enqueueContinuationInjectionAfterSelfResume(command: Command): C
119
158
  : undefined;
120
159
  if (!continuation) return null;
121
160
  const generation = typeof resume?.generation === "number" ? resume.generation : undefined;
161
+ return { continuation, mode, generation };
162
+ }
163
+
164
+ function buildInjectionParams(command: Command, injection: PendingInjection): Record<string, unknown> {
165
+ return {
166
+ reason: "self-resume-continuation",
167
+ content: injection.continuation,
168
+ selfResume: {
169
+ generation: injection.generation,
170
+ mode: injection.mode,
171
+ ...(command.type === "agent.clearContext" ? { afterClearCommandId: command.id } : {}),
172
+ ...(command.type === "agent.compact" ? { afterCompactCommandId: command.id } : {}),
173
+ },
174
+ };
175
+ }
176
+
177
+ export function enqueueContinuationInjectionAfterSelfResume(command: Command): Command | null {
178
+ if (command.status !== "succeeded") return null;
179
+ const injection = extractPendingInjection(command);
180
+ if (!injection) return null;
122
181
  const existing = listCommands({ target: command.target, type: "agent.injectContext", limit: 500 })
123
182
  .find((item) => item.correlationId === command.id);
124
183
  if (existing) return null;
@@ -127,16 +186,7 @@ export function enqueueContinuationInjectionAfterSelfResume(command: Command): C
127
186
  source: "system",
128
187
  target: command.target,
129
188
  correlationId: command.id,
130
- params: {
131
- reason: "self-resume-continuation",
132
- content: continuation,
133
- selfResume: {
134
- generation,
135
- mode,
136
- ...(command.type === "agent.clearContext" ? { afterClearCommandId: command.id } : {}),
137
- ...(command.type === "agent.compact" ? { afterCompactCommandId: command.id } : {}),
138
- },
139
- },
189
+ params: buildInjectionParams(command, injection),
140
190
  });
141
191
  emitCommandEvent(inject, "command.requested");
142
192
  return inject;
@@ -146,19 +196,101 @@ export function enqueueContinuationInjectionAfterClear(command: Command): Comman
146
196
  return enqueueContinuationInjectionAfterSelfResume(command);
147
197
  }
148
198
 
199
+ const CLEAR_OR_COMPACT_TYPES = ["agent.clearContext", "agent.compact"] as const;
200
+ const INJECTION_SWEEP_TERMINAL_STATUSES: CommandStatus[] = ["succeeded", "timed_out"];
201
+ const INJECTION_SWEEP_LOOKBACK_MS = 24 * 60 * 60 * 1000;
202
+ const MAX_INJECTION_ATTEMPTS = 3;
203
+ const IN_FLIGHT_OR_DONE_STATUSES: CommandStatus[] = ["succeeded", "pending", "accepted", "running"];
204
+
205
+ // #1023 — periodic backstop for the three silent mind-wipe windows found in the
206
+ // self-resume audit (relay message #176981):
207
+ // 1. runner->relay `command.update` lost in transit, so the clear/compact command
208
+ // never reaches "succeeded" and just times out — the reactive inject in
209
+ // enqueueContinuationInjectionAfterSelfResume never fires.
210
+ // 2. relay crash between persisting "succeeded" and creating the inject command.
211
+ // 3. the inject command itself fails once (e.g. provider momentarily unavailable)
212
+ // with no retry.
213
+ // This re-derives the missing inject directly from the durably persisted command
214
+ // params — the same idempotency key (correlationId = the clear/compact command id)
215
+ // used by the reactive path, so running this concurrently with it is safe. A
216
+ // timed-out clear/compact is treated the same as succeeded: we cannot always tell
217
+ // whether the runner's destructive step actually completed before the update was
218
+ // lost, and a redundant-but-harmless re-injection is preferable to a permanent
219
+ // silent mind-wipe. Retries are capped at MAX_INJECTION_ATTEMPTS; once exhausted the
220
+ // parent is notified once instead of retrying forever against an unreachable agent.
221
+ export function sweepMissedContinuationInjections(now: number = Date.now()): { enqueuedCommandIds: string[] } {
222
+ const enqueuedCommandIds: string[] = [];
223
+ const since = now - INJECTION_SWEEP_LOOKBACK_MS;
224
+ for (const type of CLEAR_OR_COMPACT_TYPES) {
225
+ for (const command of listCommands({ type, since, limit: 500 })) {
226
+ if (!INJECTION_SWEEP_TERMINAL_STATUSES.includes(command.status)) continue;
227
+ const injection = extractPendingInjection(command);
228
+ if (!injection) continue;
229
+ if (!getAgent(command.target)) continue;
230
+ const attempts = listCommands({ target: command.target, type: "agent.injectContext", limit: 500 })
231
+ .filter((item) => item.correlationId === command.id);
232
+ if (attempts.some((item) => IN_FLIGHT_OR_DONE_STATUSES.includes(item.status))) continue;
233
+ if (attempts.length >= MAX_INJECTION_ATTEMPTS) {
234
+ notifyContinuationInjectionExhausted(command, attempts.length);
235
+ continue;
236
+ }
237
+ const inject = createCommand({
238
+ type: "agent.injectContext",
239
+ source: "system",
240
+ target: command.target,
241
+ correlationId: command.id,
242
+ params: buildInjectionParams(command, injection),
243
+ });
244
+ emitCommandEvent(inject, "command.requested");
245
+ enqueuedCommandIds.push(inject.id);
246
+ }
247
+ }
248
+ return { enqueuedCommandIds };
249
+ }
250
+
251
+ export const SELF_RESUME_INJECTION_SWEEP_JOB = {
252
+ id: "self-resume-injection-sweep",
253
+ title: "Self-resume injection sweep",
254
+ description: "Re-deliver self-resume continuations whose inject was lost to a transport blip, a relay crash between persisting the clear/compact and enqueuing the inject, or a failed inject attempt (#1023).",
255
+ intervalMs: REAP_INTERVAL_MS,
256
+ runOnStart: true,
257
+ handler() {
258
+ return sweepMissedContinuationInjections();
259
+ },
260
+ };
261
+
262
+ function notifyContinuationInjectionExhausted(command: Command, attempts: number): void {
263
+ const idempotencyKey = `self-resume-injection-exhausted:${command.target}:${command.id}`;
264
+ if (findMessageByIdempotencyKey(command.target, idempotencyKey)) return;
265
+ const agent = getAgent(command.target);
266
+ const payload = { kind: "agent.resume_injection_failed", agentId: command.target, commandId: command.id, attempts };
267
+ emitRelayEvent({ type: "agent.resume_injection_failed", source: "server", subject: command.target, data: payload });
268
+ const recipient = agent?.spawnedBy ?? "user";
269
+ routeNotification({
270
+ type: "agent.resume_injection_failed",
271
+ scope: { agentId: command.target, parent: agent?.spawnedBy },
272
+ recipients: [recipient],
273
+ message: {
274
+ subject: "Self-resume continuation failed to inject",
275
+ body: `Agent \`${command.target}\` completed a self-resume ${command.type === "agent.compact" ? "compact" : "clear"} but its continuation could not be delivered after ${attempts} attempt(s). The agent may be running with an empty or stale context — check it and consider a manual relay_compact_and_resume or restart.`,
276
+ payload,
277
+ idempotencyKey,
278
+ from: command.target,
279
+ replyExpected: false,
280
+ },
281
+ });
282
+ }
283
+
149
284
  export function renderContinuation(
150
285
  envelope: ContinuationEnvelope,
151
286
  input: { intro?: string } = {},
152
287
  ): string {
153
- const ledger = envelope.ledger.length
154
- ? envelope.ledger.map((entry) => `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}: ${entry.ruledOut}${entry.why ? `; why: ${entry.why}` : ""}`).join("\n")
155
- : "- none";
156
288
  const decisions = envelope.decisions.length ? envelope.decisions.map(renderDecisionEntry).join("\n") : "- none";
157
289
  return renderTemplate("continuation.envelope", {
158
290
  intro: input.intro ?? `You have been resumed by Agent Relay — generation ${envelope.generation}.`,
159
291
  generation: String(envelope.generation),
160
292
  objective: envelope.objective,
161
- ruledOut: ledger,
293
+ ruledOut: renderLedger(envelope.ledger),
162
294
  decisions,
163
295
  workingState: envelope.workingState,
164
296
  archiveRefs: envelope.archiveRefs.length ? envelope.archiveRefs.join("\n") : "- none",
@@ -170,6 +302,31 @@ function renderDecisionEntry(entry: ContinuationDecisionEntry): string {
170
302
  return `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}${by}: ${entry.decision}${entry.rationale ? `; rationale: ${entry.rationale}` : ""}`;
171
303
  }
172
304
 
305
+ function renderLedgerLine(entry: ContinuationLedgerEntry): string {
306
+ return `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}: ${entry.ruledOut}${entry.why ? `; why: ${entry.why}` : ""}`;
307
+ }
308
+
309
+ // #1023 — render-time backstop on top of the persisted bound in
310
+ // updateContinuationAfterCompact. Even if that bound is ever bypassed by a future
311
+ // bug, this guarantees the ledger section injected into a fresh context can't blow
312
+ // past a fixed size; it keeps the newest entries and notes what was omitted rather
313
+ // than truncating mid-entry.
314
+ const MAX_RENDERED_LEDGER_CHARS = 300_000;
315
+
316
+ function renderLedger(entries: ContinuationLedgerEntry[]): string {
317
+ if (!entries.length) return "- none";
318
+ const lines = entries.map(renderLedgerLine);
319
+ let start = 0;
320
+ let totalChars = lines.reduce((sum, line) => sum + line.length + 1, 0);
321
+ while (totalChars > MAX_RENDERED_LEDGER_CHARS && start < lines.length - 1) {
322
+ totalChars -= lines[start]!.length + 1;
323
+ start += 1;
324
+ }
325
+ if (start === 0) return lines.join("\n");
326
+ const omitted = start;
327
+ return `- (${omitted} older ruled-out ${omitted === 1 ? "entry" : "entries"} omitted for size — see relay_recall for the archived history)\n${lines.slice(start).join("\n")}`;
328
+ }
329
+
173
330
  function assertResumeSupported(agent: AgentCard, mode: SelfResumeMode): void {
174
331
  const context = agent.providerCapabilities?.context;
175
332
  const resume = context?.resume ?? (context?.clear && context?.inject ? "clear-inject" : "none");
@@ -320,7 +477,7 @@ function notifySelfResumeBudgetWarning(agent: AgentCard, envelope: ContinuationE
320
477
  function parseRuledOut(value: unknown, generation: number, now: number): ContinuationLedgerEntry[] {
321
478
  if (value === undefined || value === null) return [];
322
479
  if (!Array.isArray(value)) throw new ValidationError("ruledOut must be an array");
323
- return value.map((item, index) => {
480
+ const entries = value.map((item, index) => {
324
481
  if (typeof item === "string") return ledgerEntry(item, "", generation, now);
325
482
  if (!isRecord(item)) throw new ValidationError(`ruledOut[${index}] must be a string or object`);
326
483
  const ruledOut = typeof item.ruledOut === "string" ? item.ruledOut.trim() : "";
@@ -328,6 +485,13 @@ function parseRuledOut(value: unknown, generation: number, now: number): Continu
328
485
  if (!ruledOut) throw new ValidationError(`ruledOut[${index}].ruledOut required`);
329
486
  return ledgerEntry(ruledOut, why, generation, now);
330
487
  });
488
+ // #1023 — ruledOut had no size limit at all (unlike the sibling workingState/objective
489
+ // fields, both capped at/near MAX_BODY_BYTES): a single legal call could inject a
490
+ // multi-megabyte continuation straight into the fresh context it was meant to fit
491
+ // inside. Bound total new content per call to the same ceiling as workingState.
492
+ const totalBytes = entries.reduce((sum, entry) => sum + new TextEncoder().encode(entry.ruledOut).byteLength + new TextEncoder().encode(entry.why).byteLength, 0);
493
+ if (totalBytes > MAX_BODY_BYTES) throw new ValidationError(`ruledOut exceeds ${MAX_BODY_BYTES} bytes for a single call — split it across multiple relay_compact_and_resume calls`);
494
+ return entries;
331
495
  }
332
496
 
333
497
  function ledgerEntry(ruledOut: string, why: string, generation: number, now: number): ContinuationLedgerEntry {
package/src/server.ts CHANGED
@@ -16,6 +16,7 @@ import { startRelayScheduler } from "./services/scheduler";
16
16
  import { startEventLoopLagSampler } from "./event-loop-lag";
17
17
  import { recordWorkspaceIssueActivity } from "./services/spawn-issue-activity";
18
18
  import { onWorkspaceCreate } from "./db";
19
+ import { warnIfNoHumanReachableEscalationTarget } from "./workspace-human-escalation";
19
20
  import { isTerminalSessionRequestAuthorized } from "./terminal-authz";
20
21
  import { resolve, sep } from "path";
21
22
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
@@ -81,6 +82,7 @@ export function startServer(): void {
81
82
  getCompactionWatch().start();
82
83
  reconcileRelayVersion();
83
84
  startConnectorStatusPoller();
85
+ warnIfNoHumanReachableEscalationTarget();
84
86
  startEventLoopLagSampler();
85
87
  startMaintenanceScheduler();
86
88
  startRelayScheduler();
@@ -1,10 +1,11 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
- import { addTaskDependency, createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, getTaskDetail, listPlansWithBoundOwner, planThreadExists, removeTaskDependency, replacePlan, setPlanArchived, ValidationError } from "../db";
2
+ import { addTaskDependency, createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, getTaskDetail, listPlansWithBoundOwner, planThreadExists, removeTaskDependency, replacePlan, sendMessage, setPlanArchived, ValidationError } from "../db";
3
3
  import { associateIssue, planNodeIssueEntityId, removeAssociation } from "../db/issue-associations";
4
4
  import { resolveIssueRefFromString } from "../db/issue-refs";
5
5
  import { emitRelayEvent } from "../events";
6
6
  import { cleanString, cleanStringArray, optionalEnum } from "../validation";
7
7
  import { defaultIssueRepo } from "../config";
8
+ import { issueRepoForAgent } from "../agent-issue-repo";
8
9
  import { buildTaskGraphView, parseTaskId, planStatusToTaskStatus, projectPlanOverTasks } from "./task-plan-view";
9
10
  import { createTaskEntity, setTaskEntityStatus } from "./task-entity";
10
11
  import type { AuthContext } from "./auth-context";
@@ -75,14 +76,25 @@ function cleanOwnerRef(value: unknown): PlanOwnerRef | undefined {
75
76
  return Object.keys(out).length ? out : undefined;
76
77
  }
77
78
 
78
- function cleanRefs(value: unknown): PlanNode["refs"] | undefined {
79
+ // #220: a bare `#NNN` issueId ref needs a default repo to resolve against — issue-refs.ts
80
+ // throws without one, contrary to its own error text advertising the bare form as accepted.
81
+ // Resolve it the same way #533 resolves chat `#NNN` linkify: the CALLING agent's own project
82
+ // (cwd → project → issueRepo), falling back to the single global env default. This scopes the
83
+ // default to the actor's actual repo rather than guessing from teamId — agents have no
84
+ // team→project FK; cwd is the only durable link (see agent-issue-repo.ts).
85
+ function resolveDefaultIssueRepo(actor: string): string | undefined {
86
+ const agent = getAgent(actor);
87
+ return (agent ? issueRepoForAgent(agent) : undefined) ?? defaultIssueRepo();
88
+ }
89
+
90
+ function cleanRefs(value: unknown, actor: string): PlanNode["refs"] | undefined {
79
91
  if (value === undefined || value === null) return undefined;
80
92
  const ref = cleanRecordRef(value, "refs");
81
93
  if (!ref) return undefined;
82
94
  const out: PlanNode["refs"] = {};
83
95
  const issueId = cleanString(ref.issueId, "refs.issueId", { max: 1000 });
84
96
  if (issueId) {
85
- out.issueId = resolveIssueRefFromString({ ref: issueId, defaultRepo: defaultIssueRepo() }).id;
97
+ out.issueId = resolveIssueRefFromString({ ref: issueId, defaultRepo: resolveDefaultIssueRepo(actor) }).id;
86
98
  }
87
99
  // #836: refs.taskId is the live binding seam to a durable Task. Validate it resolves to a
88
100
  // deliverable Task; store the canonical numeric id as a string. Once bound, the node's
@@ -147,12 +159,12 @@ function applyBoundStatus(node: PlanNode): PlanNode {
147
159
  return { ...node, status: boundStatusForAgent(getAgent(agentId)) ?? "pending" };
148
160
  }
149
161
 
150
- function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
162
+ function cleanNode(input: PlanNodeInput, actor: string, planTeamId?: string): PlanNode {
151
163
  if (!isRecord(input)) throw new ValidationError("node must be an object");
152
164
  const scopeBlurb = cleanString(input.scopeBlurb, "node.scopeBlurb", { max: 2000 });
153
165
  const ownerRef = cleanOwnerRef(input.ownerRef);
154
166
  const lane = cleanString(input.lane, "node.lane", { max: 120 });
155
- const refs = cleanRefs(input.refs);
167
+ const refs = cleanRefs(input.refs, actor);
156
168
  const blackboardRefs = cleanBlackboardRefs(input.blackboardRefs, planTeamId);
157
169
  const statusSource = cleanStatusSource(input.statusSource);
158
170
  return applyBoundStatus({
@@ -168,10 +180,10 @@ function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
168
180
  });
169
181
  }
170
182
 
171
- function cleanNodes(value: unknown, planTeamId?: string): PlanNode[] {
183
+ function cleanNodes(value: unknown, actor: string, planTeamId?: string): PlanNode[] {
172
184
  if (value === undefined || value === null) return [];
173
185
  if (!Array.isArray(value)) throw new ValidationError("nodes must be an array");
174
- const nodes = value.map((item) => cleanNode(item as PlanNodeInput, planTeamId));
186
+ const nodes = value.map((item) => cleanNode(item as PlanNodeInput, actor, planTeamId));
175
187
  assertUnique(nodes.map((node) => node.id), "node id");
176
188
  return nodes;
177
189
  }
@@ -252,14 +264,32 @@ function emitPlan(type: "plan.created" | "plan.changed" | "plan.deleted", plan:
252
264
  emitRelayEvent({ type, source: actor, subject: plan.id, data: { plan, actor } });
253
265
  }
254
266
 
267
+ // #1035: a plan anchors to a messages.thread_id, but a fresh coordinator has no thread yet —
268
+ // forcing it to send a throwaway message just to mint one. When no threadId is supplied, mint the
269
+ // thread server-side by self-threading a lightweight system message (the same sendMessage path the
270
+ // caller would have used). Sent AS the actor when that agent is registered, else the reserved
271
+ // `system` sink, so the mint always passes the sender-registration gate.
272
+ function mintPlanThread(actor: string, channel: string | undefined): number {
273
+ const sender = getAgent(actor) ? actor : "system";
274
+ const root = sendMessage({
275
+ from: sender,
276
+ to: "system",
277
+ body: "Plan thread root",
278
+ ...(channel ? { channel } : {}),
279
+ });
280
+ return root.threadId ?? root.id;
281
+ }
282
+
255
283
  export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
256
- const threadId = cleanThreadId(input.threadId);
257
- if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
258
284
  const channel = cleanString(input.channel, "channel", { max: 120 });
259
285
  const actor = actorId(ctx);
286
+ const threadId = input.threadId === undefined || input.threadId === null
287
+ ? mintPlanThread(actor, channel)
288
+ : cleanThreadId(input.threadId);
289
+ if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
260
290
  const explicitTeamId = cleanString(input.teamId, "teamId", { max: 120 });
261
291
  const teamId = explicitTeamId ?? (input.teamId === undefined || input.teamId === null ? getAgent(actor)?.teamId : undefined);
262
- const nodes = cleanNodes(input.nodes, teamId);
292
+ const nodes = cleanNodes(input.nodes, actor, teamId);
263
293
  const edges = cleanEdges(input.edges);
264
294
  validateEdges(nodes, edges);
265
295
  const plan = createPlanRow({
@@ -279,7 +309,7 @@ export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
279
309
 
280
310
  export function addPlanNode(planId: string, nodeInput: PlanNodeInput, ctx: AuthContext): Plan {
281
311
  const plan = existingPlan(planId);
282
- const node = cleanNode(nodeInput, plan.teamId);
312
+ const node = cleanNode(nodeInput, actorId(ctx), plan.teamId);
283
313
  if (plan.nodes.some((item) => item.id === node.id)) throw new ValidationError(`node ${node.id} already exists`);
284
314
  const updated = replacePlan({ ...plan, nodes: [...plan.nodes, node] }, actorId(ctx));
285
315
  syncPlanNodeIssueAssociation(updated.id, node);
@@ -299,7 +329,7 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
299
329
  ...(patch.ownerRef !== undefined ? optionalProp("ownerRef", cleanOwnerRef(patch.ownerRef)) : {}),
300
330
  ...(patch.lane !== undefined ? optionalProp("lane", cleanString(patch.lane, "lane", { max: 120 })) : {}),
301
331
  ...(patch.status !== undefined ? { status: optionalEnum(patch.status, "status", PLAN_STATUSES) as PlanStatus } : {}),
302
- ...(patch.refs !== undefined ? optionalProp("refs", cleanRefs(patch.refs)) : {}),
332
+ ...(patch.refs !== undefined ? optionalProp("refs", cleanRefs(patch.refs, actorId(ctx))) : {}),
303
333
  ...(patch.blackboardRefs !== undefined ? optionalProp("blackboardRefs", cleanBlackboardRefs(patch.blackboardRefs, plan.teamId)) : {}),
304
334
  statusSource,
305
335
  };
@@ -10,7 +10,7 @@
10
10
  // status broadcast, the single timeline note, the spawned-child parent-wake, and
11
11
  // the first-registration audit row.
12
12
  import { errMessage, isReservedAgentId } from "agent-relay-sdk";
13
- import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
13
+ import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, mergeAgentMeta, readSpawnReportVerbosity, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
14
14
  import { emitAgentStatus, emitNewMessage } from "../sse";
15
15
  import { noteAgentTimelineEvent } from "../compaction-watch";
16
16
  import { agentProviderTimelineEvent } from "../provider-timeline-event";
@@ -128,6 +128,10 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
128
128
  // spawner so it can't idle with its verdict undelivered (vent #121). First-registration only.
129
129
  const spawnRequestId = metaStr(agent.meta, "spawnRequestId");
130
130
  const provider = metaStr(agent.meta, "provider");
131
+ // #1037 — stamp the spawner-chosen report-up verbosity onto the child's meta from the persisted
132
+ // spawn command, so the lineage report-up delivery gate can read it (defaults to quiet if absent).
133
+ const reportVerbosity = readSpawnReportVerbosity(spawnRequestId);
134
+ if (reportVerbosity) mergeAgentMeta(agent.id, { reportVerbosity });
131
135
  const promptMirror = seedSpawnPromptMirror(agent.id, spawnRequestId);
132
136
  if (promptMirror?.created || promptMirror?.updated) emitNewMessage(promptMirror.message);
133
137
  seedSpawnReplyObligation(agent.id, agent.spawnedBy, spawnRequestId);
@@ -24,9 +24,11 @@
24
24
  import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
25
25
  import {
26
26
  ValidationError,
27
+ flushDeferredLineageReport,
27
28
  getAgent,
28
29
  getMessage,
29
30
  listAgents,
31
+ listRecentFinalResponseCaptures,
30
32
  promoteLineageCapturedResponse,
31
33
  sendMessagesBatchWithResult,
32
34
  sendMessageWithResult,
@@ -225,6 +227,40 @@ function prepareSendMessage(input: SendMessageInput, ctx: AuthContext, opts: Sen
225
227
  return { receipt, suppressedParent: suppressResultsOnlyAck(input) };
226
228
  }
227
229
 
230
+ /**
231
+ * #1037 — the child transitioned out of busy (idle/awaiting-input). Deliver its deferred turn-final
232
+ * report-up (a quiet child's last turn, held back so mid-work narration never reached the spawner)
233
+ * and fan it out over SSE. No-op when nothing was deferred. Driven off the bus turn-end edge.
234
+ */
235
+ export function flushIdleLineageReport(childId: string): void {
236
+ const promoted = flushDeferredLineageReport(childId);
237
+ if (promoted?.created) emitNewMessages([promoted.message]);
238
+ }
239
+
240
+ /** How far back the startup sweep re-scans for held-but-lost turn-final captures (#1040). A report
241
+ * is only ever deferred while its child is mid-turn, so any capture lost across a restart is recent;
242
+ * a day is a generous ceiling that still bounds the scan. */
243
+ const LINEAGE_STARTUP_SWEEP_WINDOW_MS = 24 * 60 * 60 * 1000;
244
+
245
+ /**
246
+ * #1040 — startup re-promotion sweep. `deferredLineageReports` is an in-memory Map, so a relay
247
+ * restart drops every held turn-final report-up. The captures are persisted, though, so re-run
248
+ * promotion for recent final response captures: an idle child whose report was lost gets it
249
+ * delivered now, an already-delivered one is a no-op (idempotency key), and a still-busy child
250
+ * re-defers (re-arming the in-memory hold so a later idle edge flushes it). Idempotent and safe to
251
+ * run periodically.
252
+ */
253
+ export function sweepDeferredLineageReportsOnStartup(now: number = Date.now()): { promoted: number; scanned: number } {
254
+ const captures = listRecentFinalResponseCaptures(now - LINEAGE_STARTUP_SWEEP_WINDOW_MS);
255
+ const emitted: Message[] = [];
256
+ for (const capture of captures) {
257
+ const promoted = promoteLineageCapturedResponse(capture);
258
+ if (promoted?.created) emitted.push(promoted.message);
259
+ }
260
+ if (emitted.length > 0) emitNewMessages(emitted);
261
+ return { promoted: emitted.length, scanned: captures.length };
262
+ }
263
+
228
264
  function emitSendResults(results: DbSendMessageResult[]): void {
229
265
  const liveMessages: Message[] = [];
230
266
  for (const result of results) {
@@ -23,7 +23,7 @@
23
23
  // shared `buildSpawnCommand`/`buildManagedSpawnParams` payload home directly. Folding
24
24
  // them through `spawnAgent(systemCtx)` is a clean belt-and-suspenders follow-up (#342),
25
25
  // deliberately out of scope here — this slice converges the two external TRANSPORTS.
26
- import type { SpawnProvider } from "agent-relay-sdk";
26
+ import type { ReportVerbosity, SpawnProvider } from "agent-relay-sdk";
27
27
  import { DEFAULT_USER_ID, errMessage, isRecord, stringValue } from "agent-relay-sdk";
28
28
  import { statSync } from "node:fs";
29
29
  import { dirname, resolve } from "node:path";
@@ -211,6 +211,12 @@ export interface SpawnAgentInput {
211
211
  * Explicit true is preserved in the command payload for compatibility only.
212
212
  */
213
213
  expectReply?: boolean;
214
+ /**
215
+ * #1037 — per-spawn report-up verbosity (`quiet` default | `normal` | `verbose`). Persisted in
216
+ * the spawn command params and stamped onto the child's meta at registration, where the lineage
217
+ * report-up delivery gate reads it. Absent = `quiet`.
218
+ */
219
+ reportVerbosity?: ReportVerbosity;
214
220
  tags?: string[];
215
221
  capabilities?: string[];
216
222
  providerArgs?: string[];
@@ -249,6 +255,8 @@ export interface SpawnAgentResult {
249
255
  agentId: string | null;
250
256
  registered: boolean;
251
257
  command: Command;
258
+ /** #1033 — the durable Task this spawn is bound to (explicit taskId or auto-scaffolded), when any. */
259
+ taskId?: number;
252
260
  task?: { id: number; autoScaffolded: boolean; nudge: string };
253
261
  }
254
262
 
@@ -332,7 +340,16 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
332
340
 
333
341
  // #255/#328 — bias the host to the caller's own machine when neither an explicit
334
342
  // orchestratorId nor a cwd pins it, so an agent spawning a helper lands on its own host.
335
- const orchestrator = selectSpawnOrchestrator(input.provider, input.orchestratorId, requestedCwd, caller?.machine ?? lineageAgent?.machine);
343
+ // #1034 — pass the token's orchestrator allow-set so host selection can NEVER land on a host
344
+ // the caller isn't authorized for (which would then trip assertResourceAllowed with a
345
+ // misleading `-32001 component token cannot access this resource` — the concurrent-spawn flap).
346
+ const orchestrator = selectSpawnOrchestrator(
347
+ input.provider,
348
+ input.orchestratorId,
349
+ requestedCwd,
350
+ caller?.machine ?? lineageAgent?.machine,
351
+ ctx.constraints?.orchestrators,
352
+ );
336
353
 
337
354
  // #308 §3 — an explicit cwd must resolve within the TARGET host's base dir (a path valid on
338
355
  // your host may not exist on a different orchestrator).
@@ -638,6 +655,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
638
655
  resumeWorkspace: input.resumeWorkspace,
639
656
  acquisition,
640
657
  ...(expectReply ? { expectReply } : {}),
658
+ ...(input.reportVerbosity ? { reportVerbosity: input.reportVerbosity } : {}),
641
659
  policyName: input.policyName,
642
660
  spawnRequestId,
643
661
  env,
@@ -727,6 +745,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
727
745
  agentId,
728
746
  registered: agentId !== null,
729
747
  command,
748
+ ...(input.taskId !== undefined ? { taskId: input.taskId } : {}),
730
749
  ...(autoScaffoldedTask ? { task: autoScaffoldedTask } : {}),
731
750
  };
732
751
  }
@@ -1,6 +1,7 @@
1
1
  import { getAgent } from "../db";
2
2
  import { addSubscription } from "../db/notification-subscriptions";
3
3
  import { emitRelayEvent } from "../events";
4
+ import { hasRecentLifecycleSignal, taskSignalKey } from "../lifecycle-signal-registry";
4
5
  import { notifySystemMessage } from "../notify";
5
6
  import type { NotificationScope, NotificationType } from "../notification-types";
6
7
  import { routeNotification } from "../notification-router";
@@ -216,7 +217,18 @@ function dedupeRecipients(recipients: Array<{ agentId: string; matchedRule: stri
216
217
  return out;
217
218
  }
218
219
 
220
+ // #1032b — a status pulse is redundant when a first-class lifecycle event (agent.ready, the lineage
221
+ // report-up, branch.landed/merge) already conveyed this task's state to this recipient. Suppress it
222
+ // rather than waking the spawner a second time for the same fact.
223
+ // #1038 — keyed by the pulse's own transition: a progress signal (report-up / ready) supersedes only
224
+ // a redundant `working` pulse, never a later DISTINCT-state transition (committed / landed / done)
225
+ // for the same task within the TTL.
226
+ function pulseSupersededByLifecycle(agentId: string, taskId: number, transition: TaskSemanticTransition): boolean {
227
+ return hasRecentLifecycleSignal(agentId, taskSignalKey(taskId, transition));
228
+ }
229
+
219
230
  function enqueueStatusPulse(agentId: string, item: StatusPulseDigestItem): void {
231
+ if (pulseSupersededByLifecycle(agentId, item.taskId, item.transition)) return; // #1032b/#1038
220
232
  const batch = statusPulseBatches.get(agentId) ?? { items: [] };
221
233
  batch.items.push(item);
222
234
  if (!batch.timer) {
@@ -233,7 +245,11 @@ export function flushTaskStatusPulses(agentId?: string): void {
233
245
  if (batch.timer) clearTimeout(batch.timer);
234
246
  statusPulseBatches.delete(target);
235
247
  if (batch.items.length === 0) continue;
236
- const items = compactStatusPulseItems(batch.items);
248
+ // #1032b — a lifecycle event that fired AFTER these items were queued (but within the batch
249
+ // window) also supersedes them; drop those before flushing so the digest carries only news.
250
+ const fresh = batch.items.filter((item) => !pulseSupersededByLifecycle(target, item.taskId, item.transition));
251
+ if (fresh.length === 0) continue;
252
+ const items = compactStatusPulseItems(fresh);
237
253
  notifySystemMessage(target, {
238
254
  subject: `Task status pulse (${items.length})`,
239
255
  body: taskStatusPulseBody(items),
@@ -109,6 +109,8 @@ interface BuildSpawnCommandOptions {
109
109
  * registration from the persisted spawn command params.
110
110
  */
111
111
  expectReply?: boolean;
112
+ /** #1037 — per-spawn report-up verbosity, persisted so registration can stamp it onto child meta. */
113
+ reportVerbosity?: string;
112
114
  profile?: string;
113
115
  agentProfile?: unknown;
114
116
  headless?: boolean;
@@ -174,6 +176,7 @@ export function buildSpawnCommand(opts: BuildSpawnCommandOptions): Record<string
174
176
  ...def(opts.reviewer, "reviewer"),
175
177
  ...def(opts.taskId, "taskId"),
176
178
  ...def(opts.expectReply, "expectReply"),
179
+ ...def(opts.reportVerbosity, "reportVerbosity"),
177
180
  ...def(opts.headless, "headless"),
178
181
  ...def(opts.policyName, "policyName"),
179
182
  ...def(opts.automationId, "automationId"),