agent-relay-server 0.120.6 → 0.121.1
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 +5 -4
- package/public/assets/{MessageBubble-So86mU9-.js → MessageBubble-CLqxCG9b.js} +2 -2
- package/public/assets/{MessageBubble-So86mU9-.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-DBZYAsww.js → PlanGraphPanel-BkkToFEb.js} +2 -2
- package/public/assets/{PlanGraphPanel-DBZYAsww.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
- package/public/assets/{activity-CZNwmXg1.js → activity-D-ocGQGs.js} +2 -2
- package/public/assets/{activity-CZNwmXg1.js.map → activity-D-ocGQGs.js.map} +1 -1
- package/public/assets/{agent-profiles-BhadoGwi.js → agent-profiles-CRPgYTyb.js} +2 -2
- package/public/assets/{agent-profiles-BhadoGwi.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
- package/public/assets/{agents-DWn1xx9x.js → agents-Chw5E_2q.js} +2 -2
- package/public/assets/{agents-DWn1xx9x.js.map → agents-Chw5E_2q.js.map} +1 -1
- package/public/assets/{analytics-DifGnk7q.js → analytics-BmNDG0hR.js} +2 -2
- package/public/assets/{analytics-DifGnk7q.js.map → analytics-BmNDG0hR.js.map} +1 -1
- package/public/assets/{automation-BExrtKkb.js → automation-D7g90kTv.js} +2 -2
- package/public/assets/{automation-BExrtKkb.js.map → automation-D7g90kTv.js.map} +1 -1
- package/public/assets/{branch-state-badge-CyC5Qrhc.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
- package/public/assets/{branch-state-badge-CyC5Qrhc.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
- package/public/assets/{channels-BLkdt2XR.js → channels-CLwPeEPi.js} +2 -2
- package/public/assets/{channels-BLkdt2XR.js.map → channels-CLwPeEPi.js.map} +1 -1
- package/public/assets/{chat-C7OhKtr6.js → chat-Cgj7VKzc.js} +2 -2
- package/public/assets/{chat-C7OhKtr6.js.map → chat-Cgj7VKzc.js.map} +1 -1
- package/public/assets/{connectors-C0n12Je8.js → connectors-Br6fiTny.js} +2 -2
- package/public/assets/{connectors-C0n12Je8.js.map → connectors-Br6fiTny.js.map} +1 -1
- package/public/assets/{formatted-body-BCQT2qSb.js → formatted-body-CmkuD23M.js} +3 -3
- package/public/assets/{formatted-body-BCQT2qSb.js.map → formatted-body-CmkuD23M.js.map} +1 -1
- package/public/assets/{formatted-body-impl-Bk7wnESl.js → formatted-body-impl-DbdFmbwW.js} +2 -2
- package/public/assets/{formatted-body-impl-Bk7wnESl.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
- package/public/assets/{index-CRraCeo_.js → index-D9OogaB5.js} +6 -6
- package/public/assets/{index-CRraCeo_.js.map → index-D9OogaB5.js.map} +1 -1
- package/public/assets/{insights-DEgES6jz.js → insights-qvaPqWCV.js} +2 -2
- package/public/assets/{insights-DEgES6jz.js.map → insights-qvaPqWCV.js.map} +1 -1
- package/public/assets/{integrations-v14ehskT.js → integrations-DYEGSqq_.js} +2 -2
- package/public/assets/{integrations-v14ehskT.js.map → integrations-DYEGSqq_.js.map} +1 -1
- package/public/assets/{maintenance-BgVB6_gO.js → maintenance-C0HIEB-J.js} +2 -2
- package/public/assets/{maintenance-BgVB6_gO.js.map → maintenance-C0HIEB-J.js.map} +1 -1
- package/public/assets/{managed-agents-8_NKSkhI.js → managed-agents-DdS7aI38.js} +2 -2
- package/public/assets/{managed-agents-8_NKSkhI.js.map → managed-agents-DdS7aI38.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-yxQkrd2M.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
- package/public/assets/{markdown-preview-impl-yxQkrd2M.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
- package/public/assets/{memory-CJD8eekg.js → memory-CSwx7bz8.js} +2 -2
- package/public/assets/{memory-CJD8eekg.js.map → memory-CSwx7bz8.js.map} +1 -1
- package/public/assets/{messages-DLHMZbHZ.js → messages-Be9CmvDv.js} +2 -2
- package/public/assets/{messages-DLHMZbHZ.js.map → messages-Be9CmvDv.js.map} +1 -1
- package/public/assets/{orchestrators-DEOep7FX.js → orchestrators-BgtvvHvo.js} +2 -2
- package/public/assets/{orchestrators-DEOep7FX.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
- package/public/assets/{overview-BgpfO6mt.js → overview-BvhbPWtF.js} +2 -2
- package/public/assets/{overview-BgpfO6mt.js.map → overview-BvhbPWtF.js.map} +1 -1
- package/public/assets/{pairs-8NVKHh96.js → pairs-CpXSOMx0.js} +2 -2
- package/public/assets/{pairs-8NVKHh96.js.map → pairs-CpXSOMx0.js.map} +1 -1
- package/public/assets/{projects-CZJW35C7.js → projects-DdPNPFJI.js} +2 -2
- package/public/assets/{projects-CZJW35C7.js.map → projects-DdPNPFJI.js.map} +1 -1
- package/public/assets/{prompt-settings-fqyLwUa-.js → prompt-settings-BpZse7-X.js} +2 -2
- package/public/assets/{prompt-settings-fqyLwUa-.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
- package/public/assets/{registry-BKbLL4mO.js → registry-pb1gTZEg.js} +2 -2
- package/public/assets/{registry-BKbLL4mO.js.map → registry-pb1gTZEg.js.map} +1 -1
- package/public/assets/{security-BlTFTbq0.js → security-BgcX1n9D.js} +2 -2
- package/public/assets/{security-BlTFTbq0.js.map → security-BgcX1n9D.js.map} +1 -1
- package/public/assets/{select-Zd_qh0uU.js → select-DZPVpKPv.js} +2 -2
- package/public/assets/{select-Zd_qh0uU.js.map → select-DZPVpKPv.js.map} +1 -1
- package/public/assets/{settings-nHuSVRkL.js → settings-hd5kzdRB.js} +4 -4
- package/public/assets/settings-hd5kzdRB.js.map +1 -0
- package/public/assets/store-DKTLubBT.js +9 -0
- package/public/assets/store-DKTLubBT.js.map +1 -0
- package/public/assets/{tasks-C3JhQ8vB.js → tasks-C5d2LU5E.js} +2 -2
- package/public/assets/{tasks-C3JhQ8vB.js.map → tasks-C5d2LU5E.js.map} +1 -1
- package/public/assets/{teams-C9E9Yt39.js → teams-B8f2pGJe.js} +2 -2
- package/public/assets/{teams-C9E9Yt39.js.map → teams-B8f2pGJe.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-FP0JlsvS.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
- package/public/assets/{terminal-viewer-impl-FP0JlsvS.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-Ema7Hy3s.js → work-queue-9imc7aNK.js} +2 -2
- package/public/assets/{work-queue-Ema7Hy3s.js.map → work-queue-9imc7aNK.js.map} +1 -1
- package/public/assets/{workspaces-DI317fMk.js → workspaces-CLYc3yF3.js} +2 -2
- package/public/assets/{workspaces-DI317fMk.js.map → workspaces-CLYc3yF3.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
- package/src/agent-card-projection.ts +39 -9
- package/src/agent-issue-repo.ts +10 -3
- package/src/agent-lifecycle-events.ts +23 -1
- package/src/agent-ref.ts +18 -3
- package/src/bus-outbox.ts +74 -1
- package/src/bus.ts +84 -5
- package/src/channel-target.ts +4 -0
- package/src/cli/steward.ts +15 -14
- package/src/commands-db.ts +63 -9
- package/src/config-store.ts +2 -0
- package/src/config.ts +54 -0
- package/src/db/claim-sql.ts +16 -0
- package/src/db/connection.ts +3 -1
- package/src/db/continuations.ts +44 -3
- package/src/db/index.ts +1 -0
- package/src/db/maintenance-worker.ts +91 -0
- package/src/db/message-reads.ts +34 -4
- package/src/db/messages.ts +147 -8
- package/src/db/migrations.ts +7 -0
- package/src/db/pairs.ts +1 -0
- package/src/db/retention.ts +130 -0
- package/src/db/schema.ts +11 -0
- package/src/db/stats.ts +22 -0
- package/src/event-loop-lag.ts +90 -0
- package/src/gate-resolver.ts +124 -0
- package/src/lifecycle-manager.ts +41 -10
- package/src/lifecycle-signal-registry.ts +87 -0
- package/src/maintenance/constants.ts +5 -1
- package/src/maintenance/jobs.ts +48 -8
- package/src/maintenance/workspaces.ts +75 -44
- package/src/managed-policy-escalation.ts +68 -3
- package/src/mcp/tools-spawn.ts +16 -6
- package/src/mcp-plan-tools.ts +2 -3
- package/src/notification-settings.ts +11 -0
- package/src/notification-types.ts +4 -0
- package/src/operator-alarm.ts +37 -0
- package/src/retention-config-store.ts +32 -0
- package/src/retention-config.ts +58 -0
- package/src/routes/agents.ts +4 -0
- package/src/routes/messages.ts +13 -1
- package/src/routes/orchestrator-proxy.ts +13 -1
- package/src/routes/orchestrator.ts +4 -0
- package/src/routing-policy-store.ts +11 -1
- package/src/security.ts +20 -0
- package/src/self-resume.ts +185 -21
- package/src/server.ts +4 -0
- package/src/services/plan-graph.ts +42 -12
- package/src/services/register-agent.ts +5 -1
- package/src/services/send-message.ts +99 -21
- package/src/services/spawn-agent.ts +21 -2
- package/src/services/spawn-registration-watchdog.ts +74 -9
- package/src/services/task-semantic-events.ts +17 -1
- package/src/settings/registry.ts +11 -0
- package/src/spawn-command.ts +3 -0
- package/src/spawn-targets.ts +26 -1
- package/src/sse.ts +67 -14
- package/src/workspace-auto-merge.ts +90 -34
- package/src/workspace-escalation.ts +52 -3
- package/src/workspace-human-escalation.ts +68 -0
- package/src/workspace-orphans.ts +11 -6
- package/src/workspace-phase.ts +22 -1
- package/public/assets/settings-nHuSVRkL.js.map +0 -1
- package/public/assets/store-nXGxgz2Q.js +0 -9
- package/public/assets/store-nXGxgz2Q.js.map +0 -1
|
@@ -1,19 +1,30 @@
|
|
|
1
|
-
// The ONE projection
|
|
1
|
+
// The ONE projection source for agent cards before they leave the relay:
|
|
2
2
|
// branch-state badge fields (#236) + the resolved `issueRepo` for chat #NNN linkify (#533).
|
|
3
3
|
//
|
|
4
4
|
// Every `agent.status` emitter MUST route through this — the direct dashboard emitter
|
|
5
|
-
// (sse.ts) AND the bus lifecycle emitter (bus.ts). The dashboard
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// which made #NNN links and branch badges flicker on/off every few seconds (#560). Keeping
|
|
9
|
-
// one projection here is the structural guarantee that projection == reality on every
|
|
10
|
-
// surface; a new emitter can't reintroduce the drift without going around this function.
|
|
5
|
+
// (sse.ts) AND the bus lifecycle emitter (bus.ts). The dashboard merges the slim status
|
|
6
|
+
// projection into its cached card, so a raw (un-enriched) status tick would still blank
|
|
7
|
+
// derived fields on fresh/new cards until the next REST poll (#560).
|
|
11
8
|
|
|
9
|
+
import { isRecord } from "agent-relay-sdk";
|
|
12
10
|
import { attachBranchState } from "./agent-branch-state";
|
|
13
11
|
import { deriveAgentBusyHealth } from "./agent-busy-health";
|
|
14
12
|
import { attachIssueRepo } from "./agent-issue-repo";
|
|
15
13
|
import type { AgentCard } from "./types";
|
|
16
14
|
|
|
15
|
+
const OMITTED_STATUS_META_KEYS = new Set([
|
|
16
|
+
"agentProfile",
|
|
17
|
+
"auth",
|
|
18
|
+
"providerCapabilities",
|
|
19
|
+
"lifecycleCapabilities",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const OMITTED_STATUS_WORKSPACE_KEYS = new Set([
|
|
23
|
+
"deps",
|
|
24
|
+
"probe",
|
|
25
|
+
"symlinks",
|
|
26
|
+
]);
|
|
27
|
+
|
|
17
28
|
/** Enrich an AgentCard with the derived fields the dashboard expects on every surface. */
|
|
18
29
|
export function projectAgentCard(agent: AgentCard): AgentCard {
|
|
19
30
|
const projected = attachIssueRepo(attachBranchState(agent));
|
|
@@ -21,11 +32,30 @@ export function projectAgentCard(agent: AgentCard): AgentCard {
|
|
|
21
32
|
return busyHealth ? { ...projected, meta: { ...(projected.meta ?? {}), busyHealth } } : projected;
|
|
22
33
|
}
|
|
23
34
|
|
|
35
|
+
function projectStatusMeta(meta: AgentCard["meta"]): AgentCard["meta"] | undefined {
|
|
36
|
+
if (!meta) return undefined;
|
|
37
|
+
const projected: Record<string, unknown> = {};
|
|
38
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
39
|
+
if (OMITTED_STATUS_META_KEYS.has(key)) continue;
|
|
40
|
+
if (key === "workspace" && isRecord(value)) {
|
|
41
|
+
projected.workspace = Object.fromEntries(Object.entries(value).filter(([workspaceKey]) => !OMITTED_STATUS_WORKSPACE_KEYS.has(workspaceKey)));
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
projected[key] = value;
|
|
45
|
+
}
|
|
46
|
+
return projected;
|
|
47
|
+
}
|
|
48
|
+
|
|
24
49
|
/** The `agent.status` SSE payload: the enriched card when the agent is known, else the
|
|
25
|
-
* minimal offline stub the dashboard treats as "agent gone". Shared by both emitters.
|
|
50
|
+
* minimal offline stub the dashboard treats as "agent gone". Shared by both emitters.
|
|
51
|
+
* Runtime status stays live, while bulky static/secret launch projection fields stay
|
|
52
|
+
* behind GET /api/agents/:id and the normal dashboard polling path. */
|
|
26
53
|
export function projectAgentStatusData(
|
|
27
54
|
agentId: string,
|
|
28
55
|
agent: AgentCard | null | undefined,
|
|
29
56
|
): AgentCard | { id: string; status: string } {
|
|
30
|
-
|
|
57
|
+
if (!agent) return { id: agentId, status: "offline" };
|
|
58
|
+
const projected = projectAgentCard(agent);
|
|
59
|
+
const meta = projectStatusMeta(projected.meta);
|
|
60
|
+
return meta ? { ...projected, meta } : { ...projected, meta: undefined };
|
|
31
61
|
}
|
package/src/agent-issue-repo.ts
CHANGED
|
@@ -13,11 +13,18 @@ function agentCwd(agent: AgentCard): string | undefined {
|
|
|
13
13
|
return typeof cwd === "string" && cwd ? cwd : undefined;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** Resolve an agent's project issueRepo via its cwd, or undefined if it has no cwd or no
|
|
17
|
+
* matching project/issueRepo. Exported so other callers needing a caller-scoped default repo
|
|
18
|
+
* (e.g. plan-graph's bare `#NNN` issue ref resolution) reuse this instead of re-deriving cwd
|
|
19
|
+
* → project → issueRepo themselves. */
|
|
20
|
+
export function issueRepoForAgent(agent: AgentCard): string | undefined {
|
|
21
|
+
const cwd = agentCwd(agent);
|
|
22
|
+
return cwd ? resolveProjectForCwd(cwd)?.issueRepo : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
/** Enrich a single agent with its resolved project issueRepo (one project_roots scan). */
|
|
17
26
|
export function attachIssueRepo(agent: AgentCard): AgentCard {
|
|
18
|
-
const
|
|
19
|
-
if (!cwd) return agent;
|
|
20
|
-
const repo = resolveProjectForCwd(cwd)?.issueRepo;
|
|
27
|
+
const repo = issueRepoForAgent(agent);
|
|
21
28
|
return repo ? { ...agent, issueRepo: repo } : agent;
|
|
22
29
|
}
|
|
23
30
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { emitRelayEvent } from "./events";
|
|
2
2
|
import { routeNotification } from "./notification-router";
|
|
3
|
-
import {
|
|
3
|
+
import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSignalKey } from "./lifecycle-signal-registry";
|
|
4
|
+
import { deliverableTaskIdsBySpawnRequestId, getAgent, markAgentReadyNotified, recordCrashReport, recordTerminalEvent } from "./db";
|
|
4
5
|
import { crashReportFor } from "./agent-death-diagnosis";
|
|
5
6
|
import { renderTemplate } from "./prompt-resolver";
|
|
6
7
|
import type { AgentCard, AgentDeathDiagnosis } from "./types";
|
|
@@ -82,6 +83,26 @@ function spawnRequestIdOf(child: AgentCard): string | undefined {
|
|
|
82
83
|
return typeof s === "string" ? s : undefined;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
// #1032b — record that a first-class lifecycle event (ready/exit) told the parent about this child,
|
|
87
|
+
// so a redundant "Task status pulse" for the same child/task is suppressed instead of double-waking.
|
|
88
|
+
// #1038 — agent.ready means the child is coming up to work its task, so it is state-keyed to
|
|
89
|
+
// `working` (same as a report-up): it supersedes a redundant `working` pulse only, never a later
|
|
90
|
+
// distinct-state transition for the same task within the TTL.
|
|
91
|
+
function stampChildLifecycleSignal(parent: string, child: AgentCard): void {
|
|
92
|
+
stampLifecycleSignal(parent, childSignalKey(child.id));
|
|
93
|
+
const direct = child.meta?.taskId;
|
|
94
|
+
const directNum = typeof direct === "string" ? Number(direct) : direct;
|
|
95
|
+
if (typeof directNum === "number" && Number.isSafeInteger(directNum) && directNum > 0) {
|
|
96
|
+
stampLifecycleSignal(parent, taskSignalKey(directNum, LIFECYCLE_STATE_WORKING));
|
|
97
|
+
}
|
|
98
|
+
const spawnRequestId = spawnRequestIdOf(child);
|
|
99
|
+
if (spawnRequestId) {
|
|
100
|
+
for (const taskId of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) {
|
|
101
|
+
stampLifecycleSignal(parent, taskSignalKey(taskId, LIFECYCLE_STATE_WORKING));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
/**
|
|
86
107
|
* A spawned child became genuinely ready (registered AND past onboarding — ready+idle, not merely
|
|
87
108
|
* process-started). Emits the durable `agent.ready` event and, unless suppressed, pushes the
|
|
@@ -152,6 +173,7 @@ export function notifyAgentReady(childId: string): void {
|
|
|
152
173
|
replyExpected: false,
|
|
153
174
|
},
|
|
154
175
|
});
|
|
176
|
+
stampChildLifecycleSignal(parent, child);
|
|
155
177
|
}
|
|
156
178
|
|
|
157
179
|
/**
|
package/src/agent-ref.ts
CHANGED
|
@@ -99,9 +99,19 @@ function idMatchesSegment(id: string, ref: string): boolean {
|
|
|
99
99
|
export function matchAgents(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): AgentCard[] {
|
|
100
100
|
const trimmed = ref.trim();
|
|
101
101
|
if (!trimmed) return [];
|
|
102
|
-
const pool = opts.excludeId ? agents.filter((a) => a.id !== opts.excludeId) : agents;
|
|
103
102
|
|
|
104
103
|
const selector = parseSelector(trimmed);
|
|
104
|
+
// An exact id reference — a bare full id, or an `id:` selector — names ONE agent unambiguously,
|
|
105
|
+
// so it is honored even when it targets the excluded (self) agent: addressing your own full id
|
|
106
|
+
// is a deliberate self/peer send, NOT the #290 bare-ref self-loop that `excludeId` guards
|
|
107
|
+
// against. Without this exemption an exact self-id is refused by the matcher yet still offered by
|
|
108
|
+
// the "did you mean" hint — the resolver suggesting the very id it just rejected (#1035).
|
|
109
|
+
const exactId = selector ? (selector.field === "id" ? selector.value : null) : trimmed;
|
|
110
|
+
const selfExact = opts.excludeId && exactId
|
|
111
|
+
? agents.find((a) => a.id === opts.excludeId && a.id === exactId)
|
|
112
|
+
: undefined;
|
|
113
|
+
const pool = opts.excludeId && !selfExact ? agents.filter((a) => a.id !== opts.excludeId) : agents;
|
|
114
|
+
|
|
105
115
|
if (selector) return selector.value ? pool.filter((a) => matchesSelector(a, selector)) : [];
|
|
106
116
|
|
|
107
117
|
const exact = pool.filter((a) => matchesExact(a, trimmed));
|
|
@@ -207,9 +217,14 @@ function ambiguousMessage(ref: string, candidates: AgentCard[]): string {
|
|
|
207
217
|
return `agent reference "${ref}" is ambiguous — matches ${candidates.length}: ${list}${more}. Use a full id, label, or a fan-out selector (tag:/cap:/label:/team:).`;
|
|
208
218
|
}
|
|
209
219
|
|
|
210
|
-
function notFoundMessage(ref: string, agents: AgentCard[]): string {
|
|
220
|
+
function notFoundMessage(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): string {
|
|
211
221
|
const needle = ref.toLowerCase();
|
|
212
222
|
const near = agents
|
|
223
|
+
// Canonicalize the suggestion pool to the SAME identity the matcher used: never offer the
|
|
224
|
+
// excluded (self) agent, and never suggest an id/label that EQUALS the ref — if it were an
|
|
225
|
+
// exact match it would have resolved, so surfacing it means offering the id we just rejected
|
|
226
|
+
// (#1035, the "Did you mean: <the id you typed>?" self-suggestion).
|
|
227
|
+
.filter((a) => a.id !== opts.excludeId && a.id !== ref && a.label !== ref)
|
|
213
228
|
.filter((a) => isPseudoAgent(a) && (a.id.toLowerCase().includes(needle) || a.label?.toLowerCase().includes(needle)))
|
|
214
229
|
.slice(0, 3)
|
|
215
230
|
.map(describeAgent);
|
|
@@ -261,7 +276,7 @@ export function planSend(to: string, agents: AgentCard[], opts: ResolveOptions =
|
|
|
261
276
|
if (offline.length > 1) {
|
|
262
277
|
return { kind: "ambiguous", message: ambiguousMessage(target, offline), candidates: offline };
|
|
263
278
|
}
|
|
264
|
-
return { kind: "not_found", message: notFoundMessage(target, agents) };
|
|
279
|
+
return { kind: "not_found", message: notFoundMessage(target, agents, opts) };
|
|
265
280
|
}
|
|
266
281
|
|
|
267
282
|
/**
|
package/src/bus-outbox.ts
CHANGED
|
@@ -20,6 +20,23 @@ interface OutboxRow {
|
|
|
20
20
|
|
|
21
21
|
export function appendEvent(type: string, source: string, data: unknown, subject?: string): number {
|
|
22
22
|
const timestamp = Date.now();
|
|
23
|
+
if (type === "message.batch") {
|
|
24
|
+
const messages = batchMessages(data);
|
|
25
|
+
if (messages.length > 0) {
|
|
26
|
+
return getDb().transaction(() => {
|
|
27
|
+
let firstSeq = 0;
|
|
28
|
+
const insert = getDb().query(`
|
|
29
|
+
INSERT INTO bus_outbox (event_type, source, subject, data, timestamp)
|
|
30
|
+
VALUES (?, ?, ?, ?, ?)
|
|
31
|
+
`);
|
|
32
|
+
for (const message of messages) {
|
|
33
|
+
const result = insert.run("message.new", message.from, String(message.id), JSON.stringify(message), timestamp);
|
|
34
|
+
if (firstSeq === 0) firstSeq = Number(result.lastInsertRowid);
|
|
35
|
+
}
|
|
36
|
+
return firstSeq;
|
|
37
|
+
})();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
23
40
|
const result = getDb().query(`
|
|
24
41
|
INSERT INTO bus_outbox (event_type, source, subject, data, timestamp)
|
|
25
42
|
VALUES (?, ?, ?, ?, ?)
|
|
@@ -28,6 +45,24 @@ export function appendEvent(type: string, source: string, data: unknown, subject
|
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
export function replayEvents(since: number, limit = 500): BusEvent[] {
|
|
48
|
+
return replayEventsSlice(since, limit).events;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ReplaySlice {
|
|
52
|
+
events: BusEvent[];
|
|
53
|
+
/** The highest real outbox row seq in this slice (or `since` when empty). Resume from here to
|
|
54
|
+
* continue past the slice — driven by the raw row seq, not the (possibly batch-expanded) payload
|
|
55
|
+
* seq, so a legacy `message.batch` row can't be half-skipped. */
|
|
56
|
+
lastSeq: number;
|
|
57
|
+
/** More retained events exist beyond `lastSeq` — the caller MUST fetch the next slice, or the
|
|
58
|
+
* backlog tail between this slice and the outbox head is silently dropped (#1022 F1). */
|
|
59
|
+
truncated: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One page of the resume backlog plus whether more remains. `replayEvents`' 500-row cap used to
|
|
63
|
+
* be a silent ceiling: a resume returning exactly `limit` rows dropped everything between the last
|
|
64
|
+
* replayed event and the outbox head. Callers loop on `truncated`, resuming from `lastSeq`. */
|
|
65
|
+
export function replayEventsSlice(since: number, limit = 500): ReplaySlice {
|
|
31
66
|
const rows = getDb().query(`
|
|
32
67
|
SELECT seq, event_type, source, subject, data, timestamp
|
|
33
68
|
FROM bus_outbox
|
|
@@ -35,7 +70,10 @@ export function replayEvents(since: number, limit = 500): BusEvent[] {
|
|
|
35
70
|
ORDER BY seq ASC
|
|
36
71
|
LIMIT ?
|
|
37
72
|
`).all(since, limit) as OutboxRow[];
|
|
38
|
-
|
|
73
|
+
const events = rows.map(rowToEvent);
|
|
74
|
+
const lastSeq = rows.length > 0 ? rows[rows.length - 1]!.seq : since;
|
|
75
|
+
const truncated = rows.length === limit && getOutboxCursor() > lastSeq;
|
|
76
|
+
return { events, lastSeq, truncated };
|
|
39
77
|
}
|
|
40
78
|
|
|
41
79
|
export function pruneOutbox(retentionMs = 60 * 60 * 1000): number {
|
|
@@ -54,6 +92,28 @@ export function getOldestOutboxCursor(): number {
|
|
|
54
92
|
return row.cursor ?? 0;
|
|
55
93
|
}
|
|
56
94
|
|
|
95
|
+
/** The highest seq ever assigned to the outbox — read from `sqlite_sequence`, so it survives the
|
|
96
|
+
* retention prune that empties the table. Needed for gap detection: once the outbox is fully
|
|
97
|
+
* pruned `getOldestOutboxCursor()` returns 0, which read as "no gap" and let a reconnecting client
|
|
98
|
+
* believe it was caught up while its post-cursor events had aged out (#1022 F2). */
|
|
99
|
+
export function getOutboxHighWater(): number {
|
|
100
|
+
const row = getDb().query("SELECT seq FROM sqlite_sequence WHERE name = 'bus_outbox'").get() as { seq: number } | undefined;
|
|
101
|
+
return row?.seq ?? 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether a resume from `since` has lost events to the retention prune. Two cases:
|
|
105
|
+
* - outbox non-empty: the oldest retained event is newer than `since + 1` → the events between
|
|
106
|
+
* were pruned.
|
|
107
|
+
* - outbox fully pruned (oldest = 0): a gap exists iff events after `since` were ever assigned
|
|
108
|
+
* (`since` is behind the high-water mark) and have since aged out.
|
|
109
|
+
* A client that hits a gap must reconcile from scratch rather than resume from a stale cursor. */
|
|
110
|
+
export function hasReplayGap(since: number): boolean {
|
|
111
|
+
if (since <= 0) return false;
|
|
112
|
+
const oldest = getOldestOutboxCursor();
|
|
113
|
+
if (oldest > 0) return oldest > since + 1;
|
|
114
|
+
return since < getOutboxHighWater();
|
|
115
|
+
}
|
|
116
|
+
|
|
57
117
|
function rowToEvent(row: OutboxRow): BusEvent {
|
|
58
118
|
return {
|
|
59
119
|
seq: row.seq,
|
|
@@ -73,3 +133,16 @@ function parseData(raw: string): Record<string, unknown> {
|
|
|
73
133
|
return {};
|
|
74
134
|
}
|
|
75
135
|
}
|
|
136
|
+
|
|
137
|
+
function batchMessages(data: unknown): Array<{ id: number; from: string } & Record<string, unknown>> {
|
|
138
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
|
|
139
|
+
const messages = (data as { messages?: unknown }).messages;
|
|
140
|
+
if (!Array.isArray(messages)) return [];
|
|
141
|
+
return messages.filter((message): message is { id: number; from: string } & Record<string, unknown> =>
|
|
142
|
+
Boolean(message) &&
|
|
143
|
+
typeof message === "object" &&
|
|
144
|
+
!Array.isArray(message) &&
|
|
145
|
+
typeof (message as { id?: unknown }).id === "number" &&
|
|
146
|
+
typeof (message as { from?: unknown }).from === "string"
|
|
147
|
+
);
|
|
148
|
+
}
|
package/src/bus.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
2
|
import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus, validateAgentSession } from "./db";
|
|
3
|
-
import {
|
|
3
|
+
import { getOutboxCursor, hasReplayGap, replayEventsSlice, type BusEvent } from "./bus-outbox";
|
|
4
4
|
import { projectAgentStatusData } from "./agent-card-projection";
|
|
5
5
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
6
6
|
import { getCommand, updateCommand } from "./commands-db";
|
|
@@ -15,6 +15,7 @@ import { agentProviderTimelineEvent, auditRunnerTimelineEvent } from "./provider
|
|
|
15
15
|
import { ServiceAuthError } from "./services/errors";
|
|
16
16
|
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
|
|
17
17
|
import { flushQueuedDirectMessages } from "./services/managed-running";
|
|
18
|
+
import { flushIdleLineageReport } from "./services/send-message";
|
|
18
19
|
import { isAgentUnavailable } from "./db/agent-predicates";
|
|
19
20
|
import { applyCommandToRecipe } from "./recipe-runner";
|
|
20
21
|
import { enqueueContinuationInjectionAfterSelfResume } from "./self-resume";
|
|
@@ -105,6 +106,10 @@ export function busHandleClose(ws: BusWebSocket): void {
|
|
|
105
106
|
if (!conn.transportReconnect) {
|
|
106
107
|
setStatus(conn.agentId, "stale", { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
107
108
|
emitAgentStatusEvent(conn.agentId);
|
|
109
|
+
// #1040 — a WS close is a busy→exit edge (the runner crashed/dropped before its stop-hook
|
|
110
|
+
// status frame). Flush any held turn-final report-up so it still reaches the spawner instead
|
|
111
|
+
// of dying with the socket. No-op when nothing was deferred.
|
|
112
|
+
flushIdleLineageReport(conn.agentId);
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
}
|
|
@@ -123,6 +128,9 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs()): { agentIds: s
|
|
|
123
128
|
revokeRuntimeTokensForAgent(row.id, Date.now(), "stale");
|
|
124
129
|
setStatus(row.id, "offline");
|
|
125
130
|
emitAgentStatusEvent(row.id);
|
|
131
|
+
// #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
|
|
132
|
+
// clean idle transition. Flush any held turn-final report-up before the agent ages out.
|
|
133
|
+
flushIdleLineageReport(row.id);
|
|
126
134
|
const tasks = orphanTasksForAgent(row.id);
|
|
127
135
|
for (const task of tasks) {
|
|
128
136
|
orphanedTasks.push(task);
|
|
@@ -156,7 +164,14 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
156
164
|
const wasUnavailable = current ? isAgentUnavailable(current) : false;
|
|
157
165
|
const runtime = runtimeFromMeta(frame.payload.meta);
|
|
158
166
|
heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
|
|
159
|
-
if (frame.payload.status)
|
|
167
|
+
if (frame.payload.status) {
|
|
168
|
+
setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
169
|
+
// #1040 — a heartbeat frame can carry the busy→idle transition instead of a `status`
|
|
170
|
+
// frame. Flush the held report-up on THIS edge too, else `before.status` is already
|
|
171
|
+
// non-busy at the next status frame → the flush never fires → the child deadlocks idle
|
|
172
|
+
// with its verdict held and the coordinator waits forever.
|
|
173
|
+
if (current?.status === "busy" && frame.payload.status !== "busy") flushIdleLineageReport(conn.agentId);
|
|
174
|
+
}
|
|
160
175
|
emitAgentStatusEvent(conn.agentId);
|
|
161
176
|
// #416 — flush queued messages if the agent transitioned from unavailable to available.
|
|
162
177
|
if (wasUnavailable) {
|
|
@@ -200,6 +215,9 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
200
215
|
emitAgentStatusEvent(conn.agentId);
|
|
201
216
|
// #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
|
|
202
217
|
probeWorkspaceOnTurnEnd(before, after);
|
|
218
|
+
// #1037 — the same busy→idle turn-end edge flushes a quiet child's deferred turn-final
|
|
219
|
+
// report-up to its spawner (held back while the child was mid-work).
|
|
220
|
+
if (before?.status === "busy" && after && after.status !== "busy") flushIdleLineageReport(conn.agentId);
|
|
203
221
|
// #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
|
|
204
222
|
// marks the offline edge with `terminalProviderExit`; the owning service turns that into a
|
|
205
223
|
// terminal event + #636 diagnosis (and clears the hold on a genuine resume).
|
|
@@ -466,18 +484,23 @@ function hasRunnerLifecycleCapabilities(capabilities: string[], meta: Record<str
|
|
|
466
484
|
}
|
|
467
485
|
|
|
468
486
|
function handleResume(ws: BusWebSocket, frameId: string, since: number): void {
|
|
469
|
-
|
|
470
|
-
if (since > 0 && oldest > 0 && oldest > since + 1) {
|
|
487
|
+
if (hasReplayGap(since)) {
|
|
471
488
|
sendError(ws, frameId, "REPLAY_GAP", "requested cursor is older than retained bus outbox events");
|
|
472
489
|
return;
|
|
473
490
|
}
|
|
474
|
-
|
|
491
|
+
// One page only. The replay cap silently dropped the backlog tail between item 500 and the head
|
|
492
|
+
// (#1022 F1); we now report `truncated` + the raw `nextSince` row seq so the client loops resume
|
|
493
|
+
// until it has drained the whole backlog rather than lurching to the live head on the next event.
|
|
494
|
+
const slice = replayEventsSlice(since);
|
|
495
|
+
const events = slice.events.flatMap(outboxToPayloads);
|
|
475
496
|
send(ws, {
|
|
476
497
|
type: "resumed",
|
|
477
498
|
payload: {
|
|
478
499
|
events,
|
|
479
500
|
fromSeq: events[0]?.seq ?? since,
|
|
480
501
|
toSeq: events.at(-1)?.seq ?? since,
|
|
502
|
+
nextSince: slice.lastSeq,
|
|
503
|
+
truncated: slice.truncated,
|
|
481
504
|
},
|
|
482
505
|
});
|
|
483
506
|
}
|
|
@@ -503,6 +526,10 @@ function withRegistered(
|
|
|
503
526
|
}
|
|
504
527
|
|
|
505
528
|
function broadcastRelayEvent(event: RelayEvent): void {
|
|
529
|
+
if (event.type === "message.batch") {
|
|
530
|
+
broadcastMessageBatch(event);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
506
533
|
const frame = JSON.stringify({
|
|
507
534
|
type: "event",
|
|
508
535
|
payload: {
|
|
@@ -521,6 +548,41 @@ function broadcastRelayEvent(event: RelayEvent): void {
|
|
|
521
548
|
}
|
|
522
549
|
}
|
|
523
550
|
|
|
551
|
+
function broadcastMessageBatch(event: RelayEvent): void {
|
|
552
|
+
const messages = batchMessages(event.data);
|
|
553
|
+
const messageEvents = messages.map((message, index) => {
|
|
554
|
+
const messageEvent: RelayEvent = {
|
|
555
|
+
...event,
|
|
556
|
+
seq: event.seq + index,
|
|
557
|
+
type: "message.new",
|
|
558
|
+
source: message.from,
|
|
559
|
+
subject: String(message.id),
|
|
560
|
+
data: message as unknown as Record<string, unknown>,
|
|
561
|
+
};
|
|
562
|
+
return {
|
|
563
|
+
event: messageEvent,
|
|
564
|
+
frame: JSON.stringify({
|
|
565
|
+
type: "event",
|
|
566
|
+
payload: {
|
|
567
|
+
seq: messageEvent.seq,
|
|
568
|
+
eventType: "message.new",
|
|
569
|
+
source: message.from,
|
|
570
|
+
subject: String(message.id),
|
|
571
|
+
data: message,
|
|
572
|
+
timestamp: event.timestamp,
|
|
573
|
+
},
|
|
574
|
+
}),
|
|
575
|
+
};
|
|
576
|
+
});
|
|
577
|
+
const agentCache = new Map<string, AgentCard | null>();
|
|
578
|
+
for (const conn of busConnections.values()) {
|
|
579
|
+
for (const { event: messageEvent, frame } of messageEvents) {
|
|
580
|
+
if (!connectionWantsEvent(conn, messageEvent, agentCache)) continue;
|
|
581
|
+
conn.ws.send(frame);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
524
586
|
function fanoutAgent(agentId: string, cache: Map<string, AgentCard | null>): AgentCard | null {
|
|
525
587
|
if (!cache.has(agentId)) cache.set(agentId, getAgent(agentId));
|
|
526
588
|
return cache.get(agentId) ?? null;
|
|
@@ -565,6 +627,23 @@ function outboxToPayload(event: BusEvent) {
|
|
|
565
627
|
};
|
|
566
628
|
}
|
|
567
629
|
|
|
630
|
+
function outboxToPayloads(event: BusEvent) {
|
|
631
|
+
if (event.eventType !== "message.batch") return [outboxToPayload(event)];
|
|
632
|
+
return batchMessages(event.data).map((message, index) => ({
|
|
633
|
+
seq: event.seq + index,
|
|
634
|
+
eventType: "message.new",
|
|
635
|
+
source: message.from,
|
|
636
|
+
subject: String(message.id),
|
|
637
|
+
data: message as unknown as Record<string, unknown>,
|
|
638
|
+
timestamp: event.timestamp,
|
|
639
|
+
}));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function batchMessages(data: Record<string, unknown>): Message[] {
|
|
643
|
+
const messages = (data as { messages?: unknown }).messages;
|
|
644
|
+
return Array.isArray(messages) ? messages as Message[] : [];
|
|
645
|
+
}
|
|
646
|
+
|
|
568
647
|
function emitAgentStatusEvent(agentId: string): void {
|
|
569
648
|
const agent = getAgent(agentId);
|
|
570
649
|
// Same enriched projection as sse.ts's emitAgentStatus — without this the bus lifecycle
|
package/src/channel-target.ts
CHANGED
|
@@ -19,6 +19,10 @@ export function parseChannelRouteTarget(target: string): ChannelRouteTarget {
|
|
|
19
19
|
if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
|
|
20
20
|
if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
|
|
21
21
|
if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
|
|
22
|
+
// `capability:` is an accepted alias of `cap:` at send time (planSend/FANOUT_PREFIXES + the SDK
|
|
23
|
+
// matcher both honor it). Parse it here too so channel routing, broadcast scoping and the
|
|
24
|
+
// claimable gate don't silently treat a `capability:` message as a bare agent id (#1022 F3).
|
|
25
|
+
if (target.startsWith("capability:")) return { type: "capability", id: target.slice("capability:".length) };
|
|
22
26
|
if (target === "broadcast") return { type: "broadcast" };
|
|
23
27
|
if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
|
|
24
28
|
return { type: "agent", id: target };
|
package/src/cli/steward.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Steward commands — auto-split from cli.ts (#294). Briefing rail for the steward
|
|
2
2
|
// (#208): the attention queue, per-workspace diagnostics, and check suggestions.
|
|
3
3
|
import { apiRequest } from "./_shared";
|
|
4
|
+
import { resolveStewardChecks } from "../gate-resolver";
|
|
4
5
|
|
|
5
6
|
// Steward briefing commands (#208): queue of workspaces needing attention, a
|
|
6
7
|
// per-workspace diagnostics inspection, and a check-command suggestion.
|
|
@@ -39,21 +40,21 @@ export async function handleStewardCommand(args: string[]): Promise<void> {
|
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
// checks: suggest validation commands from the workspace's changed files.
|
|
43
|
+
// checks: suggest validation commands from the workspace's changed files. Derive
|
|
44
|
+
// the gate from the repo (#1036) — hardcoding `bun test` produced bogus failures in
|
|
45
|
+
// non-bun repos. Resolve the worktree to read its package.json / land-gates pin:
|
|
46
|
+
// an explicit `--repo` wins, else the workspace's own worktreePath/repoRoot.
|
|
43
47
|
const diff = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diff?patch=0`) as any;
|
|
44
48
|
const files: string[] = Array.isArray(diff?.files) ? diff.files.map((f: any) => f.path) : [];
|
|
45
|
-
|
|
49
|
+
let repoRoot = repo;
|
|
50
|
+
if (!repoRoot) {
|
|
51
|
+
try {
|
|
52
|
+
const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`) as any;
|
|
53
|
+
repoRoot = ws?.worktreePath ?? ws?.repoRoot;
|
|
54
|
+
} catch {
|
|
55
|
+
// Best-effort: without a resolvable path the resolver falls back to its default.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const checks = resolveStewardChecks(files, repoRoot);
|
|
46
59
|
console.log(JSON.stringify({ workspaceId: id, changedFiles: files.length, checks }, null, 2));
|
|
47
60
|
}
|
|
48
|
-
|
|
49
|
-
// Heuristic check suggestions from changed file paths. Repo-agnostic defaults a
|
|
50
|
-
// steward can refine; cheaper than re-deriving from project docs every run.
|
|
51
|
-
function suggestStewardChecks(files: string[]): Array<{ command: string; reason: string }> {
|
|
52
|
-
const checks: Array<{ command: string; reason: string }> = [];
|
|
53
|
-
const has = (re: RegExp) => files.some((f) => re.test(f));
|
|
54
|
-
if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
|
|
55
|
-
if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
|
|
56
|
-
else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
|
|
57
|
-
if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
|
|
58
|
-
return checks;
|
|
59
|
-
}
|
package/src/commands-db.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { STALE_TTL_MS } from "./config";
|
|
2
3
|
import { getAgent, getDb, getTerminalEvent, recordTerminalEvent } from "./db";
|
|
3
4
|
import { stringValue } from "agent-relay-sdk";
|
|
4
5
|
import type { Command, CommandStatus, CreateCommandInput, UpdateCommandInput } from "./types";
|
|
@@ -63,6 +64,14 @@ function isTerminalStatus(status: CommandStatus): boolean {
|
|
|
63
64
|
function isLegitimateTerminalCorrection(type: string, from: CommandStatus, to: CommandStatus): boolean {
|
|
64
65
|
if (type === "agent.spawn" && from === "succeeded" && to === "failed") return true;
|
|
65
66
|
if (type === "orchestrator.upgrade" && from === "failed" && to === "succeeded") return true;
|
|
67
|
+
// #1025 A7 — a lost merge RESULT: the orchestrator lands the branch (base pushed,
|
|
68
|
+
// worktree recycled onto `<branch>-N`) but dies before POSTing, so the command ages to
|
|
69
|
+
// `timed_out`. When it finally reports the real `succeeded` result, that late correction
|
|
70
|
+
// must be applied — otherwise setWorkspaceBranch never runs, the row keeps the old
|
|
71
|
+
// (deleted) branch, the worktree sits on `-N`, and every later preview reports `missing`
|
|
72
|
+
// → the lane degrades into a silent previewUnavailable strand (A5). The merge already
|
|
73
|
+
// happened, so there is no double-land hazard: applying the result only reconciles state.
|
|
74
|
+
if (type === "workspace.merge" && from === "timed_out" && to === "succeeded") return true;
|
|
66
75
|
return false;
|
|
67
76
|
}
|
|
68
77
|
|
|
@@ -162,16 +171,34 @@ export function findLatestCommandByCorrelation(type: string, correlationId: stri
|
|
|
162
171
|
return row ? rowToCommand(row) : null;
|
|
163
172
|
}
|
|
164
173
|
|
|
165
|
-
|
|
174
|
+
// #1034 — count a parent's still-in-flight spawn commands, EXCLUDING any whose child has
|
|
175
|
+
// already registered as a live agent. Spawn commands settle to a terminal status
|
|
176
|
+
// asynchronously (the orchestrator reports back AFTER the child registers), so between
|
|
177
|
+
// registration and settlement a child is otherwise double-counted: once by
|
|
178
|
+
// `countLiveSpawnedAgents` (ready=1) and once here (command still running). Combined into
|
|
179
|
+
// `occupied = live + inFlight`, that transient over-count could push a parent at its quota
|
|
180
|
+
// boundary over the limit and spuriously reject a concurrent spawn — which then "succeeded
|
|
181
|
+
// seconds later" once the command settled. The `NOT EXISTS` dedup mirrors the live-child
|
|
182
|
+
// liveness predicate in `countLiveSpawnedAgents` (and the display-side dedup already in
|
|
183
|
+
// relay_my_agents) so each child counts exactly once.
|
|
184
|
+
export function countInFlightSpawnCommands(parentId: string, now: number = Date.now()): number {
|
|
166
185
|
const row = getDb()
|
|
167
186
|
.query(
|
|
168
187
|
`SELECT count(*) AS n
|
|
169
|
-
FROM commands
|
|
170
|
-
WHERE type = 'agent.spawn'
|
|
171
|
-
AND status IN (${SPAWN_IN_FLIGHT_STATUSES.map(() => "?").join(",")})
|
|
172
|
-
AND json_extract(params, '$.spawnedBy') =
|
|
188
|
+
FROM commands c
|
|
189
|
+
WHERE c.type = 'agent.spawn'
|
|
190
|
+
AND c.status IN (${SPAWN_IN_FLIGHT_STATUSES.map(() => "?").join(",")})
|
|
191
|
+
AND json_extract(c.params, '$.spawnedBy') = ?
|
|
192
|
+
AND NOT EXISTS (
|
|
193
|
+
SELECT 1 FROM agents a
|
|
194
|
+
WHERE a.spawned_by = ?
|
|
195
|
+
AND a.status NOT IN ('offline', 'stale')
|
|
196
|
+
AND a.ready = 1
|
|
197
|
+
AND a.last_seen > ?
|
|
198
|
+
AND json_extract(a.meta, '$.spawnRequestId') = coalesce(json_extract(c.params, '$.spawnRequestId'), c.correlation_id)
|
|
199
|
+
)`,
|
|
173
200
|
)
|
|
174
|
-
.get(...SPAWN_IN_FLIGHT_STATUSES, parentId) as { n: number };
|
|
201
|
+
.get(...SPAWN_IN_FLIGHT_STATUSES, parentId, parentId, now - STALE_TTL_MS) as { n: number };
|
|
175
202
|
return row.n;
|
|
176
203
|
}
|
|
177
204
|
|
|
@@ -244,11 +271,38 @@ export function deleteCommand(id: string): boolean {
|
|
|
244
271
|
return true;
|
|
245
272
|
}
|
|
246
273
|
|
|
247
|
-
|
|
248
|
-
|
|
274
|
+
// #1025 A6 — sweep both claimed/active leases past their deadline AND pending commands
|
|
275
|
+
// whose target orchestrator is permanently gone. Pending commands are normally left for a
|
|
276
|
+
// busy host to poll (#930/#953), but a `workspace.merge` (or `agent.spawn`) whose target
|
|
277
|
+
// host was decommissioned / re-registered under a new agent id will NEVER poll again — the
|
|
278
|
+
// command lingers forever, `recoverAgedInFlightWorkspaces` files the workspace under
|
|
279
|
+
// `stillActive` indefinitely, and the parked worker waits on a `landed-success` that can't
|
|
280
|
+
// fire. The `isTargetGone` predicate (supplied by the caller, which knows agent liveness)
|
|
281
|
+
// gates the pending sweep so a merely-busy host is untouched; a gone target's expired
|
|
282
|
+
// pending command is timed out, then settled → the workspace returns to review_requested
|
|
283
|
+
// and re-schedules, and the spawn parent is told. Opt-in: callers without the predicate
|
|
284
|
+
// keep the historical claimed/active-only behavior.
|
|
285
|
+
export function expireCommands(
|
|
286
|
+
now: number = Date.now(),
|
|
287
|
+
opts: { isTargetGone?: (command: Command) => boolean } = {},
|
|
288
|
+
): Command[] {
|
|
289
|
+
const activeRows = getDb()
|
|
249
290
|
.query(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status IN (${CLAIMED_ACTIVE_STATUSES.map(() => "?").join(",")})`)
|
|
250
291
|
.all(now, ...CLAIMED_ACTIVE_STATUSES) as CommandRow[];
|
|
251
|
-
|
|
292
|
+
let pendingRows: CommandRow[] = [];
|
|
293
|
+
if (opts.isTargetGone) {
|
|
294
|
+
const candidates = getDb()
|
|
295
|
+
.query(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status = 'pending'`)
|
|
296
|
+
.all(now) as CommandRow[];
|
|
297
|
+
pendingRows = candidates.filter((row) => opts.isTargetGone!(rowToCommand(row)));
|
|
298
|
+
}
|
|
299
|
+
const rows = [...activeRows, ...pendingRows];
|
|
300
|
+
for (const row of rows) {
|
|
301
|
+
updateCommand(row.id, {
|
|
302
|
+
status: "timed_out",
|
|
303
|
+
error: row.status === "pending" ? "command target gone; expired before dispatch" : "command timed out",
|
|
304
|
+
});
|
|
305
|
+
}
|
|
252
306
|
return rows.map((row) => getCommand(row.id)).filter((command): command is Command => Boolean(command));
|
|
253
307
|
}
|
|
254
308
|
|
package/src/config-store.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
validateNotificationsConfig,
|
|
10
10
|
} from "./notification-settings";
|
|
11
11
|
import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
|
|
12
|
+
import { RETENTION_NAMESPACE, validateRetentionConfig } from "./retention-config";
|
|
12
13
|
import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
|
|
13
14
|
import { PROJECT_INSTRUCTION_POLICY_NAMES } from "./project-instructions";
|
|
14
15
|
import { renderTemplate } from "./prompt-resolver";
|
|
@@ -674,6 +675,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
|
|
|
674
675
|
if (namespace === SELF_RESUME_NAMESPACE) return validateSelfResumeConfig(value);
|
|
675
676
|
if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
|
|
676
677
|
if (namespace === LANDING_NAMESPACE) return validateLandingConfig(value);
|
|
678
|
+
if (namespace === RETENTION_NAMESPACE) return validateRetentionConfig(value);
|
|
677
679
|
if (namespace === TEAM_TEMPLATE_NAMESPACE) return validateTeamTemplate(key, value);
|
|
678
680
|
if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
|
|
679
681
|
return value;
|