agent-relay-server 0.84.0 → 0.85.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-DqFyach0.js} +2 -2
- package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-DqFyach0.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-CCvKbYWe.js} +2 -2
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-CCvKbYWe.js.map} +1 -1
- package/public/assets/{activity-XqRFu_9T.js → activity-C6RvknQQ.js} +2 -2
- package/public/assets/{activity-XqRFu_9T.js.map → activity-C6RvknQQ.js.map} +1 -1
- package/public/assets/{agents-BWmmWj4Q.js → agents-D7HZJ5eG.js} +2 -2
- package/public/assets/{agents-BWmmWj4Q.js.map → agents-D7HZJ5eG.js.map} +1 -1
- package/public/assets/{automation-DRtOENs0.js → automation-BUazA3lr.js} +2 -2
- package/public/assets/{automation-DRtOENs0.js.map → automation-BUazA3lr.js.map} +1 -1
- package/public/assets/{chat-gfUxbTus.js → chat-DBTEaf-H.js} +2 -2
- package/public/assets/{chat-gfUxbTus.js.map → chat-DBTEaf-H.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DpNHGXXQ.js → formatted-body-impl-KG0iExZ-.js} +2 -2
- package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-KG0iExZ-.js.map} +1 -1
- package/public/assets/index-LUIvWcx6.css +2 -0
- package/public/assets/index-TZskQfQL.js +23 -0
- package/public/assets/index-TZskQfQL.js.map +1 -0
- package/public/assets/{insights-DPaJ5Hme.js → insights-JL1s7Uul.js} +2 -2
- package/public/assets/{insights-DPaJ5Hme.js.map → insights-JL1s7Uul.js.map} +1 -1
- package/public/assets/{maintenance-DsszLb92.js → maintenance-CTMfhXFM.js} +2 -2
- package/public/assets/{maintenance-DsszLb92.js.map → maintenance-CTMfhXFM.js.map} +1 -1
- package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-C4cb3xQ4.js} +2 -2
- package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-C4cb3xQ4.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DQIi0O7r.js} +2 -2
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DQIi0O7r.js.map} +1 -1
- package/public/assets/{memory-C2s9bJ38.js → memory-DJPXFdgw.js} +2 -2
- package/public/assets/{memory-C2s9bJ38.js.map → memory-DJPXFdgw.js.map} +1 -1
- package/public/assets/{messages-DHRJNAvy.js → messages-RSD2g4Wx.js} +2 -2
- package/public/assets/{messages-DHRJNAvy.js.map → messages-RSD2g4Wx.js.map} +1 -1
- package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-BGIkULBF.js} +2 -2
- package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-BGIkULBF.js.map} +1 -1
- package/public/assets/{overview-C1vZfyhW.js → overview-ByE9V8pe.js} +2 -2
- package/public/assets/{overview-C1vZfyhW.js.map → overview-ByE9V8pe.js.map} +1 -1
- package/public/assets/{pairs-C1uzo8x0.js → pairs-C284ntvD.js} +2 -2
- package/public/assets/{pairs-C1uzo8x0.js.map → pairs-C284ntvD.js.map} +1 -1
- package/public/assets/{security-XhAAP6q3.js → security-vDglScsg.js} +2 -2
- package/public/assets/{security-XhAAP6q3.js.map → security-vDglScsg.js.map} +1 -1
- package/public/assets/{select-DEGUFEjx.js → select-DaOiWDBR.js} +2 -2
- package/public/assets/{select-DEGUFEjx.js.map → select-DaOiWDBR.js.map} +1 -1
- package/public/assets/{settings-DTbVUdlZ.js → settings-DiLcbSqx.js} +2 -2
- package/public/assets/{settings-DTbVUdlZ.js.map → settings-DiLcbSqx.js.map} +1 -1
- package/public/assets/{tasks-zwGo7hon.js → tasks-DWJdJra3.js} +2 -2
- package/public/assets/{tasks-zwGo7hon.js.map → tasks-DWJdJra3.js.map} +1 -1
- package/public/assets/{teams-Brc5D8ox.js → teams-FejXkix4.js} +2 -2
- package/public/assets/{teams-Brc5D8ox.js.map → teams-FejXkix4.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-Bkt1w42o.js} +2 -2
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-Bkt1w42o.js.map} +1 -1
- package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-ClE2dMD8.js} +2 -2
- package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-ClE2dMD8.js.map} +1 -1
- package/public/assets/{work-queue-ge4R7keo.js → work-queue-DZdv9kOb.js} +2 -2
- package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-DZdv9kOb.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/agent-death-diagnosis.ts +255 -0
- package/src/agent-lifecycle-events.ts +42 -19
- package/src/bus.ts +18 -4
- package/src/db/agents.ts +35 -2
- package/src/db/crash-reports.ts +115 -0
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +5 -0
- package/src/maintenance/jobs.ts +6 -1
- package/src/maintenance/teams.ts +68 -1
- package/src/mcp/index.ts +2 -1
- package/src/mcp/tools-messaging.ts +38 -0
- package/src/notification-types.ts +2 -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/services/terminal-provider-exit.ts +33 -0
- package/src/spawn-targets.ts +19 -8
- package/src/team-idle-tracker.ts +15 -0
- package/src/workspace-actions.ts +5 -1
- package/src/workspace-auto-merge.ts +87 -4
- package/src/workspace-merge.ts +10 -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
|
@@ -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,7 @@ 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";
|
|
30
31
|
export * from "./plans.ts";
|
|
31
32
|
export * from "./teams.ts";
|
|
32
33
|
export * from "./blackboard.ts";
|
package/src/db/migrations.ts
CHANGED
|
@@ -20,6 +20,7 @@ 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";
|
|
23
24
|
import { normalizeReactionEmoji } from "./mappers.ts";
|
|
24
25
|
import { normalizeProviderQuotaAccountKeys } from "./provider-quotas.ts";
|
|
25
26
|
import { projectRootsMissingIssueRepo, setProjectIssueRepo } from "./projects.ts";
|
|
@@ -795,6 +796,10 @@ export function applyMigrations(): void {
|
|
|
795
796
|
)
|
|
796
797
|
`);
|
|
797
798
|
|
|
799
|
+
// #636 — persisted crash post-mortems. Own table, no FK to agents, so a death verdict outlives
|
|
800
|
+
// the offline-prune/reaper and stays retrievable by agentId.
|
|
801
|
+
ensureCrashReportsSchema();
|
|
802
|
+
|
|
798
803
|
// First-class human users (#388 keystone #393): ensure the table (single DDL home in users.ts)
|
|
799
804
|
// and seed the admin-level default user mapped to the existing single human. P0 + idempotent —
|
|
800
805
|
// bare 'user' == user:default resolve to users.id='default' via the SDK resolver; no bytes move.
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
createActivityEvent,
|
|
8
8
|
evaluatePoolBindings,
|
|
9
9
|
expireQueuedMessages,
|
|
10
|
+
getAgent,
|
|
10
11
|
pruneOrphanedSharedWorkspaces,
|
|
11
12
|
pruneOfflineAgents,
|
|
12
13
|
pruneOldMessages,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
sweepArtifacts,
|
|
20
21
|
} from "../db";
|
|
21
22
|
import { notifyAgentOffline } from "../agent-lifecycle-events";
|
|
23
|
+
import { diagnoseAgentDeath } from "../agent-death-diagnosis";
|
|
22
24
|
import { autoMergeCleanFastForwards } from "../workspace-auto-merge";
|
|
23
25
|
import { reapOrphanedWorktrees } from "../workspace-orphans";
|
|
24
26
|
import { getLifecycleManager } from "../lifecycle-manager";
|
|
@@ -173,7 +175,10 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
173
175
|
for (const id of reapedAgentIds) {
|
|
174
176
|
emitAgentStatus(id);
|
|
175
177
|
getLifecycleManager().onAgentDisappeared(id);
|
|
176
|
-
|
|
178
|
+
// #636 — a heartbeat-lost death still gets a best-effort post-mortem from the agent's last
|
|
179
|
+
// known meta (lastExit/lastError/auth) + heartbeat gap, riding the terminal event.
|
|
180
|
+
const diagnosis = diagnoseAgentDeath({ agent: getAgent(id), reason: "heartbeat lost" });
|
|
181
|
+
notifyAgentOffline(id, "heartbeat lost", diagnosis);
|
|
177
182
|
createActivityEvent({
|
|
178
183
|
clientId: "server-agent-" + id + "-heartbeat-lost-" + Date.now(),
|
|
179
184
|
kind: "state",
|
package/src/maintenance/teams.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
clearTeamZeroMembersSince,
|
|
3
3
|
countLiveTeamMembers,
|
|
4
4
|
createActivityEvent,
|
|
5
|
+
getCrashReport,
|
|
5
6
|
getTeamBusyState,
|
|
6
7
|
getTeamRoster,
|
|
7
8
|
listActiveTeams,
|
|
@@ -13,10 +14,69 @@ import { dissolveTeamById } from "../services/team";
|
|
|
13
14
|
import { getTeamsConfig } from "../teams-config-store";
|
|
14
15
|
import { teamReapGraceMs } from "./constants";
|
|
15
16
|
import { notifyTarget } from "./events";
|
|
16
|
-
import { teamIdleTracker } from "../team-idle-tracker";
|
|
17
|
+
import { teamIdleTracker, teamMemberWatchTracker, type TeamMemberWatch } from "../team-idle-tracker";
|
|
18
|
+
import type { AgentCard } from "../types";
|
|
17
19
|
|
|
18
20
|
export function resetTeamIdleWatchdogForTests(): void {
|
|
19
21
|
teamIdleTracker.clear();
|
|
22
|
+
teamMemberWatchTracker.clear();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A member counts as "alive" (worth watching for a future silence) when it's past onboarding and
|
|
26
|
+
* heartbeating — i.e. a status that implies it became ready, not a never-started spawn. */
|
|
27
|
+
function memberIsAlive(member: AgentCard, freshCutoff: number): boolean {
|
|
28
|
+
return (member.status === "idle" || member.status === "busy" || member.status === "online") && member.lastSeen > freshCutoff;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* #634 — detect a team member that was ready/busy and then went silent (heartbeat-gap), EVEN IF it
|
|
33
|
+
* dropped from the roster entirely, and ping the coordinator once. The in-memory watch remembers
|
|
34
|
+
* each member's last-active time, so a vanished agent (no roster row) is still caught. Re-arms when
|
|
35
|
+
* a member recovers. Skips the ping when a #633 crash report already exists (the coordinator was
|
|
36
|
+
* already woken by the terminal event) — this is the backstop for the NO-terminal-event case.
|
|
37
|
+
*/
|
|
38
|
+
function detectSilentMembers(
|
|
39
|
+
teamId: string,
|
|
40
|
+
coordinatorId: string | undefined,
|
|
41
|
+
now: number,
|
|
42
|
+
thresholdMs: number,
|
|
43
|
+
out: { pingedMembers: string[]; watchedMembers: number },
|
|
44
|
+
): void {
|
|
45
|
+
const freshCutoff = now - thresholdMs;
|
|
46
|
+
const watch = teamMemberWatchTracker.get(teamId) ?? new Map<string, TeamMemberWatch>();
|
|
47
|
+
teamMemberWatchTracker.set(teamId, watch);
|
|
48
|
+
|
|
49
|
+
for (const member of getTeamRoster(teamId)) {
|
|
50
|
+
if (member.id === coordinatorId) continue; // the coordinator is the recipient, not a watched worker
|
|
51
|
+
if (!memberIsAlive(member, freshCutoff)) continue;
|
|
52
|
+
const existing = watch.get(member.id);
|
|
53
|
+
// Recovery re-arms the ping: a member seen alive again after a silence can re-trigger later.
|
|
54
|
+
const recovered = existing ? member.lastSeen > existing.lastActiveAt : false;
|
|
55
|
+
watch.set(member.id, {
|
|
56
|
+
lastActiveAt: Math.max(member.lastSeen, existing?.lastActiveAt ?? 0),
|
|
57
|
+
pinged: recovered ? false : existing?.pinged ?? false,
|
|
58
|
+
...(member.label ? { label: member.label } : {}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
out.watchedMembers += watch.size;
|
|
63
|
+
for (const [memberId, entry] of watch) {
|
|
64
|
+
const gapMs = now - entry.lastActiveAt;
|
|
65
|
+
if (gapMs < thresholdMs || entry.pinged) continue;
|
|
66
|
+
entry.pinged = true;
|
|
67
|
+
if (getCrashReport(memberId)) continue; // terminal event already woke the coordinator (#633)
|
|
68
|
+
if (!coordinatorId) continue;
|
|
69
|
+
const minutes = Math.floor(gapMs / 60_000);
|
|
70
|
+
notifyTarget(
|
|
71
|
+
"team.member_silent",
|
|
72
|
+
{ teamId, agentId: memberId },
|
|
73
|
+
coordinatorId,
|
|
74
|
+
"Team member went silent",
|
|
75
|
+
`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.`,
|
|
76
|
+
{ kind: "team.member_silent", teamId, coordinatorId, memberId, label: entry.label, silentForMs: gapMs, lastActiveAt: entry.lastActiveAt },
|
|
77
|
+
);
|
|
78
|
+
out.pingedMembers.push(memberId);
|
|
79
|
+
}
|
|
20
80
|
}
|
|
21
81
|
|
|
22
82
|
function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
|
|
@@ -79,9 +139,13 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
|
|
|
79
139
|
const pingedTeamIds: string[] = [];
|
|
80
140
|
const alreadyPingedTeamIds: string[] = [];
|
|
81
141
|
const missingCoordinatorTeamIds: string[] = [];
|
|
142
|
+
const memberSilence = { pingedMembers: [] as string[], watchedMembers: 0 };
|
|
82
143
|
|
|
83
144
|
for (const team of listActiveTeams()) {
|
|
84
145
|
seen.add(team.id);
|
|
146
|
+
// #634 — runs every cycle, independent of the team-level idle gate below: a single silent
|
|
147
|
+
// member must surface even while OTHER members keep the team "busy".
|
|
148
|
+
detectSilentMembers(team.id, team.coordinatorId, now, thresholdMs, memberSilence);
|
|
85
149
|
const busy = getTeamBusyState(team.id, cutoff);
|
|
86
150
|
if (busy.busyMemberIds.length > 0) {
|
|
87
151
|
teamIdleTracker.delete(team.id);
|
|
@@ -112,6 +176,7 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
|
|
|
112
176
|
}
|
|
113
177
|
|
|
114
178
|
for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
|
|
179
|
+
for (const key of teamMemberWatchTracker.keys()) if (!seen.has(key)) teamMemberWatchTracker.delete(key);
|
|
115
180
|
|
|
116
181
|
return {
|
|
117
182
|
enabled: true,
|
|
@@ -122,6 +187,8 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
|
|
|
122
187
|
pingedTeamIds,
|
|
123
188
|
alreadyPingedTeamIds,
|
|
124
189
|
missingCoordinatorTeamIds,
|
|
190
|
+
silentMemberIds: memberSilence.pingedMembers,
|
|
191
|
+
watchedMembers: memberSilence.watchedMembers,
|
|
125
192
|
};
|
|
126
193
|
}
|
|
127
194
|
|
package/src/mcp/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { errMessage, isRecord } from "agent-relay-sdk";
|
|
|
16
16
|
import type { JsonRpcId, JsonRpcRequest, McpAuthContext, ToolDefinition } from "./types";
|
|
17
17
|
import { positiveIdOrUndefined, recordField, stringField } from "./validation";
|
|
18
18
|
import { IDENTITY_TOOLS, hasAnyScope, mcpAuthContext, relayCompactAndResume, relayRecall, relayWhoami } from "./identity";
|
|
19
|
-
import { MESSAGING_TOOLS, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
|
|
19
|
+
import { MESSAGING_TOOLS, relayAgentCrashReport, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
|
|
20
20
|
import { SPAWN_TOOLS, relayShutdownAgent, relaySpawnAgent, relaySpawnTargets } from "./tools-spawn";
|
|
21
21
|
import { WORKSPACE_TOOLS, callerIsolatedWorkspace, relayWorkspaceList, relayWorkspaceMutation, relayWorkspaceStatus } from "./tools-workspace";
|
|
22
22
|
|
|
@@ -122,6 +122,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
122
122
|
else if (name === "relay_upload_artifact") result = await relayUploadArtifact(auth, args);
|
|
123
123
|
else if (name === "relay_attach_artifact") result = relayAttachArtifact(auth, args);
|
|
124
124
|
else if (name === "relay_agent_status") result = relayAgentStatus(args);
|
|
125
|
+
else if (name === "relay_agent_crash_report") result = relayAgentCrashReport(auth, args);
|
|
125
126
|
else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
|
|
126
127
|
else if (name === "relay_whoami") result = relayWhoami(auth);
|
|
127
128
|
else if (name === "relay_compact_and_resume") result = relayCompactAndResume(auth, args);
|
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
createArtifact,
|
|
9
9
|
getAgent,
|
|
10
10
|
getArtifact,
|
|
11
|
+
getCrashReport,
|
|
11
12
|
getMessage,
|
|
13
|
+
listCrashReports,
|
|
12
14
|
getOrchestrator,
|
|
13
15
|
getTask,
|
|
14
16
|
getThread,
|
|
@@ -190,6 +192,19 @@ export const MESSAGING_TOOLS: ToolDefinition[] = [
|
|
|
190
192
|
additionalProperties: false,
|
|
191
193
|
},
|
|
192
194
|
},
|
|
195
|
+
{
|
|
196
|
+
name: "relay_agent_crash_report",
|
|
197
|
+
description: "Retrieve the post-mortem for a dead agent (#636): a likely-cause label (oom · killed · clean-exit · token-401 · onboarding-gate · provider-crash · transport-loss · unknown) + log tail + heartbeat/active-work context. Pass agentId for one report (yourself or a child you spawned); omit it to list your own spawned children's reports. The report survives the reaper, so it answers 'why did my worker die?' after it's gone.",
|
|
198
|
+
requiredScopes: ["agents:read", "agent:read"],
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: {
|
|
202
|
+
agentId: { type: "string", description: "The dead agent's id. Must be yourself or one of your spawned children. Omit to list your own children's crash reports." },
|
|
203
|
+
limit: { type: "integer", minimum: 1, maximum: 200, description: "Max reports when listing (default 50)." },
|
|
204
|
+
},
|
|
205
|
+
additionalProperties: false,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
193
208
|
];
|
|
194
209
|
|
|
195
210
|
function runMcpSend(auth: McpAuthContext, input: SendMessageInput): Message & { delivery: DeliveryReceipt } {
|
|
@@ -450,6 +465,29 @@ export function relayFindAgents(auth: McpAuthContext, args: Record<string, unkno
|
|
|
450
465
|
return { agents: safe, total, count: safe.length, truncated: true, nextOffset: offset + safe.length };
|
|
451
466
|
}
|
|
452
467
|
|
|
468
|
+
// #636 — self/parent-scoped crash post-mortem retrieval. A caller may read its OWN report or any of
|
|
469
|
+
// its spawned children's; never an unrelated agent's. Survives the reaper (separate crash_reports table).
|
|
470
|
+
export function relayAgentCrashReport(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
471
|
+
const caller = callerAgentId(auth);
|
|
472
|
+
const agentId = optionalString(args.agentId, "agentId", 240);
|
|
473
|
+
if (agentId) {
|
|
474
|
+
// Authorize FIRST, regardless of whether a report exists — the self/parent contract must hold
|
|
475
|
+
// even for an agentId with no report (else any agent:read caller leaks `{report:null}`). Prefer
|
|
476
|
+
// the live agent row's lineage; fall back to the report's recorded parent once the agent is reaped.
|
|
477
|
+
const agent = getAgent(agentId);
|
|
478
|
+
const report = getCrashReport(agentId);
|
|
479
|
+
const parent = agent?.spawnedBy ?? report?.parentId;
|
|
480
|
+
const authorized = !!caller && (caller === agentId || (parent !== undefined && caller === parent));
|
|
481
|
+
if (!authorized) throw new McpAuthError("crash report is scoped to the agent itself or its spawn-parent");
|
|
482
|
+
return { agentId, report: report ?? null };
|
|
483
|
+
}
|
|
484
|
+
// No agentId: list the caller's own spawned children's reports.
|
|
485
|
+
if (!caller) throw new McpAuthError("relay_agent_crash_report requires a resolvable caller agent to list reports");
|
|
486
|
+
const limit = optionalPositiveInt(args.limit, "limit");
|
|
487
|
+
const reports = listCrashReports({ parentId: caller, ...(limit ? { limit } : {}) });
|
|
488
|
+
return { reports, count: reports.length };
|
|
489
|
+
}
|
|
490
|
+
|
|
453
491
|
function payloadWithAttachments(
|
|
454
492
|
payload: Record<string, unknown> | undefined,
|
|
455
493
|
attachments: AttachmentRef[] | undefined,
|
|
@@ -11,6 +11,7 @@ export type NotificationType =
|
|
|
11
11
|
| "agent.transient_land_hold"
|
|
12
12
|
| "agent.rate_limit_resume"
|
|
13
13
|
| "team.idle"
|
|
14
|
+
| "team.member_silent"
|
|
14
15
|
| "plan.bound_agent_exited"
|
|
15
16
|
| "workspace.steward_task"
|
|
16
17
|
| "workspace.conflict"
|
|
@@ -62,6 +63,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
62
63
|
"agent.transient_land_hold": null,
|
|
63
64
|
"agent.rate_limit_resume": 15 * MINUTES,
|
|
64
65
|
"team.idle": null,
|
|
66
|
+
"team.member_silent": null,
|
|
65
67
|
"plan.bound_agent_exited": null,
|
|
66
68
|
"workspace.steward_task": null,
|
|
67
69
|
"workspace.conflict": null,
|
package/src/routes/agents.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: agents.
|
|
2
2
|
import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, canViewAgentRoute, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, visibleAgentIdsForRequest, type Handler } from "./_shared";
|
|
3
|
-
import { ValidationError, deleteAgent, getAgent, getAgentTimeline, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
|
|
3
|
+
import { ValidationError, deleteAgent, getAgent, getAgentTimeline, getCrashReport, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
|
|
4
4
|
import { attachBranchState, attachBranchStates } from "../agent-branch-state";
|
|
5
5
|
import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
|
|
6
6
|
import { cleanStringArray } from "../validation";
|
|
@@ -68,6 +68,16 @@ export const getAgentById: Handler = (req, params) => {
|
|
|
68
68
|
return json(attachIssueRepo(attachBranchState(agent)));
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
+
// #636 — "why did it die" data source. The crash report outlives the agent row (separate table),
|
|
72
|
+
// so this resolves even after the reaper removed the agent. Existence-hiding still applies WHILE the
|
|
73
|
+
// agent exists; once reaped, an authenticated operator can read the post-mortem.
|
|
74
|
+
export const getAgentCrashReportRoute: Handler = (req, params) => {
|
|
75
|
+
const id = params.id!;
|
|
76
|
+
const agent = getAgent(id);
|
|
77
|
+
if (agent && !canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
|
|
78
|
+
return json({ agentId: id, report: getCrashReport(id) });
|
|
79
|
+
};
|
|
80
|
+
|
|
71
81
|
export const getAgentReplyObligations: Handler = (_req, params) => {
|
|
72
82
|
const agent = getAgent(params.id!);
|
|
73
83
|
return agent ? json(listPendingReplyObligations(agent.id)) : error("agent not found", 404);
|
package/src/routes/commands.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
|
|
|
16
16
|
import { seedProviderConfigs } from "../provider-config-store";
|
|
17
17
|
import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
|
|
18
18
|
import { handleReviewerMergeResult } from "../reviewer-pipeline";
|
|
19
|
-
import { scheduleRepoMerge } from "../workspace-auto-merge";
|
|
19
|
+
import { CLEAR_MERGE_NO_PROGRESS, noteMergeNoProgress, scheduleRepoMerge } from "../workspace-auto-merge";
|
|
20
20
|
import { enqueueContinuationInjectionAfterSelfResume } from "../self-resume";
|
|
21
21
|
import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
|
|
22
22
|
|
|
@@ -220,6 +220,11 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
220
220
|
mergeResult: command.result,
|
|
221
221
|
mergeCommandId: command.id,
|
|
222
222
|
mergedAt: Date.now(),
|
|
223
|
+
// A merge that settles `succeeded` made genuine progress (land / recycle /
|
|
224
|
+
// noop-resolve / PR-opened) — clear any prior no-progress back-off so the lane
|
|
225
|
+
// is attempted normally next time (#638).
|
|
226
|
+
...CLEAR_MERGE_NO_PROGRESS,
|
|
227
|
+
mergeError: undefined,
|
|
223
228
|
...(normalizedStatus !== resultStatus ? { mergeStatusNormalizedFrom: resultStatus } : {}),
|
|
224
229
|
// The land consumes the steward's claim (#208 steward contract / vent
|
|
225
230
|
// #62): a successful land must release it, or `steward inspect` keeps
|
|
@@ -259,12 +264,25 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
259
264
|
}
|
|
260
265
|
}
|
|
261
266
|
} else if (command.status === "failed" && command.correlationId) {
|
|
262
|
-
// Merge couldn't complete — don't leave it stuck in merge_planned.
|
|
267
|
+
// Merge couldn't complete — don't leave it stuck in merge_planned. A no-progress
|
|
268
|
+
// merge now settles `failed` (orchestrator #638) carrying the result; honor the
|
|
269
|
+
// orchestrator's intended target status when it gave a valid one so a real
|
|
270
|
+
// `conflict` routes to the steward (a non-landable status the auto-merge job
|
|
271
|
+
// skips, which itself stops the loop) rather than bouncing back to the landable
|
|
272
|
+
// `review_requested`. Surface the error and count the no-progress attempt so the
|
|
273
|
+
// auto-merge job backs off after N instead of busy-looping (#638).
|
|
263
274
|
const current = getWorkspace(command.correlationId);
|
|
264
275
|
if (current && current.status === "merge_planned") {
|
|
265
|
-
|
|
276
|
+
const failedResultStatus = isRecord(command.result)
|
|
277
|
+
? optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined
|
|
278
|
+
: undefined;
|
|
279
|
+
const nextStatus: WorkspaceStatus = failedResultStatus && failedResultStatus !== "merge_planned"
|
|
280
|
+
? failedResultStatus
|
|
281
|
+
: "review_requested";
|
|
282
|
+
updateWorkspaceStatus(command.correlationId, nextStatus, {
|
|
266
283
|
mergeCommandId: command.id,
|
|
267
284
|
mergeError: command.error ?? "merge failed",
|
|
285
|
+
...noteMergeNoProgress(current.metadata),
|
|
268
286
|
});
|
|
269
287
|
notifyBranchLandFailed({
|
|
270
288
|
workspace: current,
|
package/src/routes/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Router: route table + matchRoute dispatch.
|
|
2
|
-
import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
|
|
2
|
+
import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentCrashReportRoute, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
|
|
3
3
|
import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, putAgentProfileRoute } from "./agent-profiles";
|
|
4
4
|
import { deleteAgentTerminalSession, getAgentDrive, getAgentPresence, postAgentAction, postAgentDrive, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
|
|
5
5
|
import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, headUserAvatar, patchMe, patchUserById, postMyAvatar, postUserAvatarById } from "./users";
|
|
@@ -121,6 +121,7 @@ const routes: Route[] = [
|
|
|
121
121
|
route("GET", "/api/agents/:id/timeline", getAgentTimelineRoute),
|
|
122
122
|
route("GET", "/api/agents/:id/active-memories", getAgentActiveMemories),
|
|
123
123
|
route("GET", "/api/agents/:id/reply-obligations", getAgentReplyObligations),
|
|
124
|
+
route("GET", "/api/agents/:id/crash-report", getAgentCrashReportRoute),
|
|
124
125
|
route("DELETE", "/api/agents/:id/active-memories", deleteAgentActiveMemories),
|
|
125
126
|
route("GET", "/api/agents/:id", getAgentById),
|
|
126
127
|
route("PATCH", "/api/agents/:id/status", patchAgentStatus),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
|
|
3
3
|
import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "../contracts";
|
|
4
4
|
import { notifyAgentOffline } from "../agent-lifecycle-events";
|
|
5
|
+
import { diagnoseAgentDeath } from "../agent-death-diagnosis";
|
|
5
6
|
import { VERSION } from "../config";
|
|
6
7
|
import { ValidationError, deleteOrchestrator, getOrchestrator, listOrchestrators, orchestratorHeartbeat, recordAgentExitDiagnostics, setOrchestratorUpgradeState, updateManagedAgents, upsertOrchestrator } from "../db";
|
|
7
8
|
import { auditEvent, authAuditMetadata, authorizeRoute, cleanJsonArray, cleanSafeNumber, dashboardAttribution, emitCommand, error, isRootCredentialRequest, json, parseBody, spawnRequestId, type Handler } from "./_shared";
|
|
@@ -209,7 +210,10 @@ export const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
|
209
210
|
// (its WHERE excludes status='offline'), guaranteeing a single notifyAgentOffline emission.
|
|
210
211
|
// notifyAgentOffline no-ops for non-spawned agents. Only fire when we actually recorded the
|
|
211
212
|
// exit (agent !== null) so an already-offline row can't double-notify.
|
|
212
|
-
|
|
213
|
+
// #636 — classify the death from the captured exit diagnostics + agent state and ride the
|
|
214
|
+
// verdict on the terminal event (also persisted as a crash report inside notifyAgentOffline).
|
|
215
|
+
const diagnosis = diagnoseAgentDeath({ agent, reason: exitReason(exited), exit: exited });
|
|
216
|
+
notifyAgentOffline(exited.agentId, exitReason(exited), diagnosis);
|
|
213
217
|
}
|
|
214
218
|
auditEvent({
|
|
215
219
|
clientId: [
|
|
@@ -324,6 +328,15 @@ function cleanManagedSessionExitDiagnostics(value: unknown, index: number): Mana
|
|
|
324
328
|
runnerInfoFile: cleanString(value.runnerInfoFile, `exitedAgents[${index}].runnerInfoFile`, { max: 500 }),
|
|
325
329
|
runnerInfoPresent: typeof value.runnerInfoPresent === "boolean" ? value.runnerInfoPresent : undefined,
|
|
326
330
|
systemd,
|
|
331
|
+
// #636 — termination signal / exit code / OOM probe captured by the orchestrator.
|
|
332
|
+
signal: cleanString(value.signal, `exitedAgents[${index}].signal`, { max: 40 }),
|
|
333
|
+
exitCode: cleanSafeNumber(value.exitCode),
|
|
334
|
+
oom: isRecord(value.oom)
|
|
335
|
+
? {
|
|
336
|
+
source: cleanString(value.oom.source, `exitedAgents[${index}].oom.source`, { required: true, max: 80 })!,
|
|
337
|
+
detail: cleanString(value.oom.detail, `exitedAgents[${index}].oom.detail`, { max: 240 }),
|
|
338
|
+
}
|
|
339
|
+
: undefined,
|
|
327
340
|
unavailable,
|
|
328
341
|
lastError: cleanString(value.lastError, `exitedAgents[${index}].lastError`, { required: true, max: 1000 })!,
|
|
329
342
|
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// #633 — owning service for the silent clean-exit terminal event. A runner whose provider
|
|
2
|
+
// terminally exits stays alive (manual-resume hold), so neither the orchestrator tmux-gone path nor
|
|
3
|
+
// the heartbeat reaper fires. The runner marks the offline status edge with a `terminalProviderExit`
|
|
4
|
+
// blob; this service turns that edge into a terminal `agent.exited` event with a #636 diagnosis, and
|
|
5
|
+
// clears the hold when the provider genuinely resumes. Kept out of the bus transport so the
|
|
6
|
+
// transport stays thin (transport-thinness ratchet, #348).
|
|
7
|
+
import { isRecord } from "agent-relay-sdk";
|
|
8
|
+
import { clearAgentMetaKeys } from "../db";
|
|
9
|
+
import { notifyAgentOffline } from "../agent-lifecycle-events";
|
|
10
|
+
import { diagnoseAgentDeath } from "../agent-death-diagnosis";
|
|
11
|
+
import type { AgentCard } from "../types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* React to an agent status transition observed on the bus.
|
|
15
|
+
* - dead→live with a stale marker: clear the hold so a keepalive can resurrect normally and a future
|
|
16
|
+
* death isn't mislabelled off a stale marker.
|
|
17
|
+
* - live→offline carrying a `terminalProviderExit` marker: fire the terminal event once (edge), with
|
|
18
|
+
* a death diagnosis (also persisted as a crash report inside notifyAgentOffline). No-op for
|
|
19
|
+
* non-spawned agents (notifyAgentOffline guards on lineage) and graceful shutdowns (no marker).
|
|
20
|
+
*/
|
|
21
|
+
export function reconcileTerminalProviderExit(agentId: string, before: AgentCard | null | undefined, after: AgentCard | null | undefined): void {
|
|
22
|
+
if (after && after.status !== "offline" && before?.status === "offline" && isRecord(after.meta?.terminalProviderExit)) {
|
|
23
|
+
clearAgentMetaKeys(agentId, ["terminalProviderExit"]);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!after || after.status !== "offline") return;
|
|
27
|
+
if (before && before.status === "offline") return;
|
|
28
|
+
const marker = after.meta?.terminalProviderExit;
|
|
29
|
+
if (!isRecord(marker)) return;
|
|
30
|
+
const reasonText = typeof marker.reason === "string" ? marker.reason : "provider exited";
|
|
31
|
+
const reason = `provider exited (${reasonText})`;
|
|
32
|
+
notifyAgentOffline(agentId, reason, diagnoseAgentDeath({ agent: after, reason }));
|
|
33
|
+
}
|
package/src/spawn-targets.ts
CHANGED
|
@@ -48,28 +48,39 @@ export function buildSpawnTargets(input: { callerId?: string; quota: number }):
|
|
|
48
48
|
interface SpawnCatalogish {
|
|
49
49
|
provider: SpawnProvider;
|
|
50
50
|
defaultModel?: string;
|
|
51
|
-
models: Array<{ alias: string; providerModel: string; efforts: readonly string[]; defaultEffort?: string }>;
|
|
51
|
+
models: Array<{ alias: string; providerModel: string; efforts: readonly string[]; defaultEffort?: string; disabled?: boolean }>;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function providerMatrixFor(o: Orchestrator): Array<Record<string, unknown>> {
|
|
55
55
|
const reported: ProviderCatalogSummary[] = o.providerCatalog ?? [];
|
|
56
56
|
const global = effectiveProviderCatalogList();
|
|
57
|
+
// #643 — drop disabled models so relay_spawn_targets advertises only spawnable models (parity
|
|
58
|
+
// with the #485 dashboard pickers' `!model.disabled` filter). The disabled flag is a relay-side
|
|
59
|
+
// override (provider_model_overrides) that the effective catalog carries but the orchestrator-
|
|
60
|
+
// REPORTED catalog does not, so cross-reference the effective catalog by alias regardless of
|
|
61
|
+
// which source supplies the matrix entry — otherwise the reported path keeps advertising a model
|
|
62
|
+
// the server then rejects with "model is disabled".
|
|
63
|
+
const disabledAliasesFor = (provider: SpawnProvider): Set<string> =>
|
|
64
|
+
new Set(global.find((e) => e.provider === provider)?.models.filter((m) => m.disabled).map((m) => m.alias) ?? []);
|
|
57
65
|
return o.providers.map((provider) => {
|
|
58
66
|
const cat: SpawnCatalogish | undefined =
|
|
59
67
|
reported.find((s) => s.provider === provider) ?? global.find((e) => e.provider === provider);
|
|
68
|
+
const disabled = disabledAliasesFor(provider);
|
|
60
69
|
return {
|
|
61
70
|
provider,
|
|
62
71
|
available: true,
|
|
63
72
|
...(cat
|
|
64
73
|
? {
|
|
65
74
|
defaultModel: cat.defaultModel,
|
|
66
|
-
models: cat.models
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
models: cat.models
|
|
76
|
+
.filter((m) => !m.disabled && !disabled.has(m.alias))
|
|
77
|
+
.map((m) => ({
|
|
78
|
+
id: m.alias,
|
|
79
|
+
providerModel: m.providerModel,
|
|
80
|
+
default: cat.defaultModel === m.alias,
|
|
81
|
+
effortLevels: m.efforts,
|
|
82
|
+
...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}),
|
|
83
|
+
})),
|
|
73
84
|
}
|
|
74
85
|
: { models: [] }),
|
|
75
86
|
};
|
package/src/team-idle-tracker.ts
CHANGED
|
@@ -11,3 +11,18 @@ export const teamIdleTracker = new Map<string, TeamIdleEntry>();
|
|
|
11
11
|
export function getTeamIdleEntry(teamId: string): TeamIdleEntry | undefined {
|
|
12
12
|
return teamIdleTracker.get(teamId);
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
// #634 — per-member heartbeat-gap watch. Kept in-memory (not derived from the live roster) ON
|
|
16
|
+
// PURPOSE: a member that was ready/busy and then vanished from the roster entirely escapes any
|
|
17
|
+
// roster-only enumeration, so the watch must remember it independently to detect the silence gap.
|
|
18
|
+
export interface TeamMemberWatch {
|
|
19
|
+
/** Most recent time the member was seen alive (non-offline heartbeat). */
|
|
20
|
+
lastActiveAt: number;
|
|
21
|
+
/** Whether the member's CURRENT silence has already pinged the coordinator (re-armed on recovery). */
|
|
22
|
+
pinged: boolean;
|
|
23
|
+
/** Member label for a readable ping. */
|
|
24
|
+
label?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// teamId → (memberId → watch)
|
|
28
|
+
export const teamMemberWatchTracker = new Map<string, Map<string, TeamMemberWatch>>();
|
package/src/workspace-actions.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
|
|
|
25
25
|
export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
|
|
26
26
|
import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
|
|
27
27
|
import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
28
|
-
import { scheduleRepoMerge } from "./workspace-auto-merge";
|
|
28
|
+
import { CLEAR_MERGE_NO_PROGRESS, scheduleRepoMerge } from "./workspace-auto-merge";
|
|
29
29
|
import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
30
30
|
|
|
31
31
|
// Single source of truth for the action verb set. The route's `optionalEnum` and
|
|
@@ -235,6 +235,10 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
|
|
|
235
235
|
...metadata,
|
|
236
236
|
...(detail ? { detail } : {}),
|
|
237
237
|
...(agentId ? { updatedByAgentId: agentId } : {}),
|
|
238
|
+
// #638 — a fresh ready/request-review hand-off means the owner did more work, so a
|
|
239
|
+
// prior auto-merge no-progress back-off should clear: the new commits may resolve
|
|
240
|
+
// whatever blocked the land (e.g. a base that's since been synced). Re-arm the lane.
|
|
241
|
+
...(READY_TO_LAND_STATUSES.has(nextStatus) ? CLEAR_MERGE_NO_PROGRESS : {}),
|
|
238
242
|
lastWorkspaceAction: action,
|
|
239
243
|
lastWorkspaceActionAt: Date.now(),
|
|
240
244
|
});
|