agent-relay-server 0.85.0 → 0.87.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 (104) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DqFyach0.js → MessageBubble-DS4Hbn0k.js} +2 -2
  4. package/public/assets/{MessageBubble-DqFyach0.js.map → MessageBubble-DS4Hbn0k.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CCvKbYWe.js → PlanGraphPanel-BYlragEi.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CCvKbYWe.js.map → PlanGraphPanel-BYlragEi.js.map} +1 -1
  7. package/public/assets/{activity-C6RvknQQ.js → activity-CcGFS_sY.js} +2 -2
  8. package/public/assets/{activity-C6RvknQQ.js.map → activity-CcGFS_sY.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-BTh8SQiP.js} +2 -2
  12. package/public/assets/{agents-D7HZJ5eG.js.map → agents-BTh8SQiP.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-izYGpZyQ.js} +2 -2
  16. package/public/assets/{automation-BUazA3lr.js.map → automation-izYGpZyQ.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-ThyPRUPq.js} +2 -2
  22. package/public/assets/{chat-DBTEaf-H.js.map → chat-ThyPRUPq.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-DEYxGoiw.js} +2 -2
  27. package/public/assets/{formatted-body-impl-KG0iExZ-.js.map → formatted-body-impl-DEYxGoiw.js.map} +1 -1
  28. package/public/assets/index-Bd1NBLyd.js +23 -0
  29. package/public/assets/{index-TZskQfQL.js.map → index-Bd1NBLyd.js.map} +1 -1
  30. package/public/assets/index-BjbvhfFt.css +2 -0
  31. package/public/assets/{insights-JL1s7Uul.js → insights-DAVKQ_SF.js} +2 -2
  32. package/public/assets/{insights-JL1s7Uul.js.map → insights-DAVKQ_SF.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-CTMfhXFM.js → maintenance-BL4R9076.js} +2 -2
  36. package/public/assets/{maintenance-CTMfhXFM.js.map → maintenance-BL4R9076.js.map} +1 -1
  37. package/public/assets/{managed-agents-C4cb3xQ4.js → managed-agents-C7n1gl7E.js} +2 -2
  38. package/public/assets/{managed-agents-C4cb3xQ4.js.map → managed-agents-C7n1gl7E.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-DQIi0O7r.js → markdown-preview-impl-DK0wNWXJ.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-DQIi0O7r.js.map → markdown-preview-impl-DK0wNWXJ.js.map} +1 -1
  41. package/public/assets/{memory-DJPXFdgw.js → memory-BJEkDkwl.js} +2 -2
  42. package/public/assets/{memory-DJPXFdgw.js.map → memory-BJEkDkwl.js.map} +1 -1
  43. package/public/assets/{messages-RSD2g4Wx.js → messages-DBn9tXx2.js} +2 -2
  44. package/public/assets/{messages-RSD2g4Wx.js.map → messages-DBn9tXx2.js.map} +1 -1
  45. package/public/assets/{orchestrators-BGIkULBF.js → orchestrators-Bc1eC16Q.js} +2 -2
  46. package/public/assets/{orchestrators-BGIkULBF.js.map → orchestrators-Bc1eC16Q.js.map} +1 -1
  47. package/public/assets/{overview-ByE9V8pe.js → overview-Bw7zrSqR.js} +2 -2
  48. package/public/assets/{overview-ByE9V8pe.js.map → overview-Bw7zrSqR.js.map} +1 -1
  49. package/public/assets/{pairs-C284ntvD.js → pairs-BSwWnLK2.js} +2 -2
  50. package/public/assets/{pairs-C284ntvD.js.map → pairs-BSwWnLK2.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-vDglScsg.js → security-wTBapq4K.js} +2 -2
  54. package/public/assets/{security-vDglScsg.js.map → security-wTBapq4K.js.map} +1 -1
  55. package/public/assets/{select-DaOiWDBR.js → select-D6HFOeLW.js} +2 -2
  56. package/public/assets/{select-DaOiWDBR.js.map → select-D6HFOeLW.js.map} +1 -1
  57. package/public/assets/{settings-DiLcbSqx.js → settings-CDzp7vqJ.js} +2 -2
  58. package/public/assets/{settings-DiLcbSqx.js.map → settings-CDzp7vqJ.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-DWJdJra3.js → tasks-Bd7Wjy6e.js} +2 -2
  62. package/public/assets/{tasks-DWJdJra3.js.map → tasks-Bd7Wjy6e.js.map} +1 -1
  63. package/public/assets/{teams-FejXkix4.js → teams-l3GUr2XV.js} +2 -2
  64. package/public/assets/{teams-FejXkix4.js.map → teams-l3GUr2XV.js.map} +1 -1
  65. package/public/assets/{terminal-viewer-impl-Bkt1w42o.js → terminal-viewer-impl-BQMMWctd.js} +2 -2
  66. package/public/assets/{terminal-viewer-impl-Bkt1w42o.js.map → terminal-viewer-impl-BQMMWctd.js.map} +1 -1
  67. package/public/assets/{user-avatar-ClE2dMD8.js → user-avatar-DcA69UJK.js} +2 -2
  68. package/public/assets/{user-avatar-ClE2dMD8.js.map → user-avatar-DcA69UJK.js.map} +1 -1
  69. package/public/assets/{work-queue-DZdv9kOb.js → work-queue-BYlFeJ1g.js} +2 -2
  70. package/public/assets/{work-queue-DZdv9kOb.js.map → work-queue-BYlFeJ1g.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-busy-health.ts +93 -0
  76. package/src/agent-card-projection.ts +4 -1
  77. package/src/agent-lifecycle-events.ts +6 -1
  78. package/src/branch-landed.ts +81 -0
  79. package/src/cli/index.ts +3 -3
  80. package/src/cli/reply.ts +6 -1
  81. package/src/config.ts +9 -0
  82. package/src/db/index.ts +1 -0
  83. package/src/db/migrations.ts +17 -0
  84. package/src/db/provider-quotas.ts +99 -21
  85. package/src/db/terminal-events.ts +56 -0
  86. package/src/maintenance/constants.ts +4 -0
  87. package/src/maintenance/jobs.ts +88 -0
  88. package/src/maintenance/teams.ts +116 -5
  89. package/src/maintenance/workspaces.ts +23 -10
  90. package/src/mcp/index.ts +2 -1
  91. package/src/mcp/tools-spawn.ts +119 -4
  92. package/src/notification-types.ts +6 -0
  93. package/src/provider-catalog-store.ts +3 -0
  94. package/src/routes/workspaces.ts +5 -0
  95. package/src/services/transient-agent-reaper.ts +4 -1
  96. package/src/spawn-targets.ts +8 -2
  97. package/src/steward-identity.ts +50 -0
  98. package/src/steward.ts +12 -4
  99. package/src/team-idle-tracker.ts +19 -0
  100. package/src/workspace-auto-merge.ts +35 -6
  101. package/src/workspace-pr-completion.ts +26 -0
  102. package/public/assets/index-LUIvWcx6.css +0 -2
  103. package/public/assets/index-TZskQfQL.js +0 -23
  104. package/public/assets/store-DYaQAXRv.js.map +0 -1
@@ -0,0 +1,93 @@
1
+ import {
2
+ STUCK_BUSY_PROVIDER_TURN_MS,
3
+ STUCK_BUSY_RECONNECT_MS,
4
+ STUCK_BUSY_SHUTDOWN_MS,
5
+ STUCK_BUSY_STARTUP_MS,
6
+ } from "./config";
7
+ import { stringValue } from "agent-relay-sdk";
8
+ import type { AgentCard } from "./types";
9
+
10
+ type BusyHealthKind = "provider-turn" | "startup" | "reconnect" | "shutdown";
11
+
12
+ interface ActiveWorkLike {
13
+ id?: unknown;
14
+ kind?: unknown;
15
+ label?: unknown;
16
+ startedAt?: unknown;
17
+ }
18
+
19
+ export interface AgentBusyHealth {
20
+ status: "ok" | "stuck-busy";
21
+ kind: BusyHealthKind;
22
+ thresholdMs: number;
23
+ activeWorkId?: string;
24
+ activeWorkKind?: string;
25
+ activeWorkLabel?: string;
26
+ activeWorkStartedAt: number;
27
+ activeWorkAgeMs: number;
28
+ lastProviderEventAt?: number;
29
+ lastProviderEventAgeMs?: number;
30
+ staleForMs: number;
31
+ }
32
+
33
+ function numberValue(value: unknown): number | undefined {
34
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
35
+ }
36
+
37
+ function activeWork(agent: AgentCard): ActiveWorkLike[] {
38
+ return Array.isArray(agent.meta?.activeWork) ? agent.meta.activeWork as ActiveWorkLike[] : [];
39
+ }
40
+
41
+ function thresholdFor(kind: BusyHealthKind): number {
42
+ if (kind === "startup") return STUCK_BUSY_STARTUP_MS;
43
+ if (kind === "reconnect") return STUCK_BUSY_RECONNECT_MS;
44
+ if (kind === "shutdown") return STUCK_BUSY_SHUTDOWN_MS;
45
+ return STUCK_BUSY_PROVIDER_TURN_MS;
46
+ }
47
+
48
+ function classifyBusy(agent: AgentCard, now: number): { kind: BusyHealthKind; work?: ActiveWorkLike; startedAt: number } | null {
49
+ if (agent.status !== "busy") return null;
50
+ const works = activeWork(agent);
51
+ const providerTurn = works.find((item) => item.kind === "provider-turn");
52
+ if (providerTurn) {
53
+ return { kind: "provider-turn", work: providerTurn, startedAt: numberValue(providerTurn.startedAt) ?? agent.lastSeen };
54
+ }
55
+
56
+ const lifecycleAction = stringValue(agent.meta?.lifecycleAction) ?? "";
57
+ const lifecycleActionAt = numberValue(agent.meta?.lifecycleActionAt);
58
+ if (lifecycleAction.includes("shutdown") || lifecycleAction === "killing") {
59
+ return { kind: "shutdown", work: works[0], startedAt: lifecycleActionAt ?? numberValue(works[0]?.startedAt) ?? agent.lastSeen };
60
+ }
61
+ if (lifecycleAction.includes("reconnect") || lifecycleAction === "resuming") {
62
+ return { kind: "reconnect", work: works[0], startedAt: lifecycleActionAt ?? numberValue(works[0]?.startedAt) ?? agent.lastSeen };
63
+ }
64
+ if (!agent.ready) {
65
+ return { kind: "startup", work: works[0], startedAt: numberValue(agent.meta?.startedAt) ?? agent.createdAt };
66
+ }
67
+ return works[0] ? { kind: "provider-turn", work: works[0], startedAt: numberValue(works[0].startedAt) ?? agent.lastSeen } : null;
68
+ }
69
+
70
+ export function deriveAgentBusyHealth(agent: AgentCard, now: number = Date.now()): AgentBusyHealth | undefined {
71
+ const classified = classifyBusy(agent, now);
72
+ if (!classified) return undefined;
73
+ const thresholdMs = thresholdFor(classified.kind);
74
+ const activeWorkStartedAt = Math.max(0, classified.startedAt);
75
+ const activeWorkAgeMs = Math.max(0, now - activeWorkStartedAt);
76
+ const lastProviderEventAt = numberValue(agent.meta?.lastProviderEventAt);
77
+ const lastProgressAt = lastProviderEventAt ?? activeWorkStartedAt;
78
+ const staleForMs = Math.max(0, now - lastProgressAt);
79
+ const stuck = activeWorkAgeMs >= thresholdMs && staleForMs >= thresholdMs;
80
+ const work = classified.work;
81
+ return {
82
+ status: stuck ? "stuck-busy" : "ok",
83
+ kind: classified.kind,
84
+ thresholdMs,
85
+ ...(stringValue(work?.id) ? { activeWorkId: stringValue(work?.id) } : {}),
86
+ ...(stringValue(work?.kind) ? { activeWorkKind: stringValue(work?.kind) } : {}),
87
+ ...(stringValue(work?.label) ? { activeWorkLabel: stringValue(work?.label) } : {}),
88
+ activeWorkStartedAt,
89
+ activeWorkAgeMs,
90
+ ...(lastProviderEventAt !== undefined ? { lastProviderEventAt, lastProviderEventAgeMs: Math.max(0, now - lastProviderEventAt) } : {}),
91
+ staleForMs,
92
+ };
93
+ }
@@ -10,12 +10,15 @@
10
10
  // surface; a new emitter can't reintroduce the drift without going around this function.
11
11
 
12
12
  import { attachBranchState } from "./agent-branch-state";
13
+ import { deriveAgentBusyHealth } from "./agent-busy-health";
13
14
  import { attachIssueRepo } from "./agent-issue-repo";
14
15
  import type { AgentCard } from "./types";
15
16
 
16
17
  /** Enrich an AgentCard with the derived fields the dashboard expects on every surface. */
17
18
  export function projectAgentCard(agent: AgentCard): AgentCard {
18
- return attachIssueRepo(attachBranchState(agent));
19
+ const projected = attachIssueRepo(attachBranchState(agent));
20
+ const busyHealth = deriveAgentBusyHealth(projected);
21
+ return busyHealth ? { ...projected, meta: { ...(projected.meta ?? {}), busyHealth } } : projected;
19
22
  }
20
23
 
21
24
  /** The `agent.status` SSE payload: the enriched card when the agent is known, else the
@@ -1,7 +1,7 @@
1
1
  import { emitRelayEvent } from "./events";
2
2
  import { getNotificationsConfig } from "./config-store";
3
3
  import { routeNotification } from "./notification-router";
4
- import { getAgent, markAgentReadyNotified, recordCrashReport } from "./db";
4
+ import { getAgent, markAgentReadyNotified, recordCrashReport, recordTerminalEvent } from "./db";
5
5
  import { crashReportFor } from "./agent-death-diagnosis";
6
6
  import type { AgentCard, AgentDeathDiagnosis } from "./types";
7
7
 
@@ -105,6 +105,11 @@ export function notifyAgentOffline(childId: string, reason: string, diagnosis?:
105
105
  // whether the parent push is enabled. Recorded once per terminal-event emission.
106
106
  if (diagnosis) recordCrashReport(crashReportFor(child, reason, diagnosis));
107
107
 
108
+ // #645 — persist a terminal marker for ANY offline terminus (clean exit or spawn-fail) keyed by
109
+ // agentId so it outlives the reaper. The #634 watchdog reads this to skip a benign shutdown; it
110
+ // must fire ONLY on a genuine silent death that emitted no terminal event at all.
111
+ recordTerminalEvent({ agentId: childId, reason, kind: wasReady ? "agent.exited" : "agent.spawn_failed", createdAt: Date.now() });
112
+
108
113
  if (wasReady) {
109
114
  emitRelayEvent({
110
115
  type: "agent.exited",
@@ -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
@@ -59,6 +59,11 @@ export const SPAWN_REGISTRATION_NOTIFY_MAX_AGE_MS = envPositiveInt("AGENT_RELAY_
59
59
  export const DEFAULT_FANOUT_TTL_SECONDS = envPositiveInt("AGENT_RELAY_FANOUT_TTL_SECONDS", 2 * 60 * 60); // 2h
60
60
  export const OFFLINE_PRUNE_MS = envPositiveInt("OFFLINE_PRUNE_MS", DAY_MS); // 24h offline → delete
61
61
  export const REAP_INTERVAL_MS = envPositiveInt("REAP_INTERVAL_MS", 60_000); // reaper cadence
62
+ export const STUCK_BUSY_THRESHOLD_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_THRESHOLD_MS", 15 * 60_000);
63
+ export const STUCK_BUSY_PROVIDER_TURN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_PROVIDER_TURN_MS", STUCK_BUSY_THRESHOLD_MS);
64
+ export const STUCK_BUSY_STARTUP_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_STARTUP_MS", STUCK_BUSY_THRESHOLD_MS);
65
+ export const STUCK_BUSY_RECONNECT_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_RECONNECT_MS", STUCK_BUSY_THRESHOLD_MS);
66
+ export const STUCK_BUSY_SHUTDOWN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_SHUTDOWN_MS", STUCK_BUSY_THRESHOLD_MS);
62
67
  export const CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_CLAIM_LEASE_MS", 1_800_000); // 30min claim lease
63
68
  export const POOL_CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_POOL_CLAIM_LEASE_MS", STALE_TTL_MS * 3); // pool binding lease
64
69
  // Per-repo merge serialization lease — only one base merge may run at a time per
@@ -129,6 +134,10 @@ export function maxArtifactBytesFromEnv(fallback: number): number {
129
134
  }
130
135
 
131
136
  export const OUTBOX_RETENTION_MS = envNumberOrDefault("AGENT_RELAY_OUTBOX_RETENTION_MS", 60 * 60 * 1000);
137
+ // Per-provider quota history retention (#609) — durable burn-rate history for the sparkline/analytics
138
+ // charts (#607/#608). Default 30 days; the quota-history-prune maintenance reaper drops older entries.
139
+ export const QUOTA_HISTORY_RETENTION_MS = envNumberOrDefault("AGENT_RELAY_QUOTA_HISTORY_RETENTION_MS", 30 * DAY_MS);
140
+ export const QUOTA_HISTORY_PRUNE_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_QUOTA_HISTORY_PRUNE_INTERVAL_MS", 6 * 60 * 60 * 1000);
132
141
  export const TOKEN_RECORD_RETENTION_SECONDS = envNumberOrDefault("AGENT_RELAY_TOKEN_RECORD_RETENTION_SECONDS", 7 * 24 * 60 * 60);
133
142
  export const CONFLICT_SCAN_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_CONFLICT_SCAN_INTERVAL_MS", 10 * 60 * 1000);
134
143
  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,