agent-relay-server 0.121.0 → 0.121.2

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 (130) 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 +34 -7
  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/agents.ts +6 -2
  89. package/src/db/claim-sql.ts +16 -0
  90. package/src/db/continuations.ts +44 -3
  91. package/src/db/message-reads.ts +34 -4
  92. package/src/db/messages.ts +128 -4
  93. package/src/db/pairs.ts +1 -0
  94. package/src/db/schema.ts +6 -0
  95. package/src/event-loop-lag.ts +10 -1
  96. package/src/gate-resolver.ts +124 -0
  97. package/src/lifecycle-manager.ts +41 -10
  98. package/src/lifecycle-signal-registry.ts +87 -0
  99. package/src/maintenance/constants.ts +5 -1
  100. package/src/maintenance/jobs.ts +36 -4
  101. package/src/maintenance/workspaces.ts +75 -44
  102. package/src/managed-policy-escalation.ts +68 -3
  103. package/src/mcp/tools-spawn.ts +16 -6
  104. package/src/mcp-plan-tools.ts +2 -3
  105. package/src/notification-settings.ts +11 -0
  106. package/src/notification-types.ts +4 -0
  107. package/src/operator-alarm.ts +37 -0
  108. package/src/routes/agents.ts +4 -0
  109. package/src/routes/orchestrator-proxy.ts +13 -1
  110. package/src/routes/orchestrator.ts +4 -0
  111. package/src/routes/workspaces.ts +25 -7
  112. package/src/security.ts +35 -0
  113. package/src/self-resume.ts +185 -21
  114. package/src/server.ts +8 -0
  115. package/src/services/plan-graph.ts +42 -12
  116. package/src/services/register-agent.ts +5 -1
  117. package/src/services/send-message.ts +36 -0
  118. package/src/services/spawn-agent.ts +21 -2
  119. package/src/services/task-semantic-events.ts +17 -1
  120. package/src/spawn-command.ts +3 -0
  121. package/src/spawn-targets.ts +26 -1
  122. package/src/sse.ts +13 -5
  123. package/src/workspace-auto-merge.ts +90 -34
  124. package/src/workspace-escalation.ts +52 -3
  125. package/src/workspace-human-escalation.ts +68 -0
  126. package/src/workspace-merge.ts +16 -0
  127. package/src/workspace-orphans.ts +11 -6
  128. package/src/workspace-phase.ts +22 -1
  129. package/public/assets/store-D5et8685.js +0 -9
  130. package/public/assets/store-D5et8685.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { getArtifactStorage } from "../artifact-storage";
2
2
  import { deriveAgentBusyHealth } from "../agent-busy-health";
3
3
  import { expireStaleBusAgents } from "../bus";
4
+ import { eventLoopLagMaxMs } from "../event-loop-lag";
4
5
  import { pruneOutbox } from "../bus-outbox";
5
6
  import { expireCommands } from "../commands-db";
6
7
  import { getRetentionConfig } from "../retention-config-store";
@@ -26,6 +27,7 @@ import {
26
27
  } from "../db";
27
28
  import { isAgentUnavailable } from "../db/agent-predicates";
28
29
  import { flushQueuedDirectMessages } from "../services/managed-running";
30
+ import { sweepDeferredLineageReportsOnStartup } from "../services/send-message";
29
31
  import { notifyAgentOffline } from "../agent-lifecycle-events";
30
32
  import { diagnoseAgentDeath } from "../agent-death-diagnosis";
31
33
  import { autoMergeCleanFastForwards, runAutoMergeWatchdog, scheduleRepoMerge } from "../workspace-auto-merge";
@@ -49,6 +51,7 @@ import { reapUnregisteredSpawnCommands } from "../services/spawn-registration-wa
49
51
  import { resumeRateLimitedAgents } from "../services/rate-limit-resume";
50
52
  import { collectCodexWhamQuota } from "../codex-wham-quota";
51
53
  import { MERGE_PAUSE_MAINTENANCE_JOB } from "../merge-pause-maintenance";
54
+ import { SELF_RESUME_INJECTION_SWEEP_JOB } from "../self-resume";
52
55
  import {
53
56
  CODEX_WHAM_QUOTA_INTERVAL_MS,
54
57
  DB_MAINTENANCE_INTERVAL_MS,
@@ -171,6 +174,7 @@ export const definitions: MaintenanceJobDefinition[] = [
171
174
  },
172
175
  },
173
176
  MERGE_PAUSE_MAINTENANCE_JOB,
