agent-relay-server 0.85.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DqFyach0.js → MessageBubble-CevL-2jc.js} +2 -2
  4. package/public/assets/{MessageBubble-DqFyach0.js.map → MessageBubble-CevL-2jc.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CCvKbYWe.js → PlanGraphPanel-DGpJhuVw.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CCvKbYWe.js.map → PlanGraphPanel-DGpJhuVw.js.map} +1 -1
  7. package/public/assets/{activity-C6RvknQQ.js → activity-CY2Cbpet.js} +2 -2
  8. package/public/assets/{activity-C6RvknQQ.js.map → activity-CY2Cbpet.js.map} +1 -1
  9. package/public/assets/{agent-profiles-RFCLAgnC.js → agent-profiles-CLJNeVxc.js} +2 -2
  10. package/public/assets/{agent-profiles-RFCLAgnC.js.map → agent-profiles-CLJNeVxc.js.map} +1 -1
  11. package/public/assets/{agents-D7HZJ5eG.js → agents-Bj5KzrYJ.js} +2 -2
  12. package/public/assets/{agents-D7HZJ5eG.js.map → agents-Bj5KzrYJ.js.map} +1 -1
  13. package/public/assets/{analytics-2H2z2UNS.js → analytics-C1F28oqr.js} +2 -2
  14. package/public/assets/{analytics-2H2z2UNS.js.map → analytics-C1F28oqr.js.map} +1 -1
  15. package/public/assets/{automation-BUazA3lr.js → automation-BOPk1oh_.js} +2 -2
  16. package/public/assets/{automation-BUazA3lr.js.map → automation-BOPk1oh_.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-DxwNcWr8.js → branch-state-badge-Q4W68jxj.js} +2 -2
  18. package/public/assets/{branch-state-badge-DxwNcWr8.js.map → branch-state-badge-Q4W68jxj.js.map} +1 -1
  19. package/public/assets/{channels-Sh_jdB7y.js → channels-BQbNtrtF.js} +2 -2
  20. package/public/assets/{channels-Sh_jdB7y.js.map → channels-BQbNtrtF.js.map} +1 -1
  21. package/public/assets/{chat-DBTEaf-H.js → chat-D5pI4ebq.js} +2 -2
  22. package/public/assets/{chat-DBTEaf-H.js.map → chat-D5pI4ebq.js.map} +1 -1
  23. package/public/assets/{connectors-C3C4i4Gz.js → connectors-CmARSKoV.js} +2 -2
  24. package/public/assets/{connectors-C3C4i4Gz.js.map → connectors-CmARSKoV.js.map} +1 -1
  25. package/public/assets/display-CWMHll1J.js.map +1 -1
  26. package/public/assets/{formatted-body-impl-KG0iExZ-.js → formatted-body-impl-Dr_z1UvS.js} +2 -2
  27. package/public/assets/{formatted-body-impl-KG0iExZ-.js.map → formatted-body-impl-Dr_z1UvS.js.map} +1 -1
  28. package/public/assets/{index-TZskQfQL.js → index-CEXhh5nz.js} +6 -6
  29. package/public/assets/{index-TZskQfQL.js.map → index-CEXhh5nz.js.map} +1 -1
  30. package/public/assets/{insights-JL1s7Uul.js → insights-C4dEK49e.js} +2 -2
  31. package/public/assets/{insights-JL1s7Uul.js.map → insights-C4dEK49e.js.map} +1 -1
  32. package/public/assets/{integrations-PqWMrPJa.js → integrations-D9Cx-7ma.js} +2 -2
  33. package/public/assets/{integrations-PqWMrPJa.js.map → integrations-D9Cx-7ma.js.map} +1 -1
  34. package/public/assets/{maintenance-CTMfhXFM.js → maintenance-DJZUSsja.js} +2 -2
  35. package/public/assets/{maintenance-CTMfhXFM.js.map → maintenance-DJZUSsja.js.map} +1 -1
  36. package/public/assets/{managed-agents-C4cb3xQ4.js → managed-agents-5wUMDKVi.js} +2 -2
  37. package/public/assets/{managed-agents-C4cb3xQ4.js.map → managed-agents-5wUMDKVi.js.map} +1 -1
  38. package/public/assets/{markdown-preview-impl-DQIi0O7r.js → markdown-preview-impl-DOJQR3cO.js} +2 -2
  39. package/public/assets/{markdown-preview-impl-DQIi0O7r.js.map → markdown-preview-impl-DOJQR3cO.js.map} +1 -1
  40. package/public/assets/{memory-DJPXFdgw.js → memory-BnhAD09c.js} +2 -2
  41. package/public/assets/{memory-DJPXFdgw.js.map → memory-BnhAD09c.js.map} +1 -1
  42. package/public/assets/{messages-RSD2g4Wx.js → messages-Cc9YF8Wl.js} +2 -2
  43. package/public/assets/{messages-RSD2g4Wx.js.map → messages-Cc9YF8Wl.js.map} +1 -1
  44. package/public/assets/{orchestrators-BGIkULBF.js → orchestrators-B01iyiBA.js} +2 -2
  45. package/public/assets/{orchestrators-BGIkULBF.js.map → orchestrators-B01iyiBA.js.map} +1 -1
  46. package/public/assets/{overview-ByE9V8pe.js → overview-DN8CUTVx.js} +2 -2
  47. package/public/assets/{overview-ByE9V8pe.js.map → overview-DN8CUTVx.js.map} +1 -1
  48. package/public/assets/{pairs-C284ntvD.js → pairs-BxuDMLhs.js} +2 -2
  49. package/public/assets/{pairs-C284ntvD.js.map → pairs-BxuDMLhs.js.map} +1 -1
  50. package/public/assets/{projects-B2-8Eu0e.js → projects-hU5KGW_T.js} +2 -2
  51. package/public/assets/{projects-B2-8Eu0e.js.map → projects-hU5KGW_T.js.map} +1 -1
  52. package/public/assets/{security-vDglScsg.js → security-CO0PBIau.js} +2 -2
  53. package/public/assets/{security-vDglScsg.js.map → security-CO0PBIau.js.map} +1 -1
  54. package/public/assets/{select-DaOiWDBR.js → select-DHaFsLvt.js} +2 -2
  55. package/public/assets/{select-DaOiWDBR.js.map → select-DHaFsLvt.js.map} +1 -1
  56. package/public/assets/{settings-DiLcbSqx.js → settings-De1DKwd6.js} +2 -2
  57. package/public/assets/{settings-DiLcbSqx.js.map → settings-De1DKwd6.js.map} +1 -1
  58. package/public/assets/{store-DYaQAXRv.js → store-DsqUEZOf.js} +2 -2
  59. package/public/assets/store-DsqUEZOf.js.map +1 -0
  60. package/public/assets/{tasks-DWJdJra3.js → tasks-Bqd188ql.js} +2 -2
  61. package/public/assets/{tasks-DWJdJra3.js.map → tasks-Bqd188ql.js.map} +1 -1
  62. package/public/assets/{teams-FejXkix4.js → teams-Mh6ypDPr.js} +2 -2
  63. package/public/assets/{teams-FejXkix4.js.map → teams-Mh6ypDPr.js.map} +1 -1
  64. package/public/assets/{terminal-viewer-impl-Bkt1w42o.js → terminal-viewer-impl-B7x_JNZe.js} +2 -2
  65. package/public/assets/{terminal-viewer-impl-Bkt1w42o.js.map → terminal-viewer-impl-B7x_JNZe.js.map} +1 -1
  66. package/public/assets/{user-avatar-ClE2dMD8.js → user-avatar-Dy5J3E_b.js} +2 -2
  67. package/public/assets/{user-avatar-ClE2dMD8.js.map → user-avatar-Dy5J3E_b.js.map} +1 -1
  68. package/public/assets/{work-queue-DZdv9kOb.js → work-queue-ClGgOUSu.js} +2 -2
  69. package/public/assets/{work-queue-DZdv9kOb.js.map → work-queue-ClGgOUSu.js.map} +1 -1
  70. package/public/assets/{workspaces-BhblC-Nd.js → workspaces-C740m1ys.js} +2 -2
  71. package/public/assets/{workspaces-BhblC-Nd.js.map → workspaces-C740m1ys.js.map} +1 -1
  72. package/public/index.html +2 -2
  73. package/runner/src/adapter.ts +4 -3
  74. package/src/agent-lifecycle-events.ts +6 -1
  75. package/src/branch-landed.ts +81 -0
  76. package/src/cli/index.ts +3 -3
  77. package/src/cli/reply.ts +6 -1
  78. package/src/config.ts +4 -0
  79. package/src/db/index.ts +1 -0
  80. package/src/db/migrations.ts +17 -0
  81. package/src/db/provider-quotas.ts +99 -21
  82. package/src/db/terminal-events.ts +56 -0
  83. package/src/maintenance/constants.ts +4 -0
  84. package/src/maintenance/jobs.ts +13 -0
  85. package/src/maintenance/teams.ts +8 -3
  86. package/src/maintenance/workspaces.ts +23 -10
  87. package/src/mcp/index.ts +2 -1
  88. package/src/mcp/tools-spawn.ts +119 -4
  89. package/src/notification-types.ts +2 -0
  90. package/src/provider-catalog-store.ts +3 -0
  91. package/src/routes/workspaces.ts +5 -0
  92. package/src/services/transient-agent-reaper.ts +4 -1
  93. package/src/spawn-targets.ts +8 -2
  94. package/src/steward-identity.ts +50 -0
  95. package/src/steward.ts +12 -4
  96. package/src/workspace-auto-merge.ts +35 -6
  97. package/src/workspace-pr-completion.ts +26 -0
  98. package/public/assets/store-DYaQAXRv.js.map +0 -1
@@ -257,6 +257,87 @@ export function notifyBranchLandFailed(input: {
257
257
  });
258
258
  }
259
259
 
260
+ /**
261
+ * Steward-handoff awareness wake (#641/#642). When the relay can't auto-merge a branch and
262
+ * hands the workspace to a steward, the two ends used to be mutually blind: the branch agent
263
+ * was told only that land failed (never which steward holds it), and its coordinator — the
264
+ * agent responsible for the merge landing — was never looped in at all. This fires BOTH
265
+ * notices off one handoff event:
266
+ * - the OWNER branch agent learns which steward took over (#641), and
267
+ * - the branch agent's COORDINATOR (owner's `spawnedBy`, resolved in steward-identity.ts)
268
+ * is proactively notified so it keeps the big picture (#642).
269
+ * The steward id is the managed steward's policy name (reachable via `policy:<name>`) or an
270
+ * elected steward's agent id, whichever the caller has in scope.
271
+ */
272
+ export function notifyStewardHandoff(input: {
273
+ workspace: Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "ownerAgentId">;
274
+ /** The steward now holding the merge — managed steward policy name or elected agent id. */
275
+ stewardId: string;
276
+ /** The branch agent's coordinator (owner's `spawnedBy` lineage), or null when none resolves. */
277
+ coordinatorAgentId?: string | null;
278
+ /** Why it was handed over ("conflict", "merge state unknown", auto-merge back-off, …). */
279
+ reason?: string;
280
+ }): void {
281
+ const { workspace } = input;
282
+ const base = workspace.baseRef ?? "base";
283
+ const owner = workspace.ownerAgentId;
284
+ const coordinator = input.coordinatorAgentId?.trim() || undefined;
285
+ const branchLabel = workspace.branch ? `\`${workspace.branch}\`` : "Your branch";
286
+ const reasonLabel = input.reason ? ` (${input.reason})` : "";
287
+ const payload = {
288
+ kind: "workspace.steward-handoff",
289
+ workspaceId: workspace.id,
290
+ repoRoot: workspace.repoRoot,
291
+ branch: workspace.branch,
292
+ base,
293
+ author: owner,
294
+ stewardId: input.stewardId,
295
+ coordinatorAgentId: coordinator,
296
+ reason: input.reason,
297
+ };
298
+
299
+ emitRelayEvent({ type: "workspace.steward_handoff", source: "server", subject: workspace.id, data: payload });
300
+
301
+ const config = getNotificationsConfig();
302
+ if (!config.enabled) return;
303
+
304
+ // #641 — tell the owner branch agent which steward now holds its merge, so it stops
305
+ // thinking the branch silently failed. Durable (ttl null): the owner must learn this
306
+ // even if it went idle. Mirror the "don't touch main" guardrail of the landed-failure wake.
307
+ if (owner) {
308
+ routeNotification({
309
+ type: "workspace.steward_handoff",
310
+ scope: { author: owner, repoRoot: workspace.repoRoot, workspaceId: workspace.id },
311
+ recipients: [owner],
312
+ ttlMs: null,
313
+ message: {
314
+ subject: "Your merge is now with a steward",
315
+ body: `🛟 ${branchLabel} couldn't be auto-merged to \`${base}\` and is now being handled by steward agent \`${input.stewardId}\`${reasonLabel}. The steward will rebase/resolve and land it. Do NOT push, rebase, merge, or touch the main checkout yourself; stay idle until you're woken with the result.`,
316
+ payload: { ...payload, notification: "steward-handoff" },
317
+ replyExpected: false,
318
+ },
319
+ });
320
+ }
321
+
322
+ // #642 — proactively loop in the branch agent's coordinator so it keeps the big picture
323
+ // (it spawned the branch agent and owns the merge landing). Skip when the coordinator IS
324
+ // the owner (no real lineage) to avoid a duplicate ping.
325
+ if (coordinator && coordinator !== owner) {
326
+ routeNotification({
327
+ type: "workspace.steward_handoff",
328
+ scope: { author: owner, parent: coordinator, repoRoot: workspace.repoRoot, workspaceId: workspace.id },
329
+ recipients: [coordinator],
330
+ ttlMs: null,
331
+ message: {
332
+ subject: "A branch agent's merge is now with a steward",
333
+ body: `🛟 Your branch agent \`${owner ?? "?"}\`'s merge (${branchLabel} → \`${base}\`) couldn't be auto-merged and is now with steward agent \`${input.stewardId}\`${reasonLabel}. The steward will land it or escalate to you if it can't.`,
334
+ payload: { ...payload, notification: "steward-handoff", audience: "coordinator" },
335
+ replyExpected: false,
336
+ },
337
+ });
338
+ }
339
+ }
340
+
260
341
  /**
261
342
  * Finalize a workspace whose work landed in base out-of-band — a pr-strategy land whose
262
343
  * PR merged (by anyone, incl. `gh pr merge`), or a squash/cherry-pick already in base
package/src/cli/index.ts CHANGED
@@ -72,7 +72,7 @@ Pair examples:
72
72
  agent-relay /message codex "Can you look at that failing action?"
73
73
  agent-relay /reply 206 "Sounds good, I'll take a look"
74
74
  agent-relay /react 206 👍
75
- agent-relay /reply 206 --stdin < result.md
75
+ agent-relay /reply 206 --body-file result.md
76
76
  agent-relay get-message 206 --json
77
77
  agent-relay /reply 206 --format markdown "Here is **formatted** text"
78
78
  agent-relay /send-claimable tag:backend "Please claim and fix the failing API test"
@@ -166,8 +166,8 @@ Current session
166
166
 
167
167
  Replying
168
168
  agent-relay /reply <messageId> "<response>"
169
- agent-relay /reply <messageId> --stdin < response.md
170
169
  agent-relay /reply <messageId> --body-file response.md
170
+ printf '%s\\n' "<response>" | agent-relay /reply <messageId> --stdin
171
171
  Reply to the sender of a relay message. Prefer this over /message when
172
172
  handling an incoming relay message because the server keeps the routing
173
173
  and channel context. Large replies are uploaded as an artifact
@@ -246,7 +246,7 @@ Rules of thumb
246
246
  agent-relay get-message 123
247
247
 
248
248
  If your reply is long, avoid shell quoting and use:
249
- agent-relay /reply 123 --stdin < response.md
249
+ agent-relay /reply 123 --body-file response.md
250
250
 
251
251
  If you need to know who you are in Relay, run:
252
252
  agent-relay /status --json
package/src/cli/reply.ts CHANGED
@@ -41,6 +41,7 @@ export async function handleReplyCommand(args: string[]): Promise<void> {
41
41
  const body = (await resolveBodyInput({ bodyParts, stdinBody, bodyFile })).trim();
42
42
  if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
43
43
  if (!body) throw new Error("Reply body required.");
44
+ if (body === "--stdin") throw new Error('Refusing to send literal "--stdin" as a reply body. Provide real reply text, use --body-file PATH, or pipe non-empty content into --stdin.');
44
45
 
45
46
  const prepared = await prepareReplyBody({ body, from, replyTo, format, subject });
46
47
  const message = await apiRequest("POST", "/api/messages", {
@@ -75,7 +76,11 @@ async function resolveBodyInput(options: {
75
76
  options.bodyFile ? "body file" : "",
76
77
  ].filter(Boolean);
77
78
  if (sources.length > 1) throw new Error("Reply body must come from exactly one source: arguments, --stdin, or --body-file.");
78
- if (options.stdinBody) return readStdin();
79
+ if (options.stdinBody) {
80
+ const body = await readStdin();
81
+ if (!body.trim()) throw new Error("--stdin was given but no input was received.");
82
+ return body;
83
+ }
79
84
  if (options.bodyFile) return readFileSync(resolve(options.bodyFile), "utf8");
80
85
  return options.bodyParts.join(" ");
81
86
  }
package/src/config.ts CHANGED
@@ -129,6 +129,10 @@ export function maxArtifactBytesFromEnv(fallback: number): number {
129
129
  }
130
130
 
131
131
  export const OUTBOX_RETENTION_MS = envNumberOrDefault("AGENT_RELAY_OUTBOX_RETENTION_MS", 60 * 60 * 1000);
132
+ // Per-provider quota history retention (#609) — durable burn-rate history for the sparkline/analytics
133
+ // charts (#607/#608). Default 30 days; the quota-history-prune maintenance reaper drops older entries.
134
+ export const QUOTA_HISTORY_RETENTION_MS = envNumberOrDefault("AGENT_RELAY_QUOTA_HISTORY_RETENTION_MS", 30 * DAY_MS);
135
+ export const QUOTA_HISTORY_PRUNE_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_QUOTA_HISTORY_PRUNE_INTERVAL_MS", 6 * 60 * 60 * 1000);
132
136
  export const TOKEN_RECORD_RETENTION_SECONDS = envNumberOrDefault("AGENT_RELAY_TOKEN_RECORD_RETENTION_SECONDS", 7 * 24 * 60 * 60);
133
137
  export const CONFLICT_SCAN_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_CONFLICT_SCAN_INTERVAL_MS", 10 * 60 * 1000);
134
138
  export const WORKSPACE_RETENTION_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_RETENTION_MS", DAY_MS);
package/src/db/index.ts CHANGED
@@ -28,6 +28,7 @@ export * from "./merge-lease.ts";
28
28
  export * from "./continuations.ts";
29
29
  export * from "./continuation-archives.ts";
30
30
  export * from "./crash-reports.ts";
31
+ export * from "./terminal-events.ts";
31
32
  export * from "./plans.ts";
32
33
  export * from "./teams.ts";
33
34
  export * from "./blackboard.ts";
@@ -21,6 +21,7 @@ import { ensureAgentOwnershipSchema, seedDefaultAgentOwnership } from "./agent-o
21
21
  import { ensureDriveLeaseSchema } from "./drive-leases.ts";
22
22
  import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
23
23
  import { ensureCrashReportsSchema } from "./crash-reports.ts";
24
+ import { ensureTerminalEventsSchema } from "./terminal-events.ts";
24
25
  import { normalizeReactionEmoji } from "./mappers.ts";
25
26
  import { normalizeProviderQuotaAccountKeys } from "./provider-quotas.ts";
26
27
  import { projectRootsMissingIssueRepo, setProjectIssueRepo } from "./projects.ts";
@@ -657,6 +658,17 @@ export function applyMigrations(): void {
657
658
  `);
658
659
  getDb().run("DROP TABLE quota_snapshots_legacy_agent");
659
660
  }
661
+ // #609 — durable 30-day quota history needs explicit "couldn't collect" markers so gaps are
662
+ // first-class, not silent absences. An unavailable row carries no quota: `unavailable=1` plus the
663
+ // skip reason in `last_error` (the NOT NULL windows/source/quota_state columns get inert placeholders
664
+ // that `rowToSnapshot` never reads when `unavailable=1`).
665
+ const quotaSnapshotColsNow = (getDb().query("PRAGMA table_info(quota_snapshots)").all() as Array<{ name: string }>).map((col) => col.name);
666
+ if (!quotaSnapshotColsNow.includes("unavailable")) {
667
+ getDb().run("ALTER TABLE quota_snapshots ADD COLUMN unavailable INTEGER NOT NULL DEFAULT 0");
668
+ }
669
+ if (!quotaSnapshotColsNow.includes("last_error")) {
670
+ getDb().run("ALTER TABLE quota_snapshots ADD COLUMN last_error TEXT");
671
+ }
660
672
  getDb().run(`
661
673
  CREATE TABLE IF NOT EXISTS provider_quotas (
662
674
  provider TEXT NOT NULL,
@@ -800,6 +812,11 @@ export function applyMigrations(): void {
800
812
  // the offline-prune/reaper and stays retrievable by agentId.
801
813
  ensureCrashReportsSchema();
802
814
 
815
+ // #645 — clean terminal markers (sibling of crash_reports). Own table, no FK, so an EXPECTED
816
+ // terminus (transient reap after land, graceful shutdown) outlives the reaper and lets the #634
817
+ // watchdog tell a benign completion from a silent death. Idempotent CREATE IF NOT EXISTS.
818
+ ensureTerminalEventsSchema();
819
+
803
820
  // First-class human users (#388 keystone #393): ensure the table (single DDL home in users.ts)
804
821
  // and seed the admin-level default user mapped to the existing single human. P0 + idempotent —
805
822
  // bare 'user' == user:default resolve to users.id='default' via the SDK resolver; no bytes move.
@@ -1,4 +1,4 @@
1
- import { DAY_MS } from "../config";
1
+ import { DAY_MS, QUOTA_HISTORY_RETENTION_MS } from "../config";
2
2
  import { parseJson } from "../utils";
3
3
  import { getDb } from "./connection.ts";
4
4
  import { parseStringArray } from "./shared.ts";
@@ -41,6 +41,8 @@ type ProviderQuotaSnapshotRow = {
41
41
  agent_id?: string | null;
42
42
  quota_state: string;
43
43
  captured_at: number;
44
+ unavailable?: number | null;
45
+ last_error?: string | null;
44
46
  };
45
47
 
46
48
  function cleanKey(value: string): string {
@@ -184,11 +186,19 @@ function quotaFingerprint(quota: QuotaState): string {
184
186
  }
185
187
 
186
188
  function rowToSnapshot(row: ProviderQuotaSnapshotRow): ProviderQuotaSnapshot {
187
- return {
189
+ const base = {
188
190
  provider: row.provider,
189
191
  accountKey: row.account_key,
190
192
  capturedAt: row.captured_at,
191
193
  ...(row.agent_id ? { sourceAgentId: row.agent_id } : {}),
194
+ };
195
+ // #609 — an unavailable marker carries no quota; the placeholder windows/source/quota_state are inert.
196
+ if (row.unavailable) {
197
+ const error = parseJson<ProviderQuotaError | undefined>(row.last_error, undefined);
198
+ return { ...base, unavailable: true, ...(error ? { error } : {}) };
199
+ }
200
+ return {
201
+ ...base,
192
202
  quota: parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: row.captured_at }),
193
203
  };
194
204
  }
@@ -214,7 +224,7 @@ function rowToRecord(row: ProviderQuotaRow, history: ProviderQuotaSnapshot[], no
214
224
  function latestSnapshotFingerprint(provider: string, accountKey: string): string | undefined {
215
225
  const row = getDb().query(`
216
226
  SELECT quota_state FROM quota_snapshots
217
- WHERE provider = ? AND account_key = ?
227
+ WHERE provider = ? AND account_key = ? AND unavailable = 0
218
228
  ORDER BY captured_at DESC, id DESC
219
229
  LIMIT 1
220
230
  `).get(provider, accountKey) as { quota_state: string } | undefined;
@@ -287,6 +297,11 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
287
297
 
288
298
  if (input.quota && latestSnapshotFingerprint(provider, accountKey) !== quotaFingerprint(input.quota)) {
289
299
  recordProviderQuotaSnapshot({ provider, accountKey, quota: input.quota, sourceAgentId: input.sourceAgentId }, now);
300
+ } else if (!input.quota && input.lastError?.type === "unavailable" && !latestSnapshotIsUnavailable(provider, accountKey)) {
301
+ // #609 — the provider couldn't be collected (orchestrator skip, #601). Record one explicit gap
302
+ // marker on the transition into unavailable (deduped while it stays unavailable) so history shows
303
+ // an honest gap rather than a silent absence; the next successful poll closes the gap.
304
+ recordProviderQuotaUnavailable({ provider, accountKey, error: input.lastError, sourceAgentId: input.sourceAgentId }, now);
290
305
  }
291
306
  if (hasFreshSuccessfulProviderQuota(input, now)) {
292
307
  const quotaAdvisoryState = providerQuotaAdvisoryHandler?.({
@@ -310,32 +325,65 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
310
325
  return listProviderQuotas({ provider, accountKey, historyLimit: 24, now }).quotas[0]!;
311
326
  }
312
327
 
328
+ function resolveSnapshotAgentId(sourceAgentId: string | undefined): string | undefined {
329
+ return sourceAgentId && getDb().query("SELECT 1 FROM agents WHERE id = ?").get(sourceAgentId) ? sourceAgentId : undefined;
330
+ }
331
+
313
332
  function recordProviderQuotaSnapshot(input: { provider: string; accountKey: string; quota: QuotaState; sourceAgentId?: string }, now: number): void {
314
- const sourceAgentId = input.sourceAgentId && getDb().query("SELECT 1 FROM agents WHERE id = ?").get(input.sourceAgentId)
315
- ? input.sourceAgentId
316
- : undefined;
317
333
  getDb().query(`
318
- INSERT INTO quota_snapshots (provider, account_key, agent_id, windows, source, quota_state, captured_at)
319
- VALUES (?, ?, ?, ?, ?, ?, ?)
334
+ INSERT INTO quota_snapshots (provider, account_key, agent_id, windows, source, quota_state, unavailable, last_error, captured_at)
335
+ VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?)
320
336
  `).run(
321
337
  input.provider,
322
338
  input.accountKey,
323
- sourceAgentId ?? null,
339
+ resolveSnapshotAgentId(input.sourceAgentId) ?? null,
324
340
  JSON.stringify(input.quota.windows),
325
341
  input.quota.source,
326
342
  JSON.stringify(input.quota),
327
343
  now,
328
344
  );
329
- pruneProviderQuotaSnapshots(input.provider, input.accountKey, now - DAY_MS);
345
+ pruneProviderQuotaSnapshots(input.provider, input.accountKey, now - QUOTA_HISTORY_RETENTION_MS);
346
+ }
347
+
348
+ // #609 — an unavailable marker is a quota-less history row: `unavailable=1` + the skip reason in
349
+ // last_error. windows/source/quota_state are NOT NULL, so they carry inert placeholders that
350
+ // rowToSnapshot never reads for unavailable rows.
351
+ function recordProviderQuotaUnavailable(input: { provider: string; accountKey: string; error: ProviderQuotaError; sourceAgentId?: string }, now: number): void {
352
+ getDb().query(`
353
+ INSERT INTO quota_snapshots (provider, account_key, agent_id, windows, source, quota_state, unavailable, last_error, captured_at)
354
+ VALUES (?, ?, ?, '[]', 'unavailable', 'null', 1, ?, ?)
355
+ `).run(
356
+ input.provider,
357
+ input.accountKey,
358
+ resolveSnapshotAgentId(input.sourceAgentId) ?? null,
359
+ JSON.stringify(input.error),
360
+ now,
361
+ );
362
+ pruneProviderQuotaSnapshots(input.provider, input.accountKey, now - QUOTA_HISTORY_RETENTION_MS);
363
+ }
364
+
365
+ function latestSnapshotIsUnavailable(provider: string, accountKey: string): boolean {
366
+ const row = getDb().query(`
367
+ SELECT unavailable FROM quota_snapshots
368
+ WHERE provider = ? AND account_key = ?
369
+ ORDER BY captured_at DESC, id DESC
370
+ LIMIT 1
371
+ `).get(provider, accountKey) as { unavailable?: number | null } | undefined;
372
+ return Boolean(row?.unavailable);
330
373
  }
331
374
 
332
- function pruneProviderQuotaSnapshots(provider?: string, accountKey?: string, olderThan = Date.now() - DAY_MS): number {
375
+ function pruneProviderQuotaSnapshots(provider?: string, accountKey?: string, olderThan = Date.now() - QUOTA_HISTORY_RETENTION_MS): number {
333
376
  const result = provider && accountKey
334
377
  ? getDb().query("DELETE FROM quota_snapshots WHERE provider = ? AND account_key = ? AND captured_at < ?").run(provider, accountKey, olderThan)
335
378
  : getDb().query("DELETE FROM quota_snapshots WHERE captured_at < ?").run(olderThan);
336
379
  return result.changes;
337
380
  }
338
381
 
382
+ // Maintenance reaper hook (#609): drop quota history beyond the 30-day retention window across all providers.
383
+ export function pruneProviderQuotaHistory(olderThan = Date.now() - QUOTA_HISTORY_RETENTION_MS): number {
384
+ return pruneProviderQuotaSnapshots(undefined, undefined, olderThan);
385
+ }
386
+
339
387
  function pruneProviderQuotaRecords(provider?: string, accountKey?: string, olderThan = Date.now() - DAY_MS): number {
340
388
  const result = provider && accountKey
341
389
  ? getDb().query("DELETE FROM provider_quotas WHERE provider = ? AND account_key = ? AND updated_at < ?").run(provider, accountKey, olderThan)
@@ -387,10 +435,13 @@ function suppressRedundantUnavailableRows(rows: ProviderQuotaRow[]): ProviderQuo
387
435
  return rows.filter((row) => !(isUnavailableMarkerRow(row) && collectableProviders.has(row.provider)));
388
436
  }
389
437
 
390
- function listProviderQuotaSnapshots(provider: string, accountKey: string, options: { since?: number; limit?: number } = {}): ProviderQuotaSnapshot[] {
391
- const limit = Math.max(1, Math.min(options.limit ?? 288, 1000));
438
+ const QUOTA_HISTORY_MAX_ROWS = 10_000;
439
+
440
+ function listProviderQuotaSnapshots(provider: string, accountKey: string, options: { since?: number; limit?: number; includeUnavailable?: boolean } = {}): ProviderQuotaSnapshot[] {
441
+ const limit = Math.max(1, Math.min(options.limit ?? 288, QUOTA_HISTORY_MAX_ROWS));
392
442
  const params: any[] = [normalizeProvider(provider), cleanKey(accountKey)];
393
- let sql = "SELECT * FROM quota_snapshots WHERE provider = ? AND account_key = ?";
443
+ // The live matrix/widget history is quota-only; the 30-day history API opts into gap markers.
444
+ let sql = `SELECT * FROM quota_snapshots WHERE provider = ? AND account_key = ?${options.includeUnavailable ? "" : " AND unavailable = 0"}`;
394
445
  if (options.since !== undefined) {
395
446
  sql += " AND captured_at >= ?";
396
447
  params.push(options.since);
@@ -400,15 +451,42 @@ function listProviderQuotaSnapshots(provider: string, accountKey: string, option
400
451
  return (getDb().query(sql).all(...params) as ProviderQuotaSnapshotRow[]).map(rowToSnapshot);
401
452
  }
402
453
 
454
+ /**
455
+ * #609 — durable 30-day per-provider quota history (the read API the #607 sparkline + #608 analytics
456
+ * chart consume). Returns snapshots oldest-first across all of the provider's canonical accountKeys,
457
+ * including explicit `unavailable` gap markers (`quota` absent, `unavailable: true`, `error` set).
458
+ * Group by `accountKey` for a per-account series; chart `quota.windows[].utilization` over `capturedAt`.
459
+ */
460
+ export function getProviderQuotaHistory(provider: string, options: { sinceMs?: number; accountKey?: string; limit?: number } = {}): ProviderQuotaSnapshot[] {
461
+ const normalizedProvider = normalizeProvider(provider);
462
+ if (!normalizedProvider) return [];
463
+ const limit = Math.max(1, Math.min(options.limit ?? QUOTA_HISTORY_MAX_ROWS, QUOTA_HISTORY_MAX_ROWS));
464
+ const params: any[] = [normalizedProvider];
465
+ let sql = "SELECT * FROM quota_snapshots WHERE provider = ?";
466
+ if (options.accountKey) {
467
+ sql += " AND account_key = ?";
468
+ params.push(cleanKey(options.accountKey));
469
+ }
470
+ if (options.sinceMs !== undefined) {
471
+ sql += " AND captured_at >= ?";
472
+ params.push(options.sinceMs);
473
+ }
474
+ sql += " ORDER BY captured_at ASC, id ASC LIMIT ?";
475
+ params.push(limit);
476
+ return (getDb().query(sql).all(...params) as ProviderQuotaSnapshotRow[]).map(rowToSnapshot);
477
+ }
478
+
403
479
  export function listAgentQuotaSnapshotsFromProviderStore(agent: AgentCard, options: { since?: number; limit?: number } = {}) {
404
480
  const identity = providerQuotaIdentityForAgent(agent);
405
- return listProviderQuotaSnapshots(identity.provider, identity.accountKey, options).map((snapshot) => ({
406
- agentId: agent.id,
407
- capturedAt: snapshot.capturedAt,
408
- windows: snapshot.quota.windows,
409
- source: snapshot.quota.source,
410
- updatedAt: snapshot.quota.updatedAt,
411
- }));
481
+ return listProviderQuotaSnapshots(identity.provider, identity.accountKey, options)
482
+ .filter((snapshot): snapshot is ProviderQuotaSnapshot & { quota: QuotaState } => Boolean(snapshot.quota))
483
+ .map((snapshot) => ({
484
+ agentId: agent.id,
485
+ capturedAt: snapshot.capturedAt,
486
+ windows: snapshot.quota.windows,
487
+ source: snapshot.quota.source,
488
+ updatedAt: snapshot.quota.updatedAt,
489
+ }));
412
490
  }
413
491
 
414
492
  export function listProviderQuotas(options: { provider?: string; accountKey?: string; since?: number; historyLimit?: number; now?: number } = {}): ProviderQuotaMatrix {
@@ -0,0 +1,56 @@
1
+ // #645 — persisted CLEAN terminal markers. A sibling of crash_reports: where crash_reports record a
2
+ // DEATH (with a diagnosis), this records an EXPECTED, clean terminus — a transient worker reaped
3
+ // after a land, a graceful shutdown, a spawn that failed to come up. Same survival property: its own
4
+ // table, no FK to agents, so the marker outlives the offline-prune/reaper and stays retrievable by
5
+ // agentId. The #634 heartbeat-gap watchdog consults it to tell a benign completion from a silent
6
+ // death — the watchdog must fire ONLY when an agent went quiet with NO terminal event of any kind.
7
+ import { getDb } from "./connection.ts";
8
+
9
+ interface TerminalEventRow {
10
+ agent_id: string;
11
+ reason: string;
12
+ kind: string;
13
+ created_at: number;
14
+ }
15
+
16
+ export function ensureTerminalEventsSchema(): void {
17
+ getDb().run(`
18
+ CREATE TABLE IF NOT EXISTS terminal_events (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ agent_id TEXT NOT NULL,
21
+ reason TEXT NOT NULL,
22
+ kind TEXT NOT NULL,
23
+ created_at INTEGER NOT NULL
24
+ )
25
+ `);
26
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_terminal_events_agent ON terminal_events(agent_id, created_at DESC)");
27
+ }
28
+
29
+ export interface TerminalEvent {
30
+ agentId: string;
31
+ /** Cause text, e.g. "transient-complete", "shutdown", "heartbeat lost". */
32
+ reason: string;
33
+ /** Relay event kind that terminated the agent, e.g. "agent.exited", "agent.spawn_failed". */
34
+ kind: string;
35
+ createdAt: number;
36
+ }
37
+
38
+ export function recordTerminalEvent(event: TerminalEvent): void {
39
+ getDb()
40
+ .query("INSERT INTO terminal_events (agent_id, reason, kind, created_at) VALUES (?, ?, ?, ?)")
41
+ .run(event.agentId, event.reason, event.kind, event.createdAt);
42
+ }
43
+
44
+ /** The most recent terminal event for an agent, or null if none was ever recorded. */
45
+ export function getTerminalEvent(agentId: string): TerminalEvent | null {
46
+ const row = getDb()
47
+ .query("SELECT * FROM terminal_events WHERE agent_id = ? ORDER BY created_at DESC, id DESC LIMIT 1")
48
+ .get(agentId) as TerminalEventRow | undefined;
49
+ if (!row) return null;
50
+ return { agentId: row.agent_id, reason: row.reason, kind: row.kind, createdAt: row.created_at };
51
+ }
52
+
53
+ /** True when any terminal event (clean exit, spawn-fail, reap) was recorded for the agent. */
54
+ export function hasTerminalEvent(agentId: string): boolean {
55
+ return getTerminalEvent(agentId) !== null;
56
+ }
@@ -5,6 +5,8 @@ import {
5
5
  DB_VACUUM_EVERY,
6
6
  ORPHAN_REAPER_INTERVAL_MS,
7
7
  OUTBOX_RETENTION_MS,
8
+ QUOTA_HISTORY_PRUNE_INTERVAL_MS,
9
+ QUOTA_HISTORY_RETENTION_MS,
8
10
  REAP_INTERVAL_MS,
9
11
  TOKEN_RECORD_RETENTION_SECONDS,
10
12
  WORKSPACE_AUTO_MERGE_INTERVAL_MS,
@@ -38,6 +40,8 @@ export {
38
40
  DB_VACUUM_EVERY,
39
41
  ORPHAN_REAPER_INTERVAL_MS,
40
42
  OUTBOX_RETENTION_MS,
43
+ QUOTA_HISTORY_PRUNE_INTERVAL_MS,
44
+ QUOTA_HISTORY_RETENTION_MS,
41
45
  TOKEN_RECORD_RETENTION_SECONDS,
42
46
  WORKSPACE_AUTO_MERGE_INTERVAL_MS,
43
47
  WORKSPACE_GC_INTERVAL_MS,
@@ -11,6 +11,7 @@ import {
11
11
  pruneOrphanedSharedWorkspaces,
12
12
  pruneOfflineAgents,
13
13
  pruneOldMessages,
14
+ pruneProviderQuotaHistory,
14
15
  reapStaleAgents,
15
16
  reapStaleOrchestrators,
16
17
  releaseExpiredClaims,
@@ -45,6 +46,8 @@ import {
45
46
  DAY_MS,
46
47
  ORPHAN_REAPER_INTERVAL_MS,
47
48
  OUTBOX_RETENTION_MS,
49
+ QUOTA_HISTORY_PRUNE_INTERVAL_MS,
50
+ QUOTA_HISTORY_RETENTION_MS,
48
51
  TOKEN_RECORD_RETENTION_SECONDS,
49
52
  WORKSPACE_AUTO_MERGE_INTERVAL_MS,
50
53
  WORKSPACE_GC_INTERVAL_MS,
@@ -280,6 +283,16 @@ export const definitions: MaintenanceJobDefinition[] = [
280
283
  return runDbMaintenance({ vacuum });
281
284
  },
282
285
  },
286
+ {
287
+ id: "quota-history-prune",
288
+ title: "Quota history prune",
289
+ description: "Delete per-provider quota history snapshots (incl. unavailable markers) beyond the 30-day retention window (#609).",
290
+ intervalMs: QUOTA_HISTORY_PRUNE_INTERVAL_MS,
291
+ runOnStart: true,
292
+ handler() {
293
+ return { prunedQuotaHistoryRows: pruneProviderQuotaHistory(Date.now() - QUOTA_HISTORY_RETENTION_MS) };
294
+ },
295
+ },
283
296
  {
284
297
  id: "bus-outbox-prune",
285
298
  title: "Bus outbox prune",
@@ -3,6 +3,7 @@ import {
3
3
  countLiveTeamMembers,
4
4
  createActivityEvent,
5
5
  getCrashReport,
6
+ hasTerminalEvent,
6
7
  getTeamBusyState,
7
8
  getTeamRoster,
8
9
  listActiveTeams,
@@ -32,8 +33,10 @@ function memberIsAlive(member: AgentCard, freshCutoff: number): boolean {
32
33
  * #634 — detect a team member that was ready/busy and then went silent (heartbeat-gap), EVEN IF it
33
34
  * dropped from the roster entirely, and ping the coordinator once. The in-memory watch remembers
34
35
  * each member's last-active time, so a vanished agent (no roster row) is still caught. Re-arms when
35
- * a member recovers. Skips the ping when a #633 crash report already exists (the coordinator was
36
- * already woken by the terminal event) this is the backstop for the NO-terminal-event case.
36
+ * a member recovers. Skips the ping when any terminal event already fired — a #633 crash report (a
37
+ * DEATH the coordinator was already woken on) OR a #645 clean terminus (a transient worker reaped
38
+ * after its land, a graceful shutdown). This watchdog must surface ONLY a genuine silent death:
39
+ * an agent that went quiet with NO terminal event of any kind.
37
40
  */
38
41
  function detectSilentMembers(
39
42
  teamId: string,
@@ -64,7 +67,9 @@ function detectSilentMembers(
64
67
  const gapMs = now - entry.lastActiveAt;
65
68
  if (gapMs < thresholdMs || entry.pinged) continue;
66
69
  entry.pinged = true;
67
- if (getCrashReport(memberId)) continue; // terminal event already woke the coordinator (#633)
70
+ // A terminal event already fired a #633 crash report (death) or a #645 clean terminus
71
+ // (transient reap after land / graceful shutdown). Either way this is not a silent death.
72
+ if (getCrashReport(memberId) || hasTerminalEvent(memberId)) continue;
68
73
  if (!coordinatorId) continue;
69
74
  const minutes = Math.floor(gapMs / 60_000);
70
75
  notifyTarget(
@@ -13,7 +13,8 @@ import {
13
13
  updateWorkspaceStatus,
14
14
  } from "../db";
15
15
  import type { WorkspaceMergePreview, WorkspaceStatus } from "../types";
16
- import { reconcileLandedWorkspace } from "../branch-landed";
16
+ import { notifyStewardHandoff, reconcileLandedWorkspace } from "../branch-landed";
17
+ import { resolveStewardIdentity } from "../steward-identity";
17
18
  import { getStewardConfig } from "../config-store";
18
19
  import { preparePrCompletionScan, reconcileLandedPr } from "../workspace-pr-completion";
19
20
  import { applyBranchFreshness, fetchHostMergePreview, workspacePathWithinBase } from "../workspace-probe";
@@ -97,15 +98,22 @@ export async function scanWorkspaceConflicts(): Promise<Record<string, unknown>>
97
98
  if (woke) notifiedStewards.push(woke);
98
99
  } else if (ws.stewardAgentId) {
99
100
  try {
101
+ // #640 — carry the identity triangle (branch agent + its coordinator) so the
102
+ // elected steward can address them; #641/#642 notify owner + coordinator of the handoff.
103
+ const { ownerAgentId, coordinatorAgentId } = resolveStewardIdentity(ws);
104
+ const coordinatorLine = coordinatorAgentId
105
+ ? ` Escalate to the branch agent's coordinator \`${coordinatorAgentId}\` (not the system fallback) if you can't resolve it.`
106
+ : "";
100
107
  notifyTarget(
101
108
  "workspace.conflict",
102
- { workspaceId: ws.id, repoRoot: ws.repoRoot },
109
+ { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ownerAgentId, parent: coordinatorAgentId ?? undefined },
103
110
  ws.stewardAgentId,
104
111
  "Workspace merge conflict",
105
- `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
106
- { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
112
+ `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind).${ownerAgentId ? ` Branch agent (owner): \`${ownerAgentId}\`.` : ""} As repo steward, please coordinate resolution.${coordinatorLine}`,
113
+ { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind, ownerAgentId, coordinatorAgentId },
107
114
  );
108
115
  notifiedStewards.push(ws.stewardAgentId);
116
+ notifyStewardHandoff({ workspace: ws, stewardId: ws.stewardAgentId, coordinatorAgentId, reason: "conflict" });
109
117
  } catch {}
110
118
  }
111
119
  } else if (p.conflict === false && ws.status === "conflict" && meta.autoConflict === true) {
@@ -168,13 +176,18 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
168
176
 
169
177
  if (strandedAt === undefined) { patchWorkspaceMetadata(fresh.id, { strandedAt: now }); continue; }
170
178
  if (now - strandedAt < escalationMs || meta.escalatedAt) continue;
179
+ // #642 — escalate to the branch agent's coordinator (owner's `spawnedBy`), falling back to
180
+ // the configured `stewardFallbackTarget` only when no coordinator resolves. The bare
181
+ // fallback was an unset `system` dead-end in the live incident.
182
+ const { coordinatorAgentId } = resolveStewardIdentity(fresh);
183
+ const escalationTarget = coordinatorAgentId ?? fallbackTarget;
171
184
  const sent = notifyTarget(
172
185
  "workspace.stranded_escalation",
173
- { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
174
- fallbackTarget,
186
+ { workspaceId: fresh.id, repoRoot: fresh.repoRoot, author: fresh.ownerAgentId, parent: coordinatorAgentId ?? undefined },
187
+ escalationTarget,
175
188
  "Stranded workspace needs an owner",
176
- `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
177
- { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt },
189
+ `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h.${coordinatorAgentId ? ` You are the branch agent \`${fresh.ownerAgentId ?? "?"}\`'s coordinator.` : ""} Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
190
+ { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt, coordinatorAgentId },
178
191
  );
179
192
  if (sent) escalationTargets.push(sent);
180
193
  patchWorkspaceMetadata(fresh.id, { escalatedAt: now });
@@ -183,11 +196,11 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
183
196
  clientId: `workspace-gc-escalate-${fresh.id}-${now}`,
184
197
  kind: "state",
185
198
  title: "Workspace escalated",
186
- body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${fallbackTarget ? ` to ${fallbackTarget}` : " (no fallback configured)"}`,
199
+ body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${escalationTarget ? ` to ${escalationTarget}${coordinatorAgentId ? " (coordinator)" : ""}` : " (no coordinator or fallback configured)"}`,
187
200
  meta: fresh.branch ?? fresh.id,
188
201
  icon: "ti-alert-octagon",
189
202
  view: "orchestrators",
190
- metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, fallback: fallbackTarget || null },
203
+ metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, escalationTarget: escalationTarget || null, coordinatorAgentId: coordinatorAgentId ?? null },
191
204
  });
192
205
  }
193
206
 
package/src/mcp/index.ts CHANGED
@@ -17,7 +17,7 @@ import type { JsonRpcId, JsonRpcRequest, McpAuthContext, ToolDefinition } from "
17
17
  import { positiveIdOrUndefined, recordField, stringField } from "./validation";
18
18
  import { IDENTITY_TOOLS, hasAnyScope, mcpAuthContext, relayCompactAndResume, relayRecall, relayWhoami } from "./identity";
19
19
  import { MESSAGING_TOOLS, relayAgentCrashReport, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
20
- import { SPAWN_TOOLS, relayShutdownAgent, relaySpawnAgent, relaySpawnTargets } from "./tools-spawn";
20
+ import { SPAWN_TOOLS, relayMyAgents, relayShutdownAgent, relaySpawnAgent, relaySpawnTargets } from "./tools-spawn";
21
21
  import { WORKSPACE_TOOLS, callerIsolatedWorkspace, relayWorkspaceList, relayWorkspaceMutation, relayWorkspaceStatus } from "./tools-workspace";
22
22
 
23
23
  const MCP_PROTOCOL_VERSION = "2024-11-05";
@@ -129,6 +129,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
129
129
  else if (name === "relay_recall") result = relayRecall(auth, args);
130
130
  else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
131
131
  else if (name === "relay_spawn_targets") result = relaySpawnTargets(auth);
132
+ else if (name === "relay_my_agents") result = relayMyAgents(auth, args);
132
133
  else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
133
134
  else if (name === "relay_agent_action") result = relayAgentAction(auth, args);
134
135
  else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);