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.
- package/docs/openapi.json +39 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-BTBQr8uG.js → MessageBubble-CevL-2jc.js} +2 -2
- package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-CevL-2jc.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-DGpJhuVw.js} +2 -2
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-DGpJhuVw.js.map} +1 -1
- package/public/assets/{activity-XqRFu_9T.js → activity-CY2Cbpet.js} +2 -2
- package/public/assets/{activity-XqRFu_9T.js.map → activity-CY2Cbpet.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-BWmmWj4Q.js → agents-Bj5KzrYJ.js} +2 -2
- package/public/assets/{agents-BWmmWj4Q.js.map → agents-Bj5KzrYJ.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-DRtOENs0.js → automation-BOPk1oh_.js} +2 -2
- package/public/assets/{automation-DRtOENs0.js.map → automation-BOPk1oh_.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-gfUxbTus.js → chat-D5pI4ebq.js} +2 -2
- package/public/assets/{chat-gfUxbTus.js.map → chat-D5pI4ebq.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-DpNHGXXQ.js → formatted-body-impl-Dr_z1UvS.js} +2 -2
- package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-Dr_z1UvS.js.map} +1 -1
- package/public/assets/index-CEXhh5nz.js +23 -0
- package/public/assets/index-CEXhh5nz.js.map +1 -0
- package/public/assets/index-LUIvWcx6.css +2 -0
- package/public/assets/{insights-DPaJ5Hme.js → insights-C4dEK49e.js} +2 -2
- package/public/assets/{insights-DPaJ5Hme.js.map → insights-C4dEK49e.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-DsszLb92.js → maintenance-DJZUSsja.js} +2 -2
- package/public/assets/{maintenance-DsszLb92.js.map → maintenance-DJZUSsja.js.map} +1 -1
- package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-5wUMDKVi.js} +2 -2
- package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-5wUMDKVi.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DOJQR3cO.js} +2 -2
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DOJQR3cO.js.map} +1 -1
- package/public/assets/{memory-C2s9bJ38.js → memory-BnhAD09c.js} +2 -2
- package/public/assets/{memory-C2s9bJ38.js.map → memory-BnhAD09c.js.map} +1 -1
- package/public/assets/{messages-DHRJNAvy.js → messages-Cc9YF8Wl.js} +2 -2
- package/public/assets/{messages-DHRJNAvy.js.map → messages-Cc9YF8Wl.js.map} +1 -1
- package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-B01iyiBA.js} +2 -2
- package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-B01iyiBA.js.map} +1 -1
- package/public/assets/{overview-C1vZfyhW.js → overview-DN8CUTVx.js} +2 -2
- package/public/assets/{overview-C1vZfyhW.js.map → overview-DN8CUTVx.js.map} +1 -1
- package/public/assets/{pairs-C1uzo8x0.js → pairs-BxuDMLhs.js} +2 -2
- package/public/assets/{pairs-C1uzo8x0.js.map → pairs-BxuDMLhs.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-XhAAP6q3.js → security-CO0PBIau.js} +2 -2
- package/public/assets/{security-XhAAP6q3.js.map → security-CO0PBIau.js.map} +1 -1
- package/public/assets/{select-DEGUFEjx.js → select-DHaFsLvt.js} +2 -2
- package/public/assets/{select-DEGUFEjx.js.map → select-DHaFsLvt.js.map} +1 -1
- package/public/assets/{settings-DTbVUdlZ.js → settings-De1DKwd6.js} +2 -2
- package/public/assets/{settings-DTbVUdlZ.js.map → settings-De1DKwd6.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-zwGo7hon.js → tasks-Bqd188ql.js} +2 -2
- package/public/assets/{tasks-zwGo7hon.js.map → tasks-Bqd188ql.js.map} +1 -1
- package/public/assets/{teams-Brc5D8ox.js → teams-Mh6ypDPr.js} +2 -2
- package/public/assets/{teams-Brc5D8ox.js.map → teams-Mh6ypDPr.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-B7x_JNZe.js} +2 -2
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-B7x_JNZe.js.map} +1 -1
- package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-Dy5J3E_b.js} +2 -2
- package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-Dy5J3E_b.js.map} +1 -1
- package/public/assets/{work-queue-ge4R7keo.js → work-queue-ClGgOUSu.js} +2 -2
- package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-ClGgOUSu.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-death-diagnosis.ts +255 -0
- package/src/agent-lifecycle-events.ts +47 -19
- package/src/branch-landed.ts +81 -0
- package/src/bus.ts +18 -4
- package/src/cli/index.ts +3 -3
- package/src/cli/reply.ts +6 -1
- package/src/config.ts +4 -0
- package/src/db/agents.ts +35 -2
- package/src/db/crash-reports.ts +115 -0
- package/src/db/index.ts +2 -0
- package/src/db/migrations.ts +22 -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 +19 -1
- package/src/maintenance/teams.ts +73 -1
- package/src/maintenance/workspaces.ts +23 -10
- package/src/mcp/index.ts +4 -2
- package/src/mcp/tools-messaging.ts +38 -0
- package/src/mcp/tools-spawn.ts +119 -4
- package/src/notification-types.ts +4 -0
- package/src/provider-catalog-store.ts +3 -0
- package/src/routes/agents.ts +11 -1
- package/src/routes/commands.ts +21 -3
- package/src/routes/index.ts +2 -1
- package/src/routes/orchestrator.ts +14 -1
- package/src/routes/workspaces.ts +5 -0
- package/src/services/terminal-provider-exit.ts +33 -0
- package/src/services/transient-agent-reaper.ts +4 -1
- package/src/spawn-targets.ts +25 -8
- package/src/steward-identity.ts +50 -0
- package/src/steward.ts +12 -4
- package/src/team-idle-tracker.ts +15 -0
- package/src/workspace-actions.ts +5 -1
- package/src/workspace-auto-merge.ts +120 -8
- package/src/workspace-merge.ts +10 -0
- package/src/workspace-pr-completion.ts +26 -0
- package/public/assets/index-D8TVUdlC.js +0 -22
- package/public/assets/index-D8TVUdlC.js.map +0 -1
- package/public/assets/index-DgZLDL0K.css +0 -2
- 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";
|
package/src/db/migrations.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/src/maintenance/teams.ts
CHANGED
|
@@ -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
|
|