agent-relay-server 0.84.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 (115) hide show
  1. package/docs/openapi.json +39 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-BTBQr8uG.js → MessageBubble-CevL-2jc.js} +2 -2
  4. package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-CevL-2jc.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-DGpJhuVw.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-DGpJhuVw.js.map} +1 -1
  7. package/public/assets/{activity-XqRFu_9T.js → activity-CY2Cbpet.js} +2 -2
  8. package/public/assets/{activity-XqRFu_9T.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-BWmmWj4Q.js → agents-Bj5KzrYJ.js} +2 -2
  12. package/public/assets/{agents-BWmmWj4Q.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-DRtOENs0.js → automation-BOPk1oh_.js} +2 -2
  16. package/public/assets/{automation-DRtOENs0.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-gfUxbTus.js → chat-D5pI4ebq.js} +2 -2
  22. package/public/assets/{chat-gfUxbTus.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-DpNHGXXQ.js → formatted-body-impl-Dr_z1UvS.js} +2 -2
  27. package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-Dr_z1UvS.js.map} +1 -1
  28. package/public/assets/index-CEXhh5nz.js +23 -0
  29. package/public/assets/index-CEXhh5nz.js.map +1 -0
  30. package/public/assets/index-LUIvWcx6.css +2 -0
  31. package/public/assets/{insights-DPaJ5Hme.js → insights-C4dEK49e.js} +2 -2
  32. package/public/assets/{insights-DPaJ5Hme.js.map → insights-C4dEK49e.js.map} +1 -1
  33. package/public/assets/{integrations-PqWMrPJa.js → integrations-D9Cx-7ma.js} +2 -2
  34. package/public/assets/{integrations-PqWMrPJa.js.map → integrations-D9Cx-7ma.js.map} +1 -1
  35. package/public/assets/{maintenance-DsszLb92.js → maintenance-DJZUSsja.js} +2 -2
  36. package/public/assets/{maintenance-DsszLb92.js.map → maintenance-DJZUSsja.js.map} +1 -1
  37. package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-5wUMDKVi.js} +2 -2
  38. package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-5wUMDKVi.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DOJQR3cO.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DOJQR3cO.js.map} +1 -1
  41. package/public/assets/{memory-C2s9bJ38.js → memory-BnhAD09c.js} +2 -2
  42. package/public/assets/{memory-C2s9bJ38.js.map → memory-BnhAD09c.js.map} +1 -1
  43. package/public/assets/{messages-DHRJNAvy.js → messages-Cc9YF8Wl.js} +2 -2
  44. package/public/assets/{messages-DHRJNAvy.js.map → messages-Cc9YF8Wl.js.map} +1 -1
  45. package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-B01iyiBA.js} +2 -2
  46. package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-B01iyiBA.js.map} +1 -1
  47. package/public/assets/{overview-C1vZfyhW.js → overview-DN8CUTVx.js} +2 -2
  48. package/public/assets/{overview-C1vZfyhW.js.map → overview-DN8CUTVx.js.map} +1 -1
  49. package/public/assets/{pairs-C1uzo8x0.js → pairs-BxuDMLhs.js} +2 -2
  50. package/public/assets/{pairs-C1uzo8x0.js.map → pairs-BxuDMLhs.js.map} +1 -1
  51. package/public/assets/{projects-B2-8Eu0e.js → projects-hU5KGW_T.js} +2 -2
  52. package/public/assets/{projects-B2-8Eu0e.js.map → projects-hU5KGW_T.js.map} +1 -1
  53. package/public/assets/{security-XhAAP6q3.js → security-CO0PBIau.js} +2 -2
  54. package/public/assets/{security-XhAAP6q3.js.map → security-CO0PBIau.js.map} +1 -1
  55. package/public/assets/{select-DEGUFEjx.js → select-DHaFsLvt.js} +2 -2
  56. package/public/assets/{select-DEGUFEjx.js.map → select-DHaFsLvt.js.map} +1 -1
  57. package/public/assets/{settings-DTbVUdlZ.js → settings-De1DKwd6.js} +2 -2
  58. package/public/assets/{settings-DTbVUdlZ.js.map → settings-De1DKwd6.js.map} +1 -1
  59. package/public/assets/{store-DYaQAXRv.js → store-DsqUEZOf.js} +2 -2
  60. package/public/assets/store-DsqUEZOf.js.map +1 -0
  61. package/public/assets/{tasks-zwGo7hon.js → tasks-Bqd188ql.js} +2 -2
  62. package/public/assets/{tasks-zwGo7hon.js.map → tasks-Bqd188ql.js.map} +1 -1
  63. package/public/assets/{teams-Brc5D8ox.js → teams-Mh6ypDPr.js} +2 -2
  64. package/public/assets/{teams-Brc5D8ox.js.map → teams-Mh6ypDPr.js.map} +1 -1
  65. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-B7x_JNZe.js} +2 -2
  66. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-B7x_JNZe.js.map} +1 -1
  67. package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-Dy5J3E_b.js} +2 -2
  68. package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-Dy5J3E_b.js.map} +1 -1
  69. package/public/assets/{work-queue-ge4R7keo.js → work-queue-ClGgOUSu.js} +2 -2
  70. package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-ClGgOUSu.js.map} +1 -1
  71. package/public/assets/{workspaces-BhblC-Nd.js → workspaces-C740m1ys.js} +2 -2
  72. package/public/assets/{workspaces-BhblC-Nd.js.map → workspaces-C740m1ys.js.map} +1 -1
  73. package/public/index.html +3 -3
  74. package/runner/src/adapter.ts +4 -3
  75. package/src/agent-death-diagnosis.ts +255 -0
  76. package/src/agent-lifecycle-events.ts +47 -19
  77. package/src/branch-landed.ts +81 -0
  78. package/src/bus.ts +18 -4
  79. package/src/cli/index.ts +3 -3
  80. package/src/cli/reply.ts +6 -1
  81. package/src/config.ts +4 -0
  82. package/src/db/agents.ts +35 -2
  83. package/src/db/crash-reports.ts +115 -0
  84. package/src/db/index.ts +2 -0
  85. package/src/db/migrations.ts +22 -0
  86. package/src/db/provider-quotas.ts +99 -21
  87. package/src/db/terminal-events.ts +56 -0
  88. package/src/maintenance/constants.ts +4 -0
  89. package/src/maintenance/jobs.ts +19 -1
  90. package/src/maintenance/teams.ts +73 -1
  91. package/src/maintenance/workspaces.ts +23 -10
  92. package/src/mcp/index.ts +4 -2
  93. package/src/mcp/tools-messaging.ts +38 -0
  94. package/src/mcp/tools-spawn.ts +119 -4
  95. package/src/notification-types.ts +4 -0
  96. package/src/provider-catalog-store.ts +3 -0
  97. package/src/routes/agents.ts +11 -1
  98. package/src/routes/commands.ts +21 -3
  99. package/src/routes/index.ts +2 -1
  100. package/src/routes/orchestrator.ts +14 -1
  101. package/src/routes/workspaces.ts +5 -0
  102. package/src/services/terminal-provider-exit.ts +33 -0
  103. package/src/services/transient-agent-reaper.ts +4 -1
  104. package/src/spawn-targets.ts +25 -8
  105. package/src/steward-identity.ts +50 -0
  106. package/src/steward.ts +12 -4
  107. package/src/team-idle-tracker.ts +15 -0
  108. package/src/workspace-actions.ts +5 -1
  109. package/src/workspace-auto-merge.ts +120 -8
  110. package/src/workspace-merge.ts +10 -0
  111. package/src/workspace-pr-completion.ts +26 -0
  112. package/public/assets/index-D8TVUdlC.js +0 -22
  113. package/public/assets/index-D8TVUdlC.js.map +0 -1
  114. package/public/assets/index-DgZLDL0K.css +0 -2
  115. package/public/assets/store-DYaQAXRv.js.map +0 -1