177
+ SELF_RESUME_INJECTION_SWEEP_JOB,
174
178
  {
175
179
  id: "drive-lease-expiration",
176
180
  title: "Drive lease expiration",
@@ -237,7 +241,18 @@ export const definitions: MaintenanceJobDefinition[] = [
237
241
  intervalMs: REAP_INTERVAL_MS,
238
242
  runOnStart: true,
239
243
  handler() {
240
- const commands = expireCommands();
244
+ // #1025 A6 — also expire PENDING commands whose target orchestrator/agent is
245
+ // permanently gone. Orchestrators register an `orchestrator-<id>` agent kept fresh
246
+ // by heartbeat and deleted on removal, so a missing/offline agent record for the
247
+ // target means it will never poll this command again. A merely-busy (online) host is
248
+ // left alone, preserving the #930/#953 "don't sweep before the host can claim it".
249
+ const commands = expireCommands(Date.now(), {
250
+ isTargetGone: (command) => {
251
+ if (!command.target || command.target === "system") return false;
252
+ const agent = getAgent(command.target);
253
+ return !agent || agent.status === "offline";
254
+ },
255
+ });
241
256
  for (const command of commands) {
242
257
  settleWorkspaceCommand(command, { onMergeSettled: scheduleRepoMerge });
243
258
  applyCommandToRecipe(command);
@@ -263,7 +278,7 @@ export const definitions: MaintenanceJobDefinition[] = [
263
278
  intervalMs: REAP_INTERVAL_MS,
264
279
  runOnStart: true,
265
280
  handler() {
266
- const expired = expireStaleBusAgents();
281
+ const expired = expireStaleBusAgents(undefined, eventLoopLagMaxMs());
267
282
  for (const id of expired.agentIds) {
268
283
  getLifecycleManager().onAgentDisappeared(id);
269
284
  // #636/#659 — only after the stale reconnect grace expires do we turn a bare
@@ -286,7 +301,12 @@ export const definitions: MaintenanceJobDefinition[] = [
286
301
  intervalMs: REAP_INTERVAL_MS,
287
302
  runOnStart: true,
288
303
  handler() {
289
- const reapedAgentIds = reapStaleAgents(STALE_TTL_MS);
304
+ // #1048 gate staleness on the relay's OWN event-loop health. eventLoopLagMaxMs()
305
+ // is side-effect-free (does not consume the /health poller's spike max). During a
306
+ // relay self-stall this grace keeps live agents whose heartbeats we simply couldn't
307
+ // process from being reaped as dead.
308
+ const lagGraceMs = eventLoopLagMaxMs();
309
+ const reapedAgentIds = reapStaleAgents(STALE_TTL_MS, lagGraceMs);
290
310
  for (const id of reapedAgentIds) {
291
311
  emitAgentStatus(id);
292
312
  createActivityEvent({
@@ -325,6 +345,14 @@ export const definitions: MaintenanceJobDefinition[] = [
325
345
  handler: () => reapOrphanedSessions(),
326
346
  },
327
347
  { id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => reapTransientAgents() },
348
+ {
349
+ id: "lineage-report-startup-sweep",
350
+ title: "Lineage report startup sweep",
351
+ description: "Re-promote turn-final report-ups whose in-memory hold was lost across a relay restart (#1040).",
352
+ intervalMs: DAY_MS,
353
+ runOnStart: true,
354
+ handler: () => sweepDeferredLineageReportsOnStartup(),
355
+ },
328
356
  { id: "rate-limit-resume", title: "Rate-limit resume", description: "Auto-resume agents held on a provider usage/rate-limit stall once their reset window has passed (#286).", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => resumeRateLimitedAgents() },
329
357
  {
330
358
  id: "stuck-busy-watchdog",
@@ -530,7 +558,11 @@ export const definitions: MaintenanceJobDefinition[] = [
530
558
  title: "Workspace GC",
531
559
  description: "Prune terminal workspace rows past retention, auto-abandon stale review_requested worktrees, and trigger git worktree prune on orchestrators.",
532
560
  intervalMs: WORKSPACE_GC_INTERVAL_MS,
533
- runOnStart: false,
561
+ // #1021 A4 — the last-line strand backstop MUST run on start: it is the only sweep that
562
+ // catches ready/review_requested/conflict rows stranded independent of steward health, and
563
+ // a relay restarted more often than the hourly interval (normal during active dev) would
564
+ // otherwise never run it at all — the backstop effectively off exactly when it's needed.
565
+ runOnStart: true,
534
566
  timeoutMs: 60 * 1000,
535
567
  handler: workspaceGC,
536
568
  },
@@ -21,7 +21,8 @@ import { preparePrCompletionScan, reconcileLandedPr } from "../workspace-pr-comp
21
21
  import { applyBranchFreshness, fetchHostMergePreview, workspacePathWithinBase } from "../workspace-probe";
22
22
  import { wakeRepoSteward } from "../workspace-auto-merge";
23
23
  import { workspaceOwnerOrchestrator } from "../workspace-host";
24
- import { routeWorkspaceCustodyEscalation } from "../workspace-escalation";
24
+ import { routeWorkspaceCustodyEscalation, routeWorkspaceCustodyEscalationOrAlarm } from "../workspace-escalation";
25
+ import { raiseOperatorAlarm } from "../operator-alarm";
25
26
  import {
26
27
  CONFLICT_SCAN_STATUSES,
27
28
  DAY_MS,
@@ -31,7 +32,6 @@ import {
31
32
  WORKSPACE_RETENTION_MS,
32
33
  WORKSPACE_REVIEW_TTL_MS,
33
34
  stewardEscalationMs,
34
- stewardFallbackTarget,
35
35
  } from "./constants";
36
36
  import { emitCommand, notifyTarget } from "./events";
37
37
 
@@ -97,8 +97,8 @@ export async function scanWorkspaceConflicts(): Promise<Record<string, unknown>>
97
97
  metadata: { source: "server", maintenanceJobId: "workspace-conflict-scan", workspaceId: ws.id, ahead: p.ahead, behind: p.behind },
98
98
  });
99
99
  if (getStewardConfig().enabled) {
100
- const woke = wakeRepoSteward(getWorkspace(ws.id) ?? ws, "conflict");
101
- if (woke) notifiedStewards.push(woke);
100
+ const wake = wakeRepoSteward(getWorkspace(ws.id) ?? ws, "conflict");
101
+ if (wake.ok) notifiedStewards.push(wake.policy);
102
102
  } else if (ws.stewardAgentId) {
103
103
  try {
104
104
  // #640 — carry the identity triangle (branch agent + its coordinator) so the
@@ -146,7 +146,12 @@ function previewProvesLanded(preview: WorkspaceMergePreview): boolean {
146
146
 
147
147
  async function reviewAutoAbandonDecision(ws: WorkspaceRecord, orchestrators: ReturnType<typeof listOrchestrators>): Promise<AutoAbandonDecision> {
148
148
  const owner = workspaceOwnerOrchestrator(ws, orchestrators, { requireApiUrl: true });
149
- if (!owner?.apiUrl) return ws.orchestratorId ? { kind: "defer", reason: "owner-unavailable" } : { kind: "abandon" };
149
+ // #1025 C3 no reachable owner means we cannot probe land-state. Deferring is the safe
150
+ // outcome for BOTH cases: a known-but-offline host (may reconnect) AND an unknown host (no
151
+ // orchestratorId, no path match). The old code abandoned the unknown-host case BLIND — the
152
+ // less-certain input got the more destructive outcome. Deferring instead keeps the row for
153
+ // the strand-escalation loop / orphan scan to handle with an actual probe.
154
+ if (!owner?.apiUrl) return { kind: "defer", reason: ws.orchestratorId ? "owner-unavailable" : "unknown-host" };
150
155
  const preview = await fetchHostMergePreview(owner.apiUrl, ws, { checkPr: true });
151
156
  if (!preview || (preview as { available?: false }).available === false) return { kind: "defer", reason: "owner-unavailable" };
152
157
  const p = preview as WorkspaceMergePreview;
@@ -204,13 +209,28 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
204
209
  if (getStewardConfig().enabled) {
205
210
  wakeRepoSteward(fresh, `workspace ${fresh.status} stranded for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h`);
206
211
  }
212
+ const strandedHours = Math.round((now - strandedAt) / (60 * 60 * 1000));
207
213
  const targets = routeWorkspaceCustodyEscalation({
208
214
  workspace: fresh,
209
215
  subject: "Stranded workspace needs an owner",
210
- body: `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} and has been stranded for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
216
+ body: `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} and has been stranded for ${strandedHours}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
211
217
  payload: { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt },
212
218
  idempotencyKey: `workspace-stranded:${fresh.id}:${strandedAt}:${Math.floor(now / escalationMs)}`,
213
219
  });
220
+ if (!targets.length) {
221
+ // #1021 A4 — the escalation reached ZERO recipients (owner exited, coordinator idle, no
222
+ // fallback configured). Marking this "escalated" would silently swallow the strand and
223
+ // repeat every sweep forever. Instead leave it UNescalated (don't stamp escalatedAt, so
224
+ // the next sweep retries once recipients exist) and raise a louder operator-visible alarm.
225
+ raiseOperatorAlarm({
226
+ clientId: `workspace-gc-unescalatable-${fresh.id}:${strandedAt}:${Math.floor(now / escalationMs)}`,
227
+ source: "workspace-gc",
228
+ subject: "Stranded workspace has no one to escalate to",
229
+ detail: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} has been ${fresh.status} for ${strandedHours}h but has no owner, coordinator, or fallback configured to escalate to. Configure AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK or reclaim the worktree manually.`,
230
+ metadata: { maintenanceJobId: "workspace-gc", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt },
231
+ });
232
+ continue;
233
+ }
214
234
  escalationTargets.push(...targets);
215
235
  patchWorkspaceMetadata(fresh.id, { escalatedAt: now });
216
236
  escalatedIds.push(fresh.id);
@@ -218,7 +238,7 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
218
238
  clientId: `workspace-gc-escalate-${fresh.id}-${now}`,
219
239
  kind: "state",
220
240
  title: "Workspace escalated",
221
- body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${targets.length ? ` to ${targets.join(", ")}` : " (no owner, coordinator, or fallback configured)"}`,
241
+ body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated to ${targets.join(", ")}`,
222
242
  meta: fresh.branch ?? fresh.id,
223
243
  icon: "ti-alert-octagon",
224
244
  view: "orchestrators",
@@ -230,45 +250,56 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
230
250
  const autoAbandonDeferred: string[] = [];
231
251
  const notifiedStewards: string[] = [];
232
252
  const reviewOrchestrators = listOrchestrators();
233
- const fallbackTarget = stewardFallbackTarget();
234
253
  for (const ws of all) {
235
- if (ws.status === "review_requested" && ws.updatedAt < reviewCutoff) {
236
- const fresh = getWorkspace(ws.id);
237
- if (!fresh || fresh.status !== "review_requested") continue;
238
- const decision = await reviewAutoAbandonDecision(fresh, reviewOrchestrators);
239
- if (decision.kind === "defer") {
240
- autoAbandonDeferred.push(`${fresh.id}:${decision.reason}`);
241
- continue;
242
- }
243
- if (decision.kind === "landed") {
244
- reconcileLandedWorkspace(fresh, decision.preview);
245
- reconciledLandedIds.push(fresh.id);
246
- continue;
247
- }
248
-
249
- updateWorkspaceStatus(fresh.id, "abandoned", { autoAbandoned: true, abandonedReason: "review_requested TTL exceeded", abandonedAt: now });
250
- abandonedIds.push(fresh.id);
251
- const target = fresh.stewardAgentId ?? fallbackTarget;
252
- const sent = notifyTarget(
253
- "workspace.auto_abandoned",
254
- { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
255
- target,
256
- "Workspace auto-abandoned",
257
- `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
258
- { kind: "workspace.auto-abandoned", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch },
259
- );
260
- if (sent) notifiedStewards.push(sent);
261
- createActivityEvent({
262
- clientId: `workspace-gc-abandon-${fresh.id}-${now}`,
263
- kind: "state",
264
- title: "Workspace auto-abandoned",
265
- body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - review_requested for ${Math.round((now - fresh.updatedAt) / DAY_MS)}d`,
266
- meta: fresh.branch ?? fresh.id,
267
- icon: "ti-clock-x",
268
- view: "orchestrators",
269
- metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id },
270
- });
254
+ if (ws.mode !== "isolated" || !ws.worktreePath) continue;
255
+ // #1025 C2 — auto-abandon covers the whole strandable set (ready, review_requested,
256
+ // conflict, merge_planned), not just `review_requested`; a stuck `ready` was exempt and
257
+ // leaked forever. Clock the TTL on `readyAt` (the stable hand-off time) rather than
258
+ // `updatedAt` updatedAt bumps on every owner heartbeat, so a live-but-idle owner's
259
+ // workspace never aged past the TTL. Fall back to updatedAt when readyAt is absent
260
+ // (e.g. an active→conflict autoflag that never passed through `ready`).
261
+ if (!STRANDABLE_STATUSES.has(ws.status)) continue;
262
+ if ((ws.readyAt ?? ws.updatedAt) >= reviewCutoff) continue;
263
+ const fresh = getWorkspace(ws.id);
264
+ if (!fresh || !STRANDABLE_STATUSES.has(fresh.status)) continue;
265
+ if ((fresh.readyAt ?? fresh.updatedAt) >= reviewCutoff) continue;
266
+ const decision = await reviewAutoAbandonDecision(fresh, reviewOrchestrators);
267
+ if (decision.kind === "defer") {
268
+ autoAbandonDeferred.push(`${fresh.id}:${decision.reason}`);
269
+ continue;
271
270
  }
271
+ if (decision.kind === "landed") {
272
+ reconcileLandedWorkspace(fresh, decision.preview);
273
+ reconciledLandedIds.push(fresh.id);
274
+ continue;
275
+ }
276
+
277
+ updateWorkspaceStatus(fresh.id, "abandoned", { autoAbandoned: true, abandonedReason: `${fresh.status} TTL exceeded`, abandonedAt: now });
278
+ abandonedIds.push(fresh.id);
279
+ // #1025 C4 — route through the owner/coordinator/fallback custody chain (with a loud
280
+ // operator alarm when it resolves to nobody) instead of `stewardAgentId ?? fallback`.
281
+ // Most strandable workspaces have no steward, so the old notice silently dropped while
282
+ // the state change still happened.
283
+ const targets = routeWorkspaceCustodyEscalationOrAlarm({
284
+ source: "workspace-gc",
285
+ workspace: fresh,
286
+ type: "workspace.auto_abandoned",
287
+ subject: "Workspace auto-abandoned",
288
+ body: `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d ${fresh.status} without action. Run workspace cleanup to reclaim the worktree.`,
289
+ payload: { kind: "workspace.auto-abandoned", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status },
290
+ idempotencyKey: `workspace-auto-abandoned:${fresh.id}:${now}`,
291
+ });
292
+ if (targets.length) notifiedStewards.push(...targets);
293
+ createActivityEvent({
294
+ clientId: `workspace-gc-abandon-${fresh.id}-${now}`,
295
+ kind: "state",
296
+ title: "Workspace auto-abandoned",
297
+ body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - ${fresh.status} past the ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d TTL`,
298
+ meta: fresh.branch ?? fresh.id,
299
+ icon: "ti-clock-x",
300
+ view: "orchestrators",
301
+ metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, status: fresh.status },
302
+ });
272
303
  }
273
304
 
274
305
  const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.agentId);
@@ -2,16 +2,81 @@ import { getAgent, getDb } from "./db";
2
2
  import { getSpawnPolicy } from "./config-store";
3
3
  import { stewardFallbackTarget } from "./config";
4
4
  import { routeNotification } from "./notification-router";
5
+ import { raiseOperatorAlarm } from "./operator-alarm";
5
6
  import type { ManagedAgentState, Orchestrator, SpawnPolicy } from "./types";
6
7
 
7
8
  const ORCHESTRATOR_OFFLINE_ESCALATION_FAILURES = 3;
8
9
 
9
- export function escalateOfflineOrchestrator(policy: SpawnPolicy, state: ManagedAgentState, orch: Orchestrator | null | undefined): void {
10
- if (state.consecutiveFailures < ORCHESTRATOR_OFFLINE_ESCALATION_FAILURES) return;
10
+ function managedPolicyEscalationRecipients(policy: SpawnPolicy, state: ManagedAgentState): string[] {
11
11
  const updatedBy = getSpawnPolicy(policy.name)?.updatedBy?.trim();
12
12
  const previousParent = state.agentId ? getAgent(state.agentId)?.spawnedBy?.trim() : undefined;
13
13
  const fallback = stewardFallbackTarget();
14
- const recipients = [...new Set([updatedBy, previousParent, fallback].filter((item): item is string => Boolean(item && item !== "system")))];
14
+ return [...new Set([updatedBy, previousParent, fallback].filter((item): item is string => Boolean(item && item !== "system")))];
15
+ }
16
+
17
+ // #1021 A3 — the spawn chain used to escalate only the "orchestrator offline" branch; every
18
+ // OTHER terminal failure (crash-on-boot fast-fail storm → status `failed` → reconcilePolicy
19
+ // returns forever; a spawn that dispatched but never registered a live session → 120s timeout
20
+ // that backs off forever) emitted dashboard state only, so nobody was ever told the managed
21
+ // agent — often the repo steward that lands branches — is down. Route those to the same
22
+ // recipients as the offline branch, and when there is no reachable recipient raise an
23
+ // operator-visible alarm instead of silently dropping the signal.
24
+ export function escalateManagedSpawnFailure(
25
+ policy: SpawnPolicy,
26
+ state: ManagedAgentState,
27
+ opts: { phase: "failed" | "register-timeout"; error: string },
28
+ ): void {
29
+ const recipients = managedPolicyEscalationRecipients(policy, state);
30
+ const queuedMessages = queuedPolicyMessageCount(policy.name);
31
+ const subject = opts.phase === "failed"
32
+ ? "Managed policy failed and stopped retrying"
33
+ : "Managed policy spawn never registered";
34
+ const body = [
35
+ opts.phase === "failed"
36
+ ? `Managed policy \`${policy.name}\` has failed and will not retry on its own (${opts.error}).`
37
+ : `Managed policy \`${policy.name}\` keeps spawning but no session ever registers (${opts.error}).`,
38
+ `Queued messages: ${queuedMessages}. Consecutive failures: ${state.consecutiveFailures}.`,
39
+ ].join("\n");
40
+ const payload = {
41
+ kind: opts.phase === "failed" ? "managed-policy.failed" : "managed-policy.register-timeout",
42
+ policyName: policy.name,
43
+ orchestratorId: policy.orchestratorId,
44
+ queuedMessages,
45
+ consecutiveFailures: state.consecutiveFailures,
46
+ error: opts.error,
47
+ };
48
+ if (!recipients.length) {
49
+ raiseOperatorAlarm({
50
+ clientId: `managed-policy-alarm-${opts.phase}-${policy.name}-${policy.orchestratorId}`,
51
+ source: "lifecycle-manager",
52
+ subject,
53
+ detail: `${body}\nNo owner, coordinator, or fallback is reachable to page.`,
54
+ view: "managed-agents",
55
+ metadata: payload,
56
+ });
57
+ return;
58
+ }
59
+ routeNotification({
60
+ type: "agent.spawn_failed",
61
+ scope: { target: `policy:${policy.name}` },
62
+ recipients,
63
+ message: {
64
+ subject,
65
+ body,
66
+ payload,
67
+ // Once per failed episode (failed: this spawn's request id; register-timeout: once per
68
+ // policy+host) — dedups so a wedged policy pages the coordinator once, not every tick.
69
+ idempotencyKey: opts.phase === "failed"
70
+ ? `managed-policy-failed:${policy.name}:${state.spawnRequestId ?? state.consecutiveFailures}`
71
+ : `managed-policy-register-timeout:${policy.name}:${policy.orchestratorId}`,
72
+ replyExpected: false,
73
+ },
74
+ });
75
+ }
76
+
77
+ export function escalateOfflineOrchestrator(policy: SpawnPolicy, state: ManagedAgentState, orch: Orchestrator | null | undefined): void {
78
+ if (state.consecutiveFailures < ORCHESTRATOR_OFFLINE_ESCALATION_FAILURES) return;
79
+ const recipients = managedPolicyEscalationRecipients(policy, state);
15
80
  if (!recipients.length) return;
16
81
  const host = orch?.hostname ? `${orch.hostname} (${policy.orchestratorId})` : policy.orchestratorId;
17
82
  const queuedMessages = queuedPolicyMessageCount(policy.name);
@@ -12,7 +12,7 @@ import { countLiveSpawnedAgents, listAgents, listWorkspaces, ValidationError } f
12
12
  import { listCommands } from "../commands-db";
13
13
  import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
14
14
  import { optionalEnum } from "../validation";
15
- import { APPROVAL_MODES, AUTO_MERGE_POLICIES, SPAWN_PROVIDERS, stringValue, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
15
+ import { APPROVAL_MODES, AUTO_MERGE_POLICIES, REPORT_VERBOSITIES, SPAWN_PROVIDERS, stringValue, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
16
16
  import type { ProviderEffort } from "agent-relay-sdk/provider-catalog";
17
17
  import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMode, WorkspaceRecord } from "../types";
18
18
  import type { McpAuthContext, ToolDefinition } from "./types";
@@ -74,7 +74,9 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
74
74
  required: ["branch", "mode"],
75
75
  additionalProperties: false,
76
76
  },
77
+ reportVerbosity: { type: "string", enum: REPORT_VERBOSITIES, description: "#1037 — how much of this worker's turn output reaches you (the spawner). `quiet` (default): only the worker's turn-final report, and only once it goes idle/awaiting-input — mid-task narration is suppressed. `normal`: every turn-final response, forwarded immediately. `verbose`: every turn-final response plus all narration/reasoning/tool steps. Lifecycle signals (ready/exit/merge), escalations, and messages the worker addresses to you directly always arrive regardless." },
77
78
  waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 0 = fire-and-forget; you will be PUSHED agent.ready when the child is ready)." },
79
+ verbose: { type: "boolean", description: "#1033 — return the FULL spawn detail (resolved agent profile + provisioning manifest + the dispatched command payload) instead of the default lean receipt. Off by default: the lean receipt is `{ok, spawnRequestId, agentId, taskId, orchestratorId, provider, registered, status}` — everything you need to track/shutdown the child, without the ~50-60KB profile/manifest + triple prompt echo that blows the tool-result cap. Set true only when you actually need to inspect the resolved provisioning." },
78
80
  },
79
81
  required: ["provider"],
80
82
  additionalProperties: false,
@@ -166,21 +168,29 @@ export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string,
166
168
  requestedVia: "mcp",
167
169
  waitForRegistrationMs: optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 0,
168
170
  resumeWorkspace: parseResumeWorkspace(args.resumeWorkspace),
171
+ reportVerbosity: optionalEnum(args.reportVerbosity, "reportVerbosity", REPORT_VERBOSITIES),
169
172
  };
173
+ const verbose = optionalBoolean(args.verbose, "verbose") === true;
170
174
  const result = await spawnAgent(input, authContextFromMcp(auth));
171
- // #734return the lean shape callers need (spawnRequestId + agentId for idempotency/shutdown)
172
- // plus a sanitized command summary. The full resolved provisioning already went to the
173
- // orchestrator on the dispatched command; echoing it here blows the MCP result token cap.
174
- return {
175
+ // #1033LEAN RECEIPT by default. The full resolved provisioning (agent profile + manifest +
176
+ // plugin file listing) and the hydrated prompt (echoed three times across params.prompt,
177
+ // systemPromptAppend and relayInjectionEvents) already went to the orchestrator on the DISPATCHED
178
+ // command; echoing any of it back to the caller is ~50-60KB that blows the MCP result token cap
179
+ // (#1033) for zero orchestration value — the caller only needs the identity fields to track /
180
+ // shutdown the child. Full detail (behind #734's content-stripped summary) is opt-in via `verbose`.
181
+ const receipt: Record<string, unknown> = {
175
182
  ok: result.ok,
176
183
  spawnRequestId: result.spawnRequestId,
177
184
  orchestratorId: result.orchestratorId,
178
185
  provider: result.provider,
179
186
  agentId: result.agentId,
180
187
  registered: result.registered,
181
- command: summarizeSpawnCommandForCaller(result.command),
188
+ status: result.registered ? "registered" : "pending",
189
+ ...(result.taskId !== undefined ? { taskId: result.taskId } : {}),
182
190
  ...(result.task ? { task: result.task } : {}),
183
191
  };
192
+ if (verbose) receipt.command = summarizeSpawnCommandForCaller(result.command);
193
+ return receipt;
184
194
  }
185
195
 
186
196
  export function relaySpawnTargets(auth: McpAuthContext): Record<string, unknown> {
@@ -22,7 +22,7 @@ const PLAN_NODE_REFS_SCHEMA = {
22
22
  type: "object",
23
23
  properties: {
24
24
  taskId: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] },
25
- issueId: { type: "string" },
25
+ issueId: { type: "string", description: "issue_<id>, #number, owner/repo#number, or a GitHub issue URL. A bare #number resolves against the calling agent's own project repo (cwd → project → issueRepo), falling back to AGENT_RELAY_DEFAULT_ISSUE_REPO/GITHUB_REPOSITORY (#220) — it throws if neither is configured." },
26
26
  },
27
27
  additionalProperties: false,
28
28
  } as const;
@@ -56,7 +56,7 @@ const PLAN_EDGE_SCHEMA = {
56
56
  export const PLAN_TOOLS = [
57
57
  {
58
58
  name: "relay_plan_create",
59
- description: "Create a persisted, versioned plan DAG attached to a message thread/channel. MVP statusSource is always manual.",
59
+ description: "Create a persisted, versioned plan DAG attached to a message thread/channel. threadId is optional — omit it and a thread is minted server-side (#1035), so a fresh coordinator needn't send a throwaway message just to anchor a plan. MVP statusSource is always manual.",
60
60
  requiredScopes: ["plans:write"],
61
61
  inputSchema: {
62
62
  type: "object",
@@ -69,7 +69,6 @@ export const PLAN_TOOLS = [
69
69
  nodes: { type: "array", items: PLAN_NODE_SCHEMA },
70
70
  edges: { type: "array", items: PLAN_EDGE_SCHEMA },
71
71
  },
72
- required: ["threadId"],
73
72
  additionalProperties: false,
74
73
  },
75
74
  },
@@ -25,6 +25,7 @@ export const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = {
25
25
  agentReady: true,
26
26
  agentExited: true,
27
27
  agentSpawnFailed: true,
28
+ humanEscalationChannelId: "ntfy:default",
28
29
  contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG,
29
30
  quotaThreshold: DEFAULT_QUOTA_THRESHOLD_CONFIG,
30
31
  };
@@ -63,6 +64,7 @@ export const NOTIFICATIONS_SETTINGS_SCHEMA: JSONSchema = {
63
64
  agentReady: { type: "boolean" },
64
65
  agentExited: { type: "boolean" },
65
66
  agentSpawnFailed: { type: "boolean" },
67
+ humanEscalationChannelId: { type: "string", maxLength: 200 },
66
68
  contextThreshold: CONTEXT_THRESHOLD_SETTINGS_SCHEMA,
67
69
  quotaThreshold: QUOTA_THRESHOLD_SETTINGS_SCHEMA,
68
70
  },
@@ -103,6 +105,7 @@ export function validateNotificationSettingsLayer(value: unknown): NotificationS
103
105
  ...(bool("agentReady") !== undefined ? { agentReady: bool("agentReady")! } : {}),
104
106
  ...(bool("agentExited") !== undefined ? { agentExited: bool("agentExited")! } : {}),
105
107
  ...(bool("agentSpawnFailed") !== undefined ? { agentSpawnFailed: bool("agentSpawnFailed")! } : {}),
108
+ ...(value.humanEscalationChannelId === undefined ? {} : { humanEscalationChannelId: cleanOptionalString(value.humanEscalationChannelId, "humanEscalationChannelId", 200) }),
106
109
  ...(value.contextThreshold === undefined ? {} : { contextThreshold: validateContextThresholdConfig(value.contextThreshold) }),
107
110
  ...(value.quotaThreshold === undefined ? {} : { quotaThreshold: validateQuotaThresholdConfig(value.quotaThreshold) }),
108
111
  };
@@ -144,6 +147,14 @@ function cleanBoolean(value: unknown, field: string): boolean {
144
147
  return value;
145
148
  }
146
149
 
150
+ function cleanOptionalString(value: unknown, field: string, max: number): string {
151
+ if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
152
+ const clean = value.trim();
153
+ if (clean.length > max) throw new ValidationError(`${field} must be at most ${max} characters`);
154
+ if (clean.includes("\n") || clean.includes("\r")) throw new ValidationError(`${field} must not contain newlines`);
155
+ return clean;
156
+ }
157
+
147
158
  function cleanLayerId(value: string, field: string): string {
148
159
  const clean = value.trim();
149
160
  if (!clean) throw new ValidationError(`${field} required`);
@@ -28,6 +28,7 @@ export type NotificationType =
28
28
  | "agent.quota_threshold"
29
29
  | "agent.resume_budget_warning"
30
30
  | "agent.resume_budget_exhausted"
31
+ | "agent.resume_injection_failed"
31
32
  | "agent.transient_land_hold"
32
33
  | "agent.rate_limit_resume"
33
34
  | "agent.provider_blocked"
@@ -127,6 +128,9 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
127
128
  "agent.quota_threshold": 15 * MINUTES,
128
129
  "agent.resume_budget_warning": null,
129
130
  "agent.resume_budget_exhausted": null,
131
+ // #1023 — the agent may be running with an empty/stale context; durable like the other
132
+ // resume-lifecycle notices above, not a bystander ping that should silently expire.
133
+ "agent.resume_injection_failed": null,
130
134
  "agent.transient_land_hold": null,
131
135
  "agent.rate_limit_resume": 15 * MINUTES,
132
136
  "agent.provider_blocked": null,
@@ -0,0 +1,37 @@
1
+ import { createActivityEvent } from "./db";
2
+ import { emitRelayEvent } from "./events";
3
+
4
+ export interface OperatorAlarmInput {
5
+ /** Stable client id → repeated alarms for the same condition dedup instead of spamming the timeline. */
6
+ clientId: string;
7
+ source: string;
8
+ subject: string;
9
+ detail: string;
10
+ view?: string;
11
+ metadata?: Record<string, unknown>;
12
+ }
13
+
14
+ // A "louder", operator-visible signal for a condition that would otherwise be swallowed —
15
+ // specifically an escalation that resolved to ZERO reachable recipients (#1021 A3/A4). A
16
+ // normal escalation pages an agent; when there is no agent to page (owner exited,
17
+ // coordinator idle, no fallback configured) the escalation must not silently mark itself
18
+ // done. This raises a distinct `operator.alarm` relay event (dashboard banner) plus a
19
+ // persisted `operator`-kind activity event, so a relay with no reachable escalation target
20
+ // is observable instead of looping forever in the dark.
21
+ export function raiseOperatorAlarm(input: OperatorAlarmInput): void {
22
+ emitRelayEvent({
23
+ type: "operator.alarm",
24
+ source: input.source,
25
+ subject: input.subject,
26
+ data: { subject: input.subject, detail: input.detail, ...(input.metadata ?? {}) },
27
+ });
28
+ createActivityEvent({
29
+ clientId: input.clientId,
30
+ kind: "operator",
31
+ title: input.subject,
32
+ body: input.detail,
33
+ icon: "ti-alert-octagon",
34
+ view: input.view ?? "orchestrators",
35
+ metadata: { source: input.source, operatorAlarm: true, ...(input.metadata ?? {}) },
36
+ });
37
+ }
@@ -9,6 +9,7 @@ import { emitAgentRemoved, emitAgentStatus } from "../sse";
9
9
  import { notifyAgentReady } from "../agent-lifecycle-events";
10
10
  import { bindSpawnedTaskToReadyAgent } from "../services/register-agent";
11
11
  import { flushQueuedDirectMessages } from "../services/managed-running";
12
+ import { flushIdleLineageReport } from "../services/send-message";
12
13
  import { isAgentUnavailable } from "../db/agent-predicates";
13
14
  import { getCompactionWatch } from "../compaction-watch";
14
15
  import { isRecord } from "agent-relay-sdk";
@@ -198,6 +199,9 @@ export const patchAgentStatus: Handler = async (req, params) => {
198
199
  const before = getAgent(params.id!);
199
200
  if (!setStatus(params.id!, body.status as (typeof VALID_AGENT_STATUSES)[number], guard)) return error("agent not found", 404);
200
201
  auditAgentStateTransition(params.id!, before, getAgent(params.id!));
202
+ // #1040 — the HTTP status PATCH is also a busy→idle edge (a runner without a live bus socket).
203
+ // Flush any held turn-final report-up so it reaches the spawner on this path too.
204
+ if (before?.status === "busy" && body.status !== "busy") flushIdleLineageReport(params.id!);
201
205
  } catch (e) {
202
206
  if (e instanceof ValidationError) return error(e.message, 400);
203
207
  throw e;
@@ -5,7 +5,7 @@ import { authorizeRoute, cleanJsonArray, error, json, type Handler } from "./_sh
5
5
  import { cleanStringArray } from "../validation";
6
6
  import { emitOrchestratorStatus } from "../sse";
7
7
  import { type OrchestratorRuntimeInput, type SpawnProvider } from "../types";
8
- import { relayToken } from "../config";
8
+ import { isTrustedOrchestratorApiUrl, relayToken } from "../config";
9
9
  import { isTerminalSessionRequestAuthorized } from "../terminal-authz";
10
10
 
11
11
  export const getOrchestratorDirectories: Handler = async (req, params) => {
@@ -13,6 +13,9 @@ export const getOrchestratorDirectories: Handler = async (req, params) => {
13
13
  if (!orch) return error("orchestrator not found", 404);
14
14
  if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
15
15
  if (orch.status !== "online") return error("orchestrator is offline", 422);
16
+ // #1024 — never forward the relay master token (or the caller's query string) to an untrusted
17
+ // apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
18
+ if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
16
19
  const url = new URL(req.url);
17
20
  const path = url.searchParams.get("path") || "";
18
21
  const proxyUrl = `${orch.apiUrl}/api/directories${path ? `?path=${encodeURIComponent(path)}` : ""}`;
@@ -30,6 +33,9 @@ export const postOrchestratorCreateDirectory: Handler = async (req, params) => {
30
33
  if (!orch) return error("orchestrator not found", 404);
31
34
  if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
32
35
  if (orch.status !== "online") return error("orchestrator is offline", 422);
36
+ // #1024 — never forward the relay master token (or the caller's query string) to an untrusted
37
+ // apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
38
+ if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
33
39
  try {
34
40
  const body = await req.json();
35
41
  const res = await fetch(`${orch.apiUrl}/api/directories`, {
@@ -62,6 +68,9 @@ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path:
62
68
  if (!orch) return error("orchestrator not found", 404);
63
69
  if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
64
70
  if (orch.status !== "online") return error("orchestrator is offline", 422);
71
+ // #1024 — never forward the relay master token (or the caller's query string) to an untrusted
72
+ // apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
73
+ if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
65
74
  const incoming = new URL(req.url);
66
75
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
67
76
  const headers: Record<string, string> = {};
@@ -85,6 +94,9 @@ async function proxyOrchestratorPost(req: Request, orchestratorId: string, path:
85
94
  if (!orch) return error("orchestrator not found", 404);
86
95
  if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
87
96
  if (orch.status !== "online") return error("orchestrator is offline", 422);
97
+ // #1024 — never forward the relay master token (or the caller's query string) to an untrusted
98
+ // apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
99
+ if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
88
100
  const incoming = new URL(req.url);
89
101
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
90
102
  const headers: Record<string, string> = {
@@ -11,6 +11,7 @@ import { createCommand, updateCommand } from "../commands-db";
11
11
  import { emitAgentStatus, emitOrchestratorStatus } from "../sse";
12
12
  import { getLifecycleManager } from "../lifecycle-manager";
13
13
  import { issueOrchestratorRuntimeToken } from "../runtime-tokens";
14
+ import { isOrchestratorRegistrationAuthorized } from "../security";
14
15
  import { type ManagedAgent, type ManagedSessionExitDiagnostics, type OrchestratorRuntimeInput, type RegisterOrchestratorInput, type SpawnApprovalMode, type SpawnProvider } from "../types";
15
16
 
16
17
  function cleanProtocolVersion(value: unknown): number | undefined {
@@ -51,6 +52,9 @@ function cleanRuntimeCapabilities(value: unknown): RuntimeCapabilities | undefin
51
52
  }
52
53
 
53
54
  export const postOrchestrator: Handler = async (req) => {
55
+ // #1024 — registering/upserting an orchestrator is admin-class; a provider-agent runtime token
56
+ // must NOT be able to seed a rogue orchestrator row (its apiUrl is the master-token exfil sink).
57
+ if (!isOrchestratorRegistrationAuthorized(req)) return error("forbidden", 403);
54
58
  const parsed = await parseBody<unknown>(req);
55
59
  if (!parsed.ok) return error(parsed.error, parsed.status);
56
60
  try {