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.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-DqFyach0.js → MessageBubble-DS4Hbn0k.js} +2 -2
- package/public/assets/{MessageBubble-DqFyach0.js.map → MessageBubble-DS4Hbn0k.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CCvKbYWe.js → PlanGraphPanel-BYlragEi.js} +2 -2
- package/public/assets/{PlanGraphPanel-CCvKbYWe.js.map → PlanGraphPanel-BYlragEi.js.map} +1 -1
- package/public/assets/{activity-C6RvknQQ.js → activity-CcGFS_sY.js} +2 -2
- package/public/assets/{activity-C6RvknQQ.js.map → activity-CcGFS_sY.js.map} +1 -1
- package/public/assets/{agent-profiles-RFCLAgnC.js → agent-profiles-CLJNeVxc.js} +2 -2
- package/public/assets/{agent-profiles-RFCLAgnC.js.map → agent-profiles-CLJNeVxc.js.map} +1 -1
- package/public/assets/{agents-D7HZJ5eG.js → agents-BTh8SQiP.js} +2 -2
- package/public/assets/{agents-D7HZJ5eG.js.map → agents-BTh8SQiP.js.map} +1 -1
- package/public/assets/{analytics-2H2z2UNS.js → analytics-C1F28oqr.js} +2 -2
- package/public/assets/{analytics-2H2z2UNS.js.map → analytics-C1F28oqr.js.map} +1 -1
- package/public/assets/{automation-BUazA3lr.js → automation-izYGpZyQ.js} +2 -2
- package/public/assets/{automation-BUazA3lr.js.map → automation-izYGpZyQ.js.map} +1 -1
- package/public/assets/{branch-state-badge-DxwNcWr8.js → branch-state-badge-Q4W68jxj.js} +2 -2
- package/public/assets/{branch-state-badge-DxwNcWr8.js.map → branch-state-badge-Q4W68jxj.js.map} +1 -1
- package/public/assets/{channels-Sh_jdB7y.js → channels-BQbNtrtF.js} +2 -2
- package/public/assets/{channels-Sh_jdB7y.js.map → channels-BQbNtrtF.js.map} +1 -1
- package/public/assets/{chat-DBTEaf-H.js → chat-ThyPRUPq.js} +2 -2
- package/public/assets/{chat-DBTEaf-H.js.map → chat-ThyPRUPq.js.map} +1 -1
- package/public/assets/{connectors-C3C4i4Gz.js → connectors-CmARSKoV.js} +2 -2
- package/public/assets/{connectors-C3C4i4Gz.js.map → connectors-CmARSKoV.js.map} +1 -1
- package/public/assets/display-CWMHll1J.js.map +1 -1
- package/public/assets/{formatted-body-impl-KG0iExZ-.js → formatted-body-impl-DEYxGoiw.js} +2 -2
- package/public/assets/{formatted-body-impl-KG0iExZ-.js.map → formatted-body-impl-DEYxGoiw.js.map} +1 -1
- package/public/assets/index-Bd1NBLyd.js +23 -0
- package/public/assets/{index-TZskQfQL.js.map → index-Bd1NBLyd.js.map} +1 -1
- package/public/assets/index-BjbvhfFt.css +2 -0
- package/public/assets/{insights-JL1s7Uul.js → insights-DAVKQ_SF.js} +2 -2
- package/public/assets/{insights-JL1s7Uul.js.map → insights-DAVKQ_SF.js.map} +1 -1
- package/public/assets/{integrations-PqWMrPJa.js → integrations-D9Cx-7ma.js} +2 -2
- package/public/assets/{integrations-PqWMrPJa.js.map → integrations-D9Cx-7ma.js.map} +1 -1
- package/public/assets/{maintenance-CTMfhXFM.js → maintenance-BL4R9076.js} +2 -2
- package/public/assets/{maintenance-CTMfhXFM.js.map → maintenance-BL4R9076.js.map} +1 -1
- package/public/assets/{managed-agents-C4cb3xQ4.js → managed-agents-C7n1gl7E.js} +2 -2
- package/public/assets/{managed-agents-C4cb3xQ4.js.map → managed-agents-C7n1gl7E.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DQIi0O7r.js → markdown-preview-impl-DK0wNWXJ.js} +2 -2
- package/public/assets/{markdown-preview-impl-DQIi0O7r.js.map → markdown-preview-impl-DK0wNWXJ.js.map} +1 -1
- package/public/assets/{memory-DJPXFdgw.js → memory-BJEkDkwl.js} +2 -2
- package/public/assets/{memory-DJPXFdgw.js.map → memory-BJEkDkwl.js.map} +1 -1
- package/public/assets/{messages-RSD2g4Wx.js → messages-DBn9tXx2.js} +2 -2
- package/public/assets/{messages-RSD2g4Wx.js.map → messages-DBn9tXx2.js.map} +1 -1
- package/public/assets/{orchestrators-BGIkULBF.js → orchestrators-Bc1eC16Q.js} +2 -2
- package/public/assets/{orchestrators-BGIkULBF.js.map → orchestrators-Bc1eC16Q.js.map} +1 -1
- package/public/assets/{overview-ByE9V8pe.js → overview-Bw7zrSqR.js} +2 -2
- package/public/assets/{overview-ByE9V8pe.js.map → overview-Bw7zrSqR.js.map} +1 -1
- package/public/assets/{pairs-C284ntvD.js → pairs-BSwWnLK2.js} +2 -2
- package/public/assets/{pairs-C284ntvD.js.map → pairs-BSwWnLK2.js.map} +1 -1
- package/public/assets/{projects-B2-8Eu0e.js → projects-hU5KGW_T.js} +2 -2
- package/public/assets/{projects-B2-8Eu0e.js.map → projects-hU5KGW_T.js.map} +1 -1
- package/public/assets/{security-vDglScsg.js → security-wTBapq4K.js} +2 -2
- package/public/assets/{security-vDglScsg.js.map → security-wTBapq4K.js.map} +1 -1
- package/public/assets/{select-DaOiWDBR.js → select-D6HFOeLW.js} +2 -2
- package/public/assets/{select-DaOiWDBR.js.map → select-D6HFOeLW.js.map} +1 -1
- package/public/assets/{settings-DiLcbSqx.js → settings-CDzp7vqJ.js} +2 -2
- package/public/assets/{settings-DiLcbSqx.js.map → settings-CDzp7vqJ.js.map} +1 -1
- package/public/assets/{store-DYaQAXRv.js → store-DsqUEZOf.js} +2 -2
- package/public/assets/store-DsqUEZOf.js.map +1 -0
- package/public/assets/{tasks-DWJdJra3.js → tasks-Bd7Wjy6e.js} +2 -2
- package/public/assets/{tasks-DWJdJra3.js.map → tasks-Bd7Wjy6e.js.map} +1 -1
- package/public/assets/{teams-FejXkix4.js → teams-l3GUr2XV.js} +2 -2
- package/public/assets/{teams-FejXkix4.js.map → teams-l3GUr2XV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-Bkt1w42o.js → terminal-viewer-impl-BQMMWctd.js} +2 -2
- package/public/assets/{terminal-viewer-impl-Bkt1w42o.js.map → terminal-viewer-impl-BQMMWctd.js.map} +1 -1
- package/public/assets/{user-avatar-ClE2dMD8.js → user-avatar-DcA69UJK.js} +2 -2
- package/public/assets/{user-avatar-ClE2dMD8.js.map → user-avatar-DcA69UJK.js.map} +1 -1
- package/public/assets/{work-queue-DZdv9kOb.js → work-queue-BYlFeJ1g.js} +2 -2
- package/public/assets/{work-queue-DZdv9kOb.js.map → work-queue-BYlFeJ1g.js.map} +1 -1
- package/public/assets/{workspaces-BhblC-Nd.js → workspaces-C740m1ys.js} +2 -2
- package/public/assets/{workspaces-BhblC-Nd.js.map → workspaces-C740m1ys.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/src/adapter.ts +4 -3
- package/src/agent-busy-health.ts +93 -0
- package/src/agent-card-projection.ts +4 -1
- package/src/agent-lifecycle-events.ts +6 -1
- package/src/branch-landed.ts +81 -0
- package/src/cli/index.ts +3 -3
- package/src/cli/reply.ts +6 -1
- package/src/config.ts +9 -0
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +17 -0
- package/src/db/provider-quotas.ts +99 -21
- package/src/db/terminal-events.ts +56 -0
- package/src/maintenance/constants.ts +4 -0
- package/src/maintenance/jobs.ts +88 -0
- package/src/maintenance/teams.ts +116 -5
- package/src/maintenance/workspaces.ts +23 -10
- package/src/mcp/index.ts +2 -1
- package/src/mcp/tools-spawn.ts +119 -4
- package/src/notification-types.ts +6 -0
- package/src/provider-catalog-store.ts +3 -0
- package/src/routes/workspaces.ts +5 -0
- package/src/services/transient-agent-reaper.ts +4 -1
- package/src/spawn-targets.ts +8 -2
- package/src/steward-identity.ts +50 -0
- package/src/steward.ts +12 -4
- package/src/team-idle-tracker.ts +19 -0
- package/src/workspace-auto-merge.ts +35 -6
- package/src/workspace-pr-completion.ts +26 -0
- package/public/assets/index-LUIvWcx6.css +0 -2
- package/public/assets/index-TZskQfQL.js +0 -23
- 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
|
-
|
|
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",
|
package/src/branch-landed.ts
CHANGED
|
@@ -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 --
|
|
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 --
|
|
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)
|
|
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";
|
package/src/db/migrations.ts
CHANGED
|
@@ -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
|
-
|
|
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 -
|
|
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() -
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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)
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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,
|