@@ -0,0 +1,115 @@
1
+ // #636 — persisted crash post-mortems. Stored in their own table (no FK to agents) so a death
2
+ // verdict survives `pruneOfflineAgents` / `deleteAgent` and stays retrievable by agentId after the
3
+ // worktree/session reaper has cleaned up. One row per (agentId, createdAt); the latest wins on read.
4
+ import { getDb } from "./connection.ts";
5
+ import { parseJson } from "../utils";
6
+ import type { AgentDeathDiagnosis, CrashReport } from "../types";
7
+
8
+ interface CrashReportRow {
9
+ agent_id: string;
10
+ parent_id: string | null;
11
+ team_id: string | null;
12
+ spawn_request_id: string | null;
13
+ provider: string | null;
14
+ label: string | null;
15
+ cause: string;
16
+ reason: string;
17
+ diagnosis: string;
18
+ created_at: number;
19
+ }
20
+
21
+ export function ensureCrashReportsSchema(): void {
22
+ getDb().run(`
23
+ CREATE TABLE IF NOT EXISTS crash_reports (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ agent_id TEXT NOT NULL,
26
+ parent_id TEXT,
27
+ team_id TEXT,
28
+ spawn_request_id TEXT,
29
+ provider TEXT,
30
+ label TEXT,
31
+ cause TEXT NOT NULL,
32
+ reason TEXT NOT NULL,
33
+ diagnosis TEXT NOT NULL,
34
+ created_at INTEGER NOT NULL
35
+ )
36
+ `);
37
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_crash_reports_agent ON crash_reports(agent_id, created_at DESC)");
38
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_crash_reports_parent ON crash_reports(parent_id, created_at DESC)");
39
+ }
40
+
41
+ function rowToCrashReport(row: CrashReportRow): CrashReport {
42
+ return {
43
+ agentId: row.agent_id,
44
+ ...(row.parent_id ? { parentId: row.parent_id } : {}),
45
+ ...(row.team_id ? { teamId: row.team_id } : {}),
46
+ ...(row.spawn_request_id ? { spawnRequestId: row.spawn_request_id } : {}),
47
+ ...(row.provider ? { provider: row.provider } : {}),
48
+ ...(row.label ? { label: row.label } : {}),
49
+ reason: row.reason,
50
+ diagnosis: parseJson<AgentDeathDiagnosis>(row.diagnosis, {
51
+ cause: "unknown",
52
+ confidence: "low",
53
+ summary: "Cause undetermined",
54
+ evidence: [],
55
+ detectedAt: row.created_at,
56
+ }),
57
+ createdAt: row.created_at,
58
+ };
59
+ }
60
+
61
+ export function recordCrashReport(report: CrashReport): void {
62
+ getDb()
63
+ .query(
64
+ `INSERT INTO crash_reports (agent_id, parent_id, team_id, spawn_request_id, provider, label, cause, reason, diagnosis, created_at)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
66
+ )
67
+ .run(
68
+ report.agentId,
69
+ report.parentId ?? null,
70
+ report.teamId ?? null,
71
+ report.spawnRequestId ?? null,
72
+ report.provider ?? null,
73
+ report.label ?? null,
74
+ report.diagnosis.cause,
75
+ report.reason,
76
+ JSON.stringify(report.diagnosis),
77
+ report.createdAt,
78
+ );
79
+ }
80
+
81
+ /** The most recent crash report for an agent, or null if none was ever recorded. */
82
+ export function getCrashReport(agentId: string): CrashReport | null {
83
+ const row = getDb()
84
+ .query("SELECT * FROM crash_reports WHERE agent_id = ? ORDER BY created_at DESC, id DESC LIMIT 1")
85
+ .get(agentId) as CrashReportRow | undefined;
86
+ return row ? rowToCrashReport(row) : null;
87
+ }
88
+
89
+ export interface ListCrashReportsFilter {
90
+ /** Restrict to children of this spawn-parent (the parent/self scope for the MCP tool). */
91
+ parentId?: string;
92
+ /** Restrict to a single agent. */
93
+ agentId?: string;
94
+ limit?: number;
95
+ }
96
+
97
+ export function listCrashReports(filter: ListCrashReportsFilter = {}): CrashReport[] {
98
+ const where: string[] = [];
99
+ const params: (string | number)[] = [];
100
+ if (filter.parentId) {
101
+ where.push("parent_id = ?");
102
+ params.push(filter.parentId);
103
+ }
104
+ if (filter.agentId) {
105
+ where.push("agent_id = ?");
106
+ params.push(filter.agentId);
107
+ }
108
+ const limit = Math.max(1, Math.min(filter.limit ?? 50, 200));
109
+ const rows = getDb()
110
+ .query(
111
+ `SELECT * FROM crash_reports ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC, id DESC LIMIT ?`,
112
+ )
113
+ .all(...params, limit) as CrashReportRow[];
114
+ return rows.map(rowToCrashReport);
115
+ }
package/src/db/index.ts CHANGED
@@ -27,6 +27,8 @@ export * from "./workspaces.ts";
27
27
  export * from "./merge-lease.ts";
28
28
  export * from "./continuations.ts";
29
29
  export * from "./continuation-archives.ts";
30
+ export * from "./crash-reports.ts";
31
+ export * from "./terminal-events.ts";
30
32
  export * from "./plans.ts";
31
33
  export * from "./teams.ts";
32
34
  export * from "./blackboard.ts";
@@ -20,6 +20,8 @@ import { ensureAuthIdentitiesSchema } from "./auth-identities.ts";
20
20
  import { ensureAgentOwnershipSchema, seedDefaultAgentOwnership } from "./agent-ownership.ts";
21
21
  import { ensureDriveLeaseSchema } from "./drive-leases.ts";
22
22
  import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
23
+ import { ensureCrashReportsSchema } from "./crash-reports.ts";
24
+ import { ensureTerminalEventsSchema } from "./terminal-events.ts";
23
25
  import { normalizeReactionEmoji } from "./mappers.ts";
24
26
  import { normalizeProviderQuotaAccountKeys } from "./provider-quotas.ts";
25
27
  import { projectRootsMissingIssueRepo, setProjectIssueRepo } from "./projects.ts";
@@ -656,6 +658,17 @@ export function applyMigrations(): void {
656
658
  `);
657
659
  getDb().run("DROP TABLE quota_snapshots_legacy_agent");
658
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
+ }
659
672
  getDb().run(`
660
673
  CREATE TABLE IF NOT EXISTS provider_quotas (
661
674
  provider TEXT NOT NULL,
@@ -795,6 +808,15 @@ export function applyMigrations(): void {
795
808
  )
796
809
  `);
797
810
 
811
+ // #636 — persisted crash post-mortems. Own table, no FK to agents, so a death verdict outlives
812
+ // the offline-prune/reaper and stays retrievable by agentId.
813
+ ensureCrashReportsSchema();
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
+
798
820
  // First-class human users (#388 keystone #393): ensure the table (single DDL home in users.ts)
799
821
  // and seed the admin-level default user mapped to the existing single human. P0 + idempotent —
800
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,
@@ -7,9 +7,11 @@ import {
7
7
  createActivityEvent,
8
8
  evaluatePoolBindings,
9
9
  expireQueuedMessages,
10
+ getAgent,
10
11
  pruneOrphanedSharedWorkspaces,
11
12
  pruneOfflineAgents,
12
13
  pruneOldMessages,
14
+ pruneProviderQuotaHistory,
13
15
  reapStaleAgents,
14
16
  reapStaleOrchestrators,
15
17
  releaseExpiredClaims,
@@ -19,6 +21,7 @@ import {
19
21
  sweepArtifacts,
20
22
  } from "../db";
21
23
  import { notifyAgentOffline } from "../agent-lifecycle-events";
24
+ import { diagnoseAgentDeath } from "../agent-death-diagnosis";
22
25
  import { autoMergeCleanFastForwards } from "../workspace-auto-merge";
23
26
  import { reapOrphanedWorktrees } from "../workspace-orphans";
24
27
  import { getLifecycleManager } from "../lifecycle-manager";
@@ -43,6 +46,8 @@ import {
43
46
  DAY_MS,
44
47
  ORPHAN_REAPER_INTERVAL_MS,
45
48
  OUTBOX_RETENTION_MS,
49
+ QUOTA_HISTORY_PRUNE_INTERVAL_MS,
50
+ QUOTA_HISTORY_RETENTION_MS,
46
51
  TOKEN_RECORD_RETENTION_SECONDS,
47
52
  WORKSPACE_AUTO_MERGE_INTERVAL_MS,
48
53
  WORKSPACE_GC_INTERVAL_MS,
@@ -173,7 +178,10 @@ export const definitions: MaintenanceJobDefinition[] = [
173
178
  for (const id of reapedAgentIds) {
174
179
  emitAgentStatus(id);
175
180
  getLifecycleManager().onAgentDisappeared(id);
176
- notifyAgentOffline(id, "heartbeat lost");
181
+ // #636 — a heartbeat-lost death still gets a best-effort post-mortem from the agent's last
182
+ // known meta (lastExit/lastError/auth) + heartbeat gap, riding the terminal event.
183
+ const diagnosis = diagnoseAgentDeath({ agent: getAgent(id), reason: "heartbeat lost" });
184
+ notifyAgentOffline(id, "heartbeat lost", diagnosis);
177
185
  createActivityEvent({
178
186
  clientId: "server-agent-" + id + "-heartbeat-lost-" + Date.now(),
179
187
  kind: "state",
@@ -275,6 +283,16 @@ export const definitions: MaintenanceJobDefinition[] = [
275
283
  return runDbMaintenance({ vacuum });
276
284
  },
277
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
+ },
278
296
  {
279
297
  id: "bus-outbox-prune",
280
298
  title: "Bus outbox prune",
@@ -2,6 +2,8 @@ import {
2
2
  clearTeamZeroMembersSince,
3
3
  countLiveTeamMembers,
4
4
  createActivityEvent,
5
+ getCrashReport,
6
+ hasTerminalEvent,
5
7
  getTeamBusyState,
6
8
  getTeamRoster,
7
9
  listActiveTeams,
@@ -13,10 +15,73 @@ import { dissolveTeamById } from "../services/team";
13
15
  import { getTeamsConfig } from "../teams-config-store";
14
16
  import { teamReapGraceMs } from "./constants";
15
17
  import { notifyTarget } from "./events";
16
- import { teamIdleTracker } from "../team-idle-tracker";
18
+ import { teamIdleTracker, teamMemberWatchTracker, type TeamMemberWatch } from "../team-idle-tracker";
19
+ import type { AgentCard } from "../types";
17
20
 
18
21
  export function resetTeamIdleWatchdogForTests(): void {
19
22
  teamIdleTracker.clear();
23
+ teamMemberWatchTracker.clear();
24
+ }
25
+
26
+ /** A member counts as "alive" (worth watching for a future silence) when it's past onboarding and
27
+ * heartbeating — i.e. a status that implies it became ready, not a never-started spawn. */
28
+ function memberIsAlive(member: AgentCard, freshCutoff: number): boolean {
29
+ return (member.status === "idle" || member.status === "busy" || member.status === "online") && member.lastSeen > freshCutoff;
30
+ }
31
+
32
+ /**
33
+ * #634 — detect a team member that was ready/busy and then went silent (heartbeat-gap), EVEN IF it
34
+ * dropped from the roster entirely, and ping the coordinator once. The in-memory watch remembers
35
+ * each member's last-active time, so a vanished agent (no roster row) is still caught. Re-arms when
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.
40
+ */
41
+ function detectSilentMembers(
42
+ teamId: string,
43
+ coordinatorId: string | undefined,
44
+ now: number,
45
+ thresholdMs: number,
46
+ out: { pingedMembers: string[]; watchedMembers: number },
47
+ ): void {
48
+ const freshCutoff = now - thresholdMs;
49
+ const watch = teamMemberWatchTracker.get(teamId) ?? new Map<string, TeamMemberWatch>();
50
+ teamMemberWatchTracker.set(teamId, watch);
51
+
52
+ for (const member of getTeamRoster(teamId)) {
53
+ if (member.id === coordinatorId) continue; // the coordinator is the recipient, not a watched worker
54
+ if (!memberIsAlive(member, freshCutoff)) continue;
55
+ const existing = watch.get(member.id);
56
+ // Recovery re-arms the ping: a member seen alive again after a silence can re-trigger later.
57
+ const recovered = existing ? member.lastSeen > existing.lastActiveAt : false;
58
+ watch.set(member.id, {
59
+ lastActiveAt: Math.max(member.lastSeen, existing?.lastActiveAt ?? 0),
60
+ pinged: recovered ? false : existing?.pinged ?? false,
61
+ ...(member.label ? { label: member.label } : {}),
62
+ });
63
+ }
64
+
65
+ out.watchedMembers += watch.size;
66
+ for (const [memberId, entry] of watch) {
67
+ const gapMs = now - entry.lastActiveAt;
68
+ if (gapMs < thresholdMs || entry.pinged) continue;
69
+ entry.pinged = true;
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;
73
+ if (!coordinatorId) continue;
74
+ const minutes = Math.floor(gapMs / 60_000);
75
+ notifyTarget(
76
+ "team.member_silent",
77
+ { teamId, agentId: memberId },
78
+ coordinatorId,
79
+ "Team member went silent",
80
+ `Member \`${entry.label ?? memberId}\` in team \`${teamId}\` has sent no heartbeat for ${minutes} min — it may have died without a terminal event. Check it and restart its unfinished work, or stand it down.`,
81
+ { kind: "team.member_silent", teamId, coordinatorId, memberId, label: entry.label, silentForMs: gapMs, lastActiveAt: entry.lastActiveAt },
82
+ );
83
+ out.pingedMembers.push(memberId);
84
+ }
20
85
  }
21
86
 
22
87
  function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
@@ -79,9 +144,13 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
79
144
  const pingedTeamIds: string[] = [];
80
145
  const alreadyPingedTeamIds: string[] = [];
81
146
  const missingCoordinatorTeamIds: string[] = [];
147
+ const memberSilence = { pingedMembers: [] as string[], watchedMembers: 0 };
82
148
 
83
149
  for (const team of listActiveTeams()) {
84
150
  seen.add(team.id);
151
+ // #634 — runs every cycle, independent of the team-level idle gate below: a single silent
152
+ // member must surface even while OTHER members keep the team "busy".
153
+ detectSilentMembers(team.id, team.coordinatorId, now, thresholdMs, memberSilence);
85
154
  const busy = getTeamBusyState(team.id, cutoff);
86
155
  if (busy.busyMemberIds.length > 0) {
87
156
  teamIdleTracker.delete(team.id);
@@ -112,6 +181,7 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
112
181
  }
113
182
 
114
183
  for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
184
+ for (const key of teamMemberWatchTracker.keys()) if (!seen.has(key)) teamMemberWatchTracker.delete(key);
115
185
 
116
186
  return {
117
187
  enabled: true,
@@ -122,6 +192,8 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
122
192
  pingedTeamIds,
123
193
  alreadyPingedTeamIds,
124
194
  missingCoordinatorTeamIds,
195
+ silentMemberIds: memberSilence.pingedMembers,
196
+ watchedMembers: memberSilence.watchedMembers,
125
197
  };
126
198
  }
127
199