agent-relay-server 0.123.2 → 0.125.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +1 -1
- package/package.json +3 -3
- package/public/assets/{MessageBubble-NCggGKTA.js → MessageBubble-BYeYFteG.js} +2 -2
- package/public/assets/{MessageBubble-NCggGKTA.js.map → MessageBubble-BYeYFteG.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CSdszmGk.js → PlanGraphPanel-iAXvkZKW.js} +2 -2
- package/public/assets/{PlanGraphPanel-CSdszmGk.js.map → PlanGraphPanel-iAXvkZKW.js.map} +1 -1
- package/public/assets/{activity-Cyp2nolX.js → activity-Du9SRJt5.js} +2 -2
- package/public/assets/{activity-Cyp2nolX.js.map → activity-Du9SRJt5.js.map} +1 -1
- package/public/assets/agent-profiles-DXDQnrlH.js +2 -0
- package/public/assets/{agent-profiles-D5Izg0Vn.js.map → agent-profiles-DXDQnrlH.js.map} +1 -1
- package/public/assets/{agent-type-icon-BxUopALO.js → agent-type-icon-B3i0rndK.js} +2 -2
- package/public/assets/{agent-type-icon-BxUopALO.js.map → agent-type-icon-B3i0rndK.js.map} +1 -1
- package/public/assets/{agents-DIOxuJ6M.js → agents-CWOdKrxP.js} +2 -2
- package/public/assets/{agents-DIOxuJ6M.js.map → agents-CWOdKrxP.js.map} +1 -1
- package/public/assets/{analytics-DqAuUAwX.js → analytics-YcY59Hv9.js} +2 -2
- package/public/assets/{analytics-DqAuUAwX.js.map → analytics-YcY59Hv9.js.map} +1 -1
- package/public/assets/{automation-Bn6mOLAR.js → automation-CItTse7F.js} +2 -2
- package/public/assets/{automation-Bn6mOLAR.js.map → automation-CItTse7F.js.map} +1 -1
- package/public/assets/{branch-state-badge-Mf0Ow77K.js → branch-state-badge-QjTktTYj.js} +2 -2
- package/public/assets/{branch-state-badge-Mf0Ow77K.js.map → branch-state-badge-QjTktTYj.js.map} +1 -1
- package/public/assets/{channels-DVRkVeky.js → channels-BSFTsbhT.js} +2 -2
- package/public/assets/{channels-DVRkVeky.js.map → channels-BSFTsbhT.js.map} +1 -1
- package/public/assets/chat-CRzFSFWe.js +2 -0
- package/public/assets/chat-CRzFSFWe.js.map +1 -0
- package/public/assets/{connectors-Cqubkw9C.js → connectors-C7d5dlYz.js} +2 -2
- package/public/assets/{connectors-Cqubkw9C.js.map → connectors-C7d5dlYz.js.map} +1 -1
- package/public/assets/{display-GFbGUG0O.js → display-aTnwsqQh.js} +3 -3
- package/public/assets/{display-GFbGUG0O.js.map → display-aTnwsqQh.js.map} +1 -1
- package/public/assets/{formatted-body-CvrQ2D1J.js → formatted-body-BuLfVMeD.js} +3 -3
- package/public/assets/{formatted-body-CvrQ2D1J.js.map → formatted-body-BuLfVMeD.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BJ7jdbpR.js → formatted-body-impl-DO-8giS6.js} +2 -2
- package/public/assets/{formatted-body-impl-BJ7jdbpR.js.map → formatted-body-impl-DO-8giS6.js.map} +1 -1
- package/public/assets/index-C9X_0QBt.css +2 -0
- package/public/assets/index-De1tR7J4.js +26 -0
- package/public/assets/index-De1tR7J4.js.map +1 -0
- package/public/assets/{insights-CQGH5LhD.js → insights-Ci3VuA5Y.js} +2 -2
- package/public/assets/{insights-CQGH5LhD.js.map → insights-Ci3VuA5Y.js.map} +1 -1
- package/public/assets/{integrations-9G5TJnnU.js → integrations-BboxWQQD.js} +2 -2
- package/public/assets/{integrations-9G5TJnnU.js.map → integrations-BboxWQQD.js.map} +1 -1
- package/public/assets/{maintenance-7c7UvpAt.js → maintenance-CO1miJIJ.js} +2 -2
- package/public/assets/{maintenance-7c7UvpAt.js.map → maintenance-CO1miJIJ.js.map} +1 -1
- package/public/assets/{managed-agents-yh4HPO5a.js → managed-agents-r4oYK3Fm.js} +2 -2
- package/public/assets/{managed-agents-yh4HPO5a.js.map → managed-agents-r4oYK3Fm.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BuOn5-Uc.js → markdown-preview-impl-5CCM9iyL.js} +2 -2
- package/public/assets/{markdown-preview-impl-BuOn5-Uc.js.map → markdown-preview-impl-5CCM9iyL.js.map} +1 -1
- package/public/assets/{memory-Qv0JN8CE.js → memory-BJJS07W-.js} +2 -2
- package/public/assets/{memory-Qv0JN8CE.js.map → memory-BJJS07W-.js.map} +1 -1
- package/public/assets/{messages-CgjHJmc2.js → messages-RT_dfEsL.js} +2 -2
- package/public/assets/{messages-CgjHJmc2.js.map → messages-RT_dfEsL.js.map} +1 -1
- package/public/assets/{orchestrators-CgL4CFzb.js → orchestrators-8hyl8hW1.js} +2 -2
- package/public/assets/{orchestrators-CgL4CFzb.js.map → orchestrators-8hyl8hW1.js.map} +1 -1
- package/public/assets/{overview-Bri04eYE.js → overview-CUJ9QmW_.js} +2 -2
- package/public/assets/{overview-Bri04eYE.js.map → overview-CUJ9QmW_.js.map} +1 -1
- package/public/assets/{pairs-C78LwUH2.js → pairs-CzkzMGZq.js} +2 -2
- package/public/assets/{pairs-C78LwUH2.js.map → pairs-CzkzMGZq.js.map} +1 -1
- package/public/assets/{projects-CquHTMo1.js → projects-CAuLWBU5.js} +2 -2
- package/public/assets/{projects-CquHTMo1.js.map → projects-CAuLWBU5.js.map} +1 -1
- package/public/assets/{prompt-settings-DDEkMqPZ.js → prompt-settings-BnB90R7F.js} +2 -2
- package/public/assets/{prompt-settings-DDEkMqPZ.js.map → prompt-settings-BnB90R7F.js.map} +1 -1
- package/public/assets/registry-B8TInqru.js +4 -0
- package/public/assets/registry-B8TInqru.js.map +1 -0
- package/public/assets/{security-wvnRKFvm.js → security-DG_VVZUW.js} +2 -2
- package/public/assets/{security-wvnRKFvm.js.map → security-DG_VVZUW.js.map} +1 -1
- package/public/assets/{select-BIfbVmLf.js → select-BJ8G38_V.js} +2 -2
- package/public/assets/{select-BIfbVmLf.js.map → select-BJ8G38_V.js.map} +1 -1
- package/public/assets/{settings-B6Ntahx7.js → settings-CK_T9z5w.js} +4 -4
- package/public/assets/{settings-B6Ntahx7.js.map → settings-CK_T9z5w.js.map} +1 -1
- package/public/assets/{store-zFLzd0Jd.js → store-Dl9Z8YUS.js} +4 -4
- package/public/assets/{store-zFLzd0Jd.js.map → store-Dl9Z8YUS.js.map} +1 -1
- package/public/assets/{tasks-CUglafo5.js → tasks-Briui9Mp.js} +2 -2
- package/public/assets/{tasks-CUglafo5.js.map → tasks-Briui9Mp.js.map} +1 -1
- package/public/assets/{teams-EE8RHhHM.js → teams-Ck4EVV3R.js} +2 -2
- package/public/assets/{teams-EE8RHhHM.js.map → teams-Ck4EVV3R.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DBRkdyag.js → terminal-viewer-impl-66ZjVN1v.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DBRkdyag.js.map → terminal-viewer-impl-66ZjVN1v.js.map} +1 -1
- package/public/assets/{types-CRptbSYh.js → types-BqeSkWrv.js} +2 -2
- package/public/assets/{types-CRptbSYh.js.map → types-BqeSkWrv.js.map} +1 -1
- package/public/assets/{user-avatar-kYPfLgXp.js → user-avatar-BOy9o2xK.js} +2 -2
- package/public/assets/{user-avatar-kYPfLgXp.js.map → user-avatar-BOy9o2xK.js.map} +1 -1
- package/public/assets/{work-queue-BFQVVL4T.js → work-queue-CnghFRG2.js} +2 -2
- package/public/assets/{work-queue-BFQVVL4T.js.map → work-queue-CnghFRG2.js.map} +1 -1
- package/public/assets/{workspaces-CR6f7G0R.js → workspaces-BzmQnzSm.js} +2 -2
- package/public/assets/{workspaces-CR6f7G0R.js.map → workspaces-BzmQnzSm.js.map} +1 -1
- package/public/index.html +7 -7
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/hooks/relay-status.sh +11 -1
- package/runner/plugins/claude/hooks/stop.sh +6 -1
- package/runner/plugins/claude/hooks/user-prompt-submit.sh +6 -1
- package/runner/src/adapter.ts +5 -1
- package/runner/src/adapters/claude-transcript.ts +52 -58
- package/runner/src/adapters/codex-agent-message-capture.ts +8 -36
- package/runner/src/adapters/codex.ts +30 -12
- package/runner/src/control-server.ts +9 -4
- package/runner/src/response-capture-report.ts +4 -1
- package/runner/src/runner-core.ts +75 -33
- package/src/agent-lifecycle-events.ts +8 -0
- package/src/bus.ts +0 -24
- package/src/db/agents.ts +15 -1
- package/src/db/messages.ts +114 -43
- package/src/db/provisioning-registry.ts +174 -1
- package/src/maintenance/jobs.ts +8 -2
- package/src/mcp/tools-workspace.ts +4 -0
- package/src/routes/agents.ts +0 -4
- package/src/routes/provisioning-registry.ts +3 -0
- package/src/routing-policy-store.ts +55 -0
- package/src/services/send-message.ts +7 -18
- package/src/services/shutdown-agent.ts +6 -0
- package/src/services/task-entity.ts +4 -0
- package/src/services/transient-agent-reaper.ts +40 -1
- package/src/services/work-finality.ts +25 -0
- package/src/spawn-targets.ts +130 -28
- package/public/assets/agent-profiles-D5Izg0Vn.js +0 -2
- package/public/assets/chat-eUaPz399.js +0 -2
- package/public/assets/chat-eUaPz399.js.map +0 -1
- package/public/assets/index-DcmG5gLR.css +0 -2
- package/public/assets/index-DnUo0Zqg.js +0 -25
- package/public/assets/index-DnUo0Zqg.js.map +0 -1
- package/public/assets/registry-5KhP5FE5.js +0 -2
- package/public/assets/registry-5KhP5FE5.js.map +0 -1
|
@@ -10,7 +10,12 @@ import { errMessage, isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
|
|
|
10
10
|
import type {
|
|
11
11
|
AgentProfileProvider,
|
|
12
12
|
HookAssetDefinition,
|
|
13
|
+
McpApplicability,
|
|
13
14
|
McpAssetDefinition,
|
|
15
|
+
McpEnvValue,
|
|
16
|
+
McpHealthProbe,
|
|
17
|
+
McpRunConfig,
|
|
18
|
+
McpSetupRecipe,
|
|
14
19
|
PluginAssetDefinition,
|
|
15
20
|
ProvisioningCapability,
|
|
16
21
|
ProvisioningAsset,
|
|
@@ -41,6 +46,158 @@ const BUNDLED_SOURCE_TYPE = "bundled";
|
|
|
41
46
|
// (src/db/provisioning-callmux.ts) when splitting literals from placeholders.
|
|
42
47
|
export const SHARED_CALLMUX_CONFIG_ENV_ALLOWLIST: Readonly<Record<string, readonly string[]>> = { vent: ["VENT_REPO"] };
|
|
43
48
|
|
|
49
|
+
// #1030 slice 1 (#1110) — MCP setup recipe / run config validation.
|
|
50
|
+
const MCP_SETUP_KINDS = ["npm-global", "download", "bundled"] as const;
|
|
51
|
+
const MCP_TRANSPORTS = ["stdio", "ws", "http"] as const;
|
|
52
|
+
const MCP_HEALTH_PROBE_KINDS = ["tcp", "http", "none"] as const;
|
|
53
|
+
const MCP_LISTENER_MODES = ["shared", "per-agent"] as const;
|
|
54
|
+
// Exact pin only — no ranges ("^1.2.3"), tags ("latest", "next"), or partial versions
|
|
55
|
+
// ("1.2"). The same recipe must reproduce byte-for-byte on every host.
|
|
56
|
+
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
57
|
+
const SHA256_HEX = /^[a-f0-9]{64}$/i;
|
|
58
|
+
// callmux's own secret-ref grammar (`env:NAME`, `file:/path`), plus `secret:<name>` for
|
|
59
|
+
// the relay-owned secret store a follow-on issue (#1111) resolves. A ref is stored and
|
|
60
|
+
// validated for shape only in this slice — no resolution (env lookup / file read /
|
|
61
|
+
// secret-store fetch) happens here.
|
|
62
|
+
const MCP_ENV_REF_SCHEMES = ["secret", "env", "file"] as const;
|
|
63
|
+
// Content-shaped credential detection for literal runConfig.env values (#1030 review
|
|
64
|
+
// steer): a value that looks like a real token/key must be rejected in favor of a
|
|
65
|
+
// `secret:<name>` reference — the registry never carries a resolved secret.
|
|
66
|
+
const INLINE_SECRET_CONTENT_PATTERNS: RegExp[] = [
|
|
67
|
+
/-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i,
|
|
68
|
+
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/,
|
|
69
|
+
/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/,
|
|
70
|
+
/\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
71
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
72
|
+
/^Bearer\s+\S{16,}$/i,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
function looksLikeInlineSecret(value: string): boolean {
|
|
76
|
+
if (INLINE_SECRET_CONTENT_PATTERNS.some((pattern) => pattern.test(value))) return true;
|
|
77
|
+
// Crude high-entropy heuristic: a long token-shaped string mixing upper/lower/digit.
|
|
78
|
+
return value.length >= 24 && /^[A-Za-z0-9_.-]+$/.test(value) && /[0-9]/.test(value) && /[A-Z]/.test(value) && /[a-z]/.test(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertMcpSetupRecipe(value: unknown): McpSetupRecipe {
|
|
82
|
+
if (!isRecord(value)) throw new ValidationError("mcp setup recipe must be an object");
|
|
83
|
+
const kind = value.kind;
|
|
84
|
+
if (kind === "npm-global") {
|
|
85
|
+
const pkg = value.pkg;
|
|
86
|
+
const version = value.version;
|
|
87
|
+
if (typeof pkg !== "string" || !pkg.trim()) throw new ValidationError("mcp setup npm-global requires pkg");
|
|
88
|
+
if (typeof version !== "string" || !EXACT_SEMVER.test(version)) {
|
|
89
|
+
throw new ValidationError("mcp setup npm-global requires an exact pinned version (e.g. 1.2.3), not a range or tag");
|
|
90
|
+
}
|
|
91
|
+
return { kind, pkg, version };
|
|
92
|
+
}
|
|
93
|
+
if (kind === "download") {
|
|
94
|
+
const url = value.url;
|
|
95
|
+
const sha256 = value.sha256;
|
|
96
|
+
const bin = value.bin;
|
|
97
|
+
if (typeof url !== "string" || !/^https:\/\//.test(url)) throw new ValidationError("mcp setup download requires an https url");
|
|
98
|
+
if (typeof sha256 !== "string" || !SHA256_HEX.test(sha256)) throw new ValidationError("mcp setup download requires a sha256 checksum (64 hex chars)");
|
|
99
|
+
if (typeof bin !== "string" || !bin.trim()) throw new ValidationError("mcp setup download requires bin");
|
|
100
|
+
return { kind, url, sha256: sha256.toLowerCase(), bin };
|
|
101
|
+
}
|
|
102
|
+
if (kind === "bundled") return { kind: "bundled" };
|
|
103
|
+
throw new ValidationError(`mcp setup recipe kind must be one of: ${MCP_SETUP_KINDS.join(", ")}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Splits a leading `<scheme>:<ref>` off an env value when scheme is one of the
|
|
107
|
+
// recognized MCP_ENV_REF_SCHEMES. Returns null for a bare literal (no colon, or a
|
|
108
|
+
// colon-containing literal like a Windows path whose prefix isn't a known scheme).
|
|
109
|
+
function parseMcpEnvRef(value: string): { scheme: (typeof MCP_ENV_REF_SCHEMES)[number]; ref: string } | null {
|
|
110
|
+
const colon = value.indexOf(":");
|
|
111
|
+
if (colon <= 0) return null;
|
|
112
|
+
const scheme = value.slice(0, colon);
|
|
113
|
+
if (!(MCP_ENV_REF_SCHEMES as readonly string[]).includes(scheme)) return null;
|
|
114
|
+
return { scheme: scheme as (typeof MCP_ENV_REF_SCHEMES)[number], ref: value.slice(colon + 1) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function assertMcpRunConfigEnvValue(key: string, value: unknown): McpEnvValue {
|
|
118
|
+
if (!ENV_NAME.test(key)) throw new ValidationError(`mcp runConfig env key ${key} is not a valid env name`);
|
|
119
|
+
if (typeof value !== "string") throw new ValidationError(`mcp runConfig env ${key} must be a string (a literal, or a "secret:"/"env:"/"file:" reference)`);
|
|
120
|
+
const parsed = parseMcpEnvRef(value);
|
|
121
|
+
if (parsed) {
|
|
122
|
+
if (!parsed.ref.trim()) throw new ValidationError(`mcp runConfig env ${key} ${parsed.scheme}: reference must include a name/path`);
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
if (looksLikeInlineSecret(value) || (isSecretLikeEnvName(key) && !isEnvPlaceholder(value))) {
|
|
126
|
+
throw new ValidationError(`mcp runConfig env ${key} looks like an inline secret; use "secret:<name>" instead of a literal value`);
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function assertMcpHealthProbe(value: unknown): McpHealthProbe {
|
|
132
|
+
if (!isRecord(value)) throw new ValidationError("mcp runConfig healthProbe must be an object");
|
|
133
|
+
const kind = value.kind;
|
|
134
|
+
if (kind !== "tcp" && kind !== "http" && kind !== "none") {
|
|
135
|
+
throw new ValidationError(`mcp runConfig healthProbe.kind must be one of: ${MCP_HEALTH_PROBE_KINDS.join(", ")}`);
|
|
136
|
+
}
|
|
137
|
+
const probe: McpHealthProbe = { kind };
|
|
138
|
+
if (value.path !== undefined) {
|
|
139
|
+
if (typeof value.path !== "string" || !value.path) throw new ValidationError("mcp runConfig healthProbe.path must be a non-empty string");
|
|
140
|
+
probe.path = value.path;
|
|
141
|
+
}
|
|
142
|
+
if (value.intervalMs !== undefined) {
|
|
143
|
+
if (typeof value.intervalMs !== "number" || !(value.intervalMs > 0)) throw new ValidationError("mcp runConfig healthProbe.intervalMs must be a positive number");
|
|
144
|
+
probe.intervalMs = value.intervalMs;
|
|
145
|
+
}
|
|
146
|
+
if (value.timeoutMs !== undefined) {
|
|
147
|
+
if (typeof value.timeoutMs !== "number" || !(value.timeoutMs > 0)) throw new ValidationError("mcp runConfig healthProbe.timeoutMs must be a positive number");
|
|
148
|
+
probe.timeoutMs = value.timeoutMs;
|
|
149
|
+
}
|
|
150
|
+
return probe;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function assertMcpRunConfig(value: unknown): McpRunConfig {
|
|
154
|
+
if (!isRecord(value)) throw new ValidationError("mcp runConfig must be an object");
|
|
155
|
+
const command = value.command;
|
|
156
|
+
if (typeof command !== "string" || !command.trim()) throw new ValidationError("mcp runConfig requires command");
|
|
157
|
+
const runConfig: McpRunConfig = { command, transport: "stdio", listenerMode: "shared" };
|
|
158
|
+
if (value.args !== undefined) {
|
|
159
|
+
if (!Array.isArray(value.args) || !value.args.every((arg) => typeof arg === "string")) throw new ValidationError("mcp runConfig args must be strings");
|
|
160
|
+
runConfig.args = value.args;
|
|
161
|
+
}
|
|
162
|
+
if (value.env !== undefined) {
|
|
163
|
+
if (!isRecord(value.env)) throw new ValidationError("mcp runConfig env must be an object");
|
|
164
|
+
const env: Record<string, McpEnvValue> = {};
|
|
165
|
+
for (const [key, raw] of Object.entries(value.env)) env[key] = assertMcpRunConfigEnvValue(key, raw);
|
|
166
|
+
runConfig.env = env;
|
|
167
|
+
}
|
|
168
|
+
const transport = value.transport;
|
|
169
|
+
if (transport !== "stdio" && transport !== "ws" && transport !== "http") {
|
|
170
|
+
throw new ValidationError(`mcp runConfig transport must be one of: ${MCP_TRANSPORTS.join(", ")}`);
|
|
171
|
+
}
|
|
172
|
+
runConfig.transport = transport;
|
|
173
|
+
if (value.healthProbe !== undefined) runConfig.healthProbe = assertMcpHealthProbe(value.healthProbe);
|
|
174
|
+
const listenerMode = value.listenerMode;
|
|
175
|
+
if (listenerMode !== "shared" && listenerMode !== "per-agent") {
|
|
176
|
+
throw new ValidationError(`mcp runConfig listenerMode must be one of: ${MCP_LISTENER_MODES.join(", ")}`);
|
|
177
|
+
}
|
|
178
|
+
runConfig.listenerMode = listenerMode;
|
|
179
|
+
return runConfig;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function assertMcpApplicability(value: unknown): McpApplicability {
|
|
183
|
+
if (!isRecord(value)) throw new ValidationError("mcp applicability must be an object");
|
|
184
|
+
const applicability: McpApplicability = {};
|
|
185
|
+
if (value.providers !== undefined) {
|
|
186
|
+
if (!Array.isArray(value.providers) || !value.providers.every((p) => typeof p === "string")) {
|
|
187
|
+
throw new ValidationError("mcp applicability.providers must be an array of strings");
|
|
188
|
+
}
|
|
189
|
+
for (const provider of value.providers) assertProvider(provider as AgentProfileProvider);
|
|
190
|
+
applicability.providers = value.providers as AgentProfileProvider[];
|
|
191
|
+
}
|
|
192
|
+
if (value.profiles !== undefined) {
|
|
193
|
+
if (!Array.isArray(value.profiles) || !value.profiles.every((p) => typeof p === "string" && p.trim())) {
|
|
194
|
+
throw new ValidationError("mcp applicability.profiles must be an array of non-empty strings");
|
|
195
|
+
}
|
|
196
|
+
applicability.profiles = value.profiles as string[];
|
|
197
|
+
}
|
|
198
|
+
return applicability;
|
|
199
|
+
}
|
|
200
|
+
|
|
44
201
|
interface ProvisioningAssetRow {
|
|
45
202
|
id: string;
|
|
46
203
|
capability_id?: string | null;
|
|
@@ -179,6 +336,9 @@ export interface PatchProvisioningAssetVariantInput {
|
|
|
179
336
|
|
|
180
337
|
export interface CreateMcpProvisioningVariantInput {
|
|
181
338
|
server: unknown;
|
|
339
|
+
setup?: unknown;
|
|
340
|
+
runConfig?: unknown;
|
|
341
|
+
applicability?: unknown;
|
|
182
342
|
enabled?: boolean;
|
|
183
343
|
approvalStatus?: "approved" | "pending";
|
|
184
344
|
versionConstraint?: string;
|
|
@@ -213,6 +373,9 @@ function assertDefinition(kind: ProvisioningAssetKind, definition: ResolvedAsset
|
|
|
213
373
|
assertMcpSecretPlaceholders(def.server, def.name);
|
|
214
374
|
const metadata = isRecord(def.metadata) ? def.metadata : {};
|
|
215
375
|
if (isRecord(metadata.callmuxServer)) assertCallmuxServerPlaceholders(metadata.callmuxServer as ProvisioningMcpServer, def.name);
|
|
376
|
+
if (def.setup !== undefined) assertMcpSetupRecipe(def.setup);
|
|
377
|
+
if (def.runConfig !== undefined) assertMcpRunConfig(def.runConfig);
|
|
378
|
+
if (def.applicability !== undefined) assertMcpApplicability(def.applicability);
|
|
216
379
|
}
|
|
217
380
|
if (kind === "hook") {
|
|
218
381
|
const def = definition as HookAssetDefinition;
|
|
@@ -873,11 +1036,21 @@ export function createMcpProvisioningVariant(
|
|
|
873
1036
|
const capability = getProvisioningCapabilityRowById(capabilityId);
|
|
874
1037
|
if (capability.kind !== "mcp") throw new ValidationError("MCP variants require an mcp capability");
|
|
875
1038
|
const server = coerceMcpServer(input.server);
|
|
1039
|
+
const setup = input.setup !== undefined ? assertMcpSetupRecipe(input.setup) : undefined;
|
|
1040
|
+
const runConfig = input.runConfig !== undefined ? assertMcpRunConfig(input.runConfig) : undefined;
|
|
1041
|
+
const applicability = input.applicability !== undefined ? assertMcpApplicability(input.applicability) : undefined;
|
|
876
1042
|
const asset = upsertProvisioningAsset({
|
|
877
1043
|
kind: "mcp",
|
|
878
1044
|
name: capability.slug,
|
|
879
1045
|
provider: "any",
|
|
880
|
-
definition: {
|
|
1046
|
+
definition: {
|
|
1047
|
+
kind: "mcp",
|
|
1048
|
+
name: capability.slug,
|
|
1049
|
+
server,
|
|
1050
|
+
...(setup ? { setup } : {}),
|
|
1051
|
+
...(runConfig ? { runConfig } : {}),
|
|
1052
|
+
...(applicability ? { applicability } : {}),
|
|
1053
|
+
},
|
|
881
1054
|
enabled: input.enabled,
|
|
882
1055
|
});
|
|
883
1056
|
const approvalStatus = input.approvalStatus ?? "approved";
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import { routeNotification } from "../notification-router";
|
|
49
49
|
import { pruneExpiredTokenRecords } from "../token-db";
|
|
50
50
|
import { reapTransientAgents } from "../services/transient-agent-reaper";
|
|
51
|
+
import { markAgentWorkFinal } from "../services/work-finality";
|
|
51
52
|
import { reapUnregisteredSpawnCommands } from "../services/spawn-registration-watchdog";
|
|
52
53
|
import { resumeRateLimitedAgents } from "../services/rate-limit-resume";
|
|
53
54
|
import { collectCodexWhamQuota } from "../codex-wham-quota";
|
|
@@ -334,7 +335,12 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
334
335
|
intervalMs: REAP_INTERVAL_MS,
|
|
335
336
|
runOnStart: false,
|
|
336
337
|
handler() {
|
|
337
|
-
|
|
338
|
+
// #1084 crash-final-loss fix-forward — defense-in-depth: `notifyAgentOffline` is the primary
|
|
339
|
+
// flush point when an agent first goes offline, so this is normally a no-op by the time an
|
|
340
|
+
// agent reaches prune horizon; kept here in case any offline transition ever bypasses it.
|
|
341
|
+
const prunedAgentIds = pruneOfflineAgents(OFFLINE_PRUNE_MS, {
|
|
342
|
+
beforeDelete: (id) => markAgentWorkFinal(id, "edge-fallback"),
|
|
343
|
+
});
|
|
338
344
|
for (const id of prunedAgentIds) emitAgentRemoved(id);
|
|
339
345
|
return { prunedAgentIds };
|
|
340
346
|
},
|
|
@@ -361,7 +367,7 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
361
367
|
runOnStart: false,
|
|
362
368
|
handler: () => reapOrphanedSessions(),
|
|
363
369
|
},
|
|
364
|
-
{ id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => reapTransientAgents() },
|
|
370
|
+
{ id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed; also flushes a persistent agent's held work-final report once it has settled idle (#1084 HIGH fix-forward).", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => reapTransientAgents() },
|
|
365
371
|
{
|
|
366
372
|
id: "lineage-report-startup-sweep",
|
|
367
373
|
title: "Lineage report startup sweep",
|
|
@@ -5,6 +5,7 @@ import { AUTO_MERGE_POLICIES } from "../workspace-merge";
|
|
|
5
5
|
import { describeWorkspacePhase, landReceipt, readyContract } from "../workspace-phase";
|
|
6
6
|
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "../workspace-actions";
|
|
7
7
|
import { refreshWorkspaceGitState } from "../workspace-probe";
|
|
8
|
+
import { markAgentWorkFinal } from "../services/work-finality";
|
|
8
9
|
import { optionalEnum } from "../validation";
|
|
9
10
|
import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord } from "../types";
|
|
10
11
|
import type { McpAuthContext, ToolDefinition } from "./types";
|
|
@@ -218,6 +219,9 @@ export async function relayWorkspaceMutation(auth: McpAuthContext, action: Works
|
|
|
218
219
|
if (action === "ready") {
|
|
219
220
|
payload.contract = readyContract(result.workspace);
|
|
220
221
|
payload.guidance = describeWorkspacePhase(result.workspace);
|
|
222
|
+
// #1084 — relay_workspace_ready is an explicit, platform-owned completion signal: the caller
|
|
223
|
+
// is declaring its work window closed. Close it, forwarding only the held final group.
|
|
224
|
+
markAgentWorkFinal(result.workspace.ownerAgentId ?? callerAgentId(auth), "workspace-ready");
|
|
221
225
|
}
|
|
222
226
|
return payload;
|
|
223
227
|
}
|
package/src/routes/agents.ts
CHANGED
|
@@ -10,7 +10,6 @@ import { emitAgentRemoved, emitAgentStatus } from "../sse";
|
|
|
10
10
|
import { notifyAgentReady } from "../agent-lifecycle-events";
|
|
11
11
|
import { bindSpawnedTaskToReadyAgent } from "../services/register-agent";
|
|
12
12
|
import { flushQueuedDirectMessages } from "../services/managed-running";
|
|
13
|
-
import { flushIdleLineageReport } from "../services/send-message";
|
|
14
13
|
import { isAgentUnavailable } from "../db/agent-predicates";
|
|
15
14
|
import { getCompactionWatch } from "../compaction-watch";
|
|
16
15
|
import { isRecord } from "agent-relay-sdk";
|
|
@@ -200,9 +199,6 @@ export const patchAgentStatus: Handler = async (req, params) => {
|
|
|
200
199
|
const before = getAgent(params.id!);
|
|
201
200
|
if (!setStatus(params.id!, body.status as (typeof VALID_AGENT_STATUSES)[number], guard)) return error("agent not found", 404);
|
|
202
201
|
auditAgentStateTransition(params.id!, before, getAgent(params.id!));
|
|
203
|
-
// #1040 — the HTTP status PATCH is also a busy→idle edge (a runner without a live bus socket).
|
|
204
|
-
// Flush any held turn-final report-up so it reaches the spawner on this path too.
|
|
205
|
-
if (before?.status === "busy" && body.status !== "busy") flushIdleLineageReport(params.id!);
|
|
206
202
|
} catch (e) {
|
|
207
203
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
208
204
|
throw e;
|
|
@@ -134,6 +134,9 @@ export const postProvisioningVariantMcpRoute: Handler = async (req, params) => {
|
|
|
134
134
|
const body = expectObject(parsed.body);
|
|
135
135
|
const variant = createMcpProvisioningVariant(params.id!, {
|
|
136
136
|
server: body.server ?? body,
|
|
137
|
+
setup: body.setup,
|
|
138
|
+
runConfig: body.runConfig,
|
|
139
|
+
applicability: body.applicability,
|
|
137
140
|
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
|
|
138
141
|
approvalStatus: optionalEnum(body.approvalStatus, "approvalStatus", APPROVAL_STATUSES, "approved"),
|
|
139
142
|
versionConstraint: cleanString(body.versionConstraint, "versionConstraint", { max: 120 }),
|
|
@@ -25,6 +25,8 @@ import type {
|
|
|
25
25
|
DispatchRule,
|
|
26
26
|
ProviderQuotaRoutingConfig,
|
|
27
27
|
QuotaBandThresholds,
|
|
28
|
+
QuotaPaceBandThresholds,
|
|
29
|
+
QuotaPacingConfig,
|
|
28
30
|
RoutingPolicy,
|
|
29
31
|
RoutingRule,
|
|
30
32
|
} from "agent-relay-sdk";
|
|
@@ -107,6 +109,7 @@ export function normalizeRoutingPolicy(value: unknown): RoutingPolicy {
|
|
|
107
109
|
rules: normalizeRules(input.rules),
|
|
108
110
|
...(input.quotaBands !== undefined ? { quotaBands: normalizeQuotaBands(input.quotaBands) } : {}),
|
|
109
111
|
...(input.providerQuota !== undefined ? { providerQuota: normalizeProviderQuota(input.providerQuota) } : {}),
|
|
112
|
+
...(input.quotaPacing !== undefined ? { quotaPacing: normalizeQuotaPacing(input.quotaPacing) } : {}),
|
|
110
113
|
...(input.dispatch !== undefined ? { dispatch: normalizeDispatchPolicy(input.dispatch) } : {}),
|
|
111
114
|
};
|
|
112
115
|
}
|
|
@@ -264,6 +267,58 @@ function normalizeQuotaBands(raw: unknown): Partial<QuotaBandThresholds> {
|
|
|
264
267
|
return result;
|
|
265
268
|
}
|
|
266
269
|
|
|
270
|
+
// #1098/#1105 — the budget-pacing controller's tunables. Same shape as normalizeQuotaBands: every
|
|
271
|
+
// field optional, so a partial PUT only overrides what it names and everything else keeps falling
|
|
272
|
+
// back to the provider-quota.ts defaults.
|
|
273
|
+
function normalizeQuotaPacing(raw: unknown): QuotaPacingConfig {
|
|
274
|
+
if (!isRecord(raw)) throw new ValidationError("quotaPacing must be an object");
|
|
275
|
+
const result: QuotaPacingConfig = {};
|
|
276
|
+
if (raw.delta !== undefined) {
|
|
277
|
+
if (typeof raw.delta !== "number" || !Number.isFinite(raw.delta) || raw.delta <= 0) {
|
|
278
|
+
throw new ValidationError("quotaPacing.delta must be a positive finite number");
|
|
279
|
+
}
|
|
280
|
+
result.delta = raw.delta;
|
|
281
|
+
}
|
|
282
|
+
if (raw.thresholds !== undefined) result.thresholds = normalizeQuotaPaceThresholds(raw.thresholds);
|
|
283
|
+
if (raw.windowLengthMs !== undefined) result.windowLengthMs = normalizeQuotaPaceWindowLengths(raw.windowLengthMs);
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeQuotaPaceThresholds(raw: unknown): Partial<QuotaPaceBandThresholds> {
|
|
288
|
+
if (!isRecord(raw)) throw new ValidationError("quotaPacing.thresholds must be an object");
|
|
289
|
+
const result: Partial<QuotaPaceBandThresholds> = {};
|
|
290
|
+
if (raw.preferAt !== undefined) result.preferAt = cleanFraction(raw.preferAt, "quotaPacing.thresholds.preferAt");
|
|
291
|
+
if (raw.normalAt !== undefined) result.normalAt = cleanFraction(raw.normalAt, "quotaPacing.thresholds.normalAt");
|
|
292
|
+
if (raw.cautionAt !== undefined) result.cautionAt = cleanFraction(raw.cautionAt, "quotaPacing.thresholds.cautionAt");
|
|
293
|
+
// Descending: preferAt > normalAt > cautionAt (weight 1 = best/promote, 0 = worst/avoid).
|
|
294
|
+
const orderedPairs: Array<[string, number]> = [];
|
|
295
|
+
if (result.preferAt !== undefined) orderedPairs.push(["preferAt", result.preferAt]);
|
|
296
|
+
if (result.normalAt !== undefined) orderedPairs.push(["normalAt", result.normalAt]);
|
|
297
|
+
if (result.cautionAt !== undefined) orderedPairs.push(["cautionAt", result.cautionAt]);
|
|
298
|
+
for (let i = 0; i < orderedPairs.length - 1; i++) {
|
|
299
|
+
const cur = orderedPairs[i]!;
|
|
300
|
+
const next = orderedPairs[i + 1]!;
|
|
301
|
+
if (cur[1] <= next[1]) {
|
|
302
|
+
throw new ValidationError(`quotaPacing.thresholds.${cur[0]} must be greater than quotaPacing.thresholds.${next[0]}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function normalizeQuotaPaceWindowLengths(raw: unknown): Partial<Record<"5h" | "7d", number>> {
|
|
309
|
+
if (!isRecord(raw)) throw new ValidationError("quotaPacing.windowLengthMs must be an object");
|
|
310
|
+
const result: Partial<Record<"5h" | "7d", number>> = {};
|
|
311
|
+
for (const role of ["5h", "7d"] as const) {
|
|
312
|
+
const value = raw[role];
|
|
313
|
+
if (value === undefined) continue;
|
|
314
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
315
|
+
throw new ValidationError(`quotaPacing.windowLengthMs.${role} must be a positive finite number`);
|
|
316
|
+
}
|
|
317
|
+
result[role] = value;
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
|
|
267
322
|
function normalizeProviderQuota(raw: unknown): Partial<Record<string, ProviderQuotaRoutingConfig>> {
|
|
268
323
|
if (!isRecord(raw)) throw new ValidationError("providerQuota must be an object");
|
|
269
324
|
const result: Partial<Record<string, ProviderQuotaRoutingConfig>> = {};
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId, isTurnFinalSession } from "agent-relay-sdk";
|
|
25
25
|
import {
|
|
26
26
|
ValidationError,
|
|
27
|
-
flushDeferredLineageReport,
|
|
28
27
|
getAgent,
|
|
29
28
|
getMessage,
|
|
30
29
|
latestInboundMessage,
|
|
@@ -228,28 +227,18 @@ function prepareSendMessage(input: SendMessageInput, ctx: AuthContext, opts: Sen
|
|
|
228
227
|
return { receipt, suppressedParent: suppressResultsOnlyAck(input) };
|
|
229
228
|
}
|
|
230
229
|
|
|
231
|
-
/**
|
|
232
|
-
* #1037 — the child transitioned out of busy (idle/awaiting-input). Deliver its deferred turn-final
|
|
233
|
-
* report-up (a quiet child's last turn, held back so mid-work narration never reached the spawner)
|
|
234
|
-
* and fan it out over SSE. No-op when nothing was deferred. Driven off the bus turn-end edge.
|
|
235
|
-
*/
|
|
236
|
-
export function flushIdleLineageReport(childId: string): void {
|
|
237
|
-
const promoted = flushDeferredLineageReport(childId);
|
|
238
|
-
if (promoted?.created) emitNewMessages([promoted.message]);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
230
|
/** How far back the startup sweep re-scans for held-but-lost turn-final captures (#1040). A report
|
|
242
|
-
* is only ever
|
|
231
|
+
* is only ever held while its work window is open, so any capture lost across a restart is recent;
|
|
243
232
|
* a day is a generous ceiling that still bounds the scan. */
|
|
244
233
|
const LINEAGE_STARTUP_SWEEP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
245
234
|
|
|
246
235
|
/**
|
|
247
|
-
* #1040 — startup re-promotion sweep.
|
|
248
|
-
*
|
|
249
|
-
* promotion for recent final response captures:
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
236
|
+
* #1040/#1084 — startup re-promotion sweep. The held-turn-final group (db/messages.ts) is an
|
|
237
|
+
* in-memory Map, so a relay restart drops it. The captures are persisted, though, so re-run
|
|
238
|
+
* promotion for recent final response captures: this just REBUILDS the held group (an
|
|
239
|
+
* already-forwarded capture is a no-op via its idempotency key; a `verbose` child's capture is
|
|
240
|
+
* delivered immediately, as it always is). Delivery to the spawner still only ever happens via an
|
|
241
|
+
* explicit work-finality signal (markAgentWorkFinal) — this sweep never flushes on its own.
|
|
253
242
|
*/
|
|
254
243
|
export function sweepDeferredLineageReportsOnStartup(now: number = Date.now()): { promoted: number; scanned: number } {
|
|
255
244
|
const captures = listRecentFinalResponseCaptures(now - LINEAGE_STARTUP_SWEEP_WINDOW_MS);
|
|
@@ -37,6 +37,7 @@ import { createCommand } from "../commands-db";
|
|
|
37
37
|
import { emitCommandEvent } from "../command-events";
|
|
38
38
|
import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
|
|
39
39
|
import { emitActivityEvent, emitAgentStatus } from "../sse";
|
|
40
|
+
import { markAgentWorkFinal } from "./work-finality";
|
|
40
41
|
import { stringValue } from "agent-relay-sdk";
|
|
41
42
|
import type { AuthContext } from "./auth-context";
|
|
42
43
|
import type { AgentCard, Command } from "../types";
|
|
@@ -211,6 +212,11 @@ export function shutdownAgent(input: ShutdownAgentInput, ctx: AuthContext): Shut
|
|
|
211
212
|
},
|
|
212
213
|
});
|
|
213
214
|
emitCommandEvent(command, "command.requested");
|
|
215
|
+
// #1084 — an explicit shutdown is a platform-owned teardown signal: close the target's work
|
|
216
|
+
// window now rather than let a held final group die silently with the process. `edge-fallback`
|
|
217
|
+
// is the closest fit in the closed source enum — there is no cleaner workspace-ready/task-done
|
|
218
|
+
// signal backing an arbitrary teardown (which may target an agent with neither).
|
|
219
|
+
if (targetAgentId) markAgentWorkFinal(targetAgentId, "edge-fallback");
|
|
214
220
|
|
|
215
221
|
// Lifecycle indicator — uniform across transports. The MCP path previously skipped
|
|
216
222
|
// this, so an MCP-initiated shutdown left the agent with no "shutting down" signal in
|
|
@@ -44,6 +44,7 @@ import { emitRelayEvent } from "../events";
|
|
|
44
44
|
import { routeNotification } from "../notification-router";
|
|
45
45
|
import { isAgentExitDoneWhen, stageSatisfiesDoneWhen } from "./task-done-when";
|
|
46
46
|
import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
|
|
47
|
+
import { markAgentWorkFinal } from "./work-finality";
|
|
47
48
|
|
|
48
49
|
type CreateTaskEntityInput = CreateDeliverableTaskInput & {
|
|
49
50
|
issueRefs?: string[];
|
|
@@ -140,6 +141,9 @@ export function setTaskEntityStatus(id: number, input: SetTaskStatusInput): Task
|
|
|
140
141
|
});
|
|
141
142
|
}
|
|
142
143
|
if (isTerminalTaskStatus(detail.status)) removeSubscriptionsForTask(detail.id);
|
|
144
|
+
// #1084 — a task reaching `done` is an explicit, platform-owned completion signal for its
|
|
145
|
+
// owning agent's work window: close it, forwarding only the held final group.
|
|
146
|
+
if (before?.status !== "done" && detail.status === "done") markAgentWorkFinal(detail.ownerAgentId, "task-done");
|
|
143
147
|
return detail;
|
|
144
148
|
}
|
|
145
149
|
|
|
@@ -8,6 +8,7 @@ import { relayToken } from "../config";
|
|
|
8
8
|
import { workspaceOwnerOrchestrator } from "../workspace-host";
|
|
9
9
|
import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
|
|
10
10
|
import { finalizeEphemeralTasksOnReap } from "./task-entity";
|
|
11
|
+
import { markAgentWorkFinal } from "./work-finality";
|
|
11
12
|
import type { AgentCard, Command, WorkspaceGitState, WorkspaceRecord } from "../types";
|
|
12
13
|
|
|
13
14
|
// The single home for "dispose a transient/one-shot agent" — the canonical `agent.shutdown`
|
|
@@ -38,6 +39,10 @@ export function requestTransientAgentShutdown(agent: AgentCard, opts: TransientS
|
|
|
38
39
|
});
|
|
39
40
|
emitRelayEvent({ type: "command.requested", source: command.source, subject: command.id, data: { command } });
|
|
40
41
|
emitRelayEvent({ type: "agent.exited", source: "server", subject: agent.id, data: { agentId: agent.id, parent: agent.spawnedBy, reason: opts.reason, workspaceId: opts.workspaceId, workspaceStatus: opts.workspaceStatus } });
|
|
42
|
+
// #1084 — reaping a transient agent always follows a verified-complete land/idle-grace check
|
|
43
|
+
// (the caller only reaches here once `land.complete`/gates-passed holds), so it is a genuine
|
|
44
|
+
// teardown completion signal: close its work window, forwarding only the held final group.
|
|
45
|
+
markAgentWorkFinal(agent.id, "edge-fallback");
|
|
41
46
|
// #645 — persist a clean terminal marker so the #634 heartbeat-gap watchdog reads this benign
|
|
42
47
|
// completion as a terminus (not a silent death) once the reaped worker stops heartbeating.
|
|
43
48
|
recordTerminalEvent({ agentId: agent.id, reason: opts.reason, kind: "agent.exited", epoch: agent.epoch, createdAt: now });
|
|
@@ -67,6 +72,20 @@ export function resetTransientTrackerForTests(): void {
|
|
|
67
72
|
transientTracker.clear();
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
// #1084 HIGH crash-final-loss fix-forward — a persistent (or autonomous) worker never enters the
|
|
76
|
+
// transient reap path above (it keeps running; land-and-continue). Its held work-final ring would
|
|
77
|
+
// otherwise only ever flush on an eventual explicit teardown (shutdownAgent) — hours away, or never,
|
|
78
|
+
// for a long-lived agent that simply idles after reporting in. This mirrors the SAME idle-grace
|
|
79
|
+
// mechanics as the transient tracker (aligned on `transientGraceMs`, not trigger-happy: a coordinator
|
|
80
|
+
// steer resets the clock the moment the agent goes busy again, exactly like `transientTracker`), but
|
|
81
|
+
// only ever calls `markAgentWorkFinal` — it never shuts the agent down.
|
|
82
|
+
const persistentIdleFinalityMs = () => envMsOrDefault("AGENT_RELAY_PERSISTENT_IDLE_FINALITY_MS", 2 * 60 * 1000);
|
|
83
|
+
const persistentIdleTracker = new Map<string, { firstIdleAt: number; lastFlushedAt?: number }>();
|
|
84
|
+
|
|
85
|
+
export function resetPersistentIdleTrackerForTests(): void {
|
|
86
|
+
persistentIdleTracker.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
70
89
|
// How many commits the agent's branch has ahead of its base. Returns null when
|
|
71
90
|
// the check can't run (missing fields, host/probe failure) — callers treat null as
|
|
72
91
|
// "unknown" and preserve the safe #614 hold-pending bias: only 0 is fast-reaped.
|
|
@@ -234,9 +253,28 @@ export async function reapTransientAgents(): Promise<Record<string, unknown>> {
|
|
|
234
253
|
const now = Date.now();
|
|
235
254
|
const grace = transientGraceMs();
|
|
236
255
|
const maxLandWait = transientMaxLandWaitMs();
|
|
256
|
+
const persistentGrace = persistentIdleFinalityMs();
|
|
237
257
|
const seen = new Set<string>();
|
|
258
|
+
const seenPersistent = new Set<string>();
|
|
259
|
+
const persistentFinalsFlushed: string[] = [];
|
|
238
260
|
const reaped: string[] = [], held: string[] = [], waiting: string[] = [];
|
|
239
261
|
for (const agent of listAgents()) {
|
|
262
|
+
if (agent.lifecycle === "persistent") {
|
|
263
|
+
// No parent means no held ring can ever be flushed to anyone (flushWorkFinalReports
|
|
264
|
+
// requires a spawner) — skip tracking it at all rather than churn the map every scan.
|
|
265
|
+
if (!agent.spawnedBy) continue;
|
|
266
|
+
seenPersistent.add(agent.id);
|
|
267
|
+
if (reapProtected(agent)) { persistentIdleTracker.delete(agent.id); continue; }
|
|
268
|
+
if (completedProviderTurns(agent) === 0) { persistentIdleTracker.delete(agent.id); continue; }
|
|
269
|
+
const persistentEntry = persistentIdleTracker.get(agent.id) ?? { firstIdleAt: now };
|
|
270
|
+
persistentIdleTracker.set(agent.id, persistentEntry);
|
|
271
|
+
const persistentIdleForMs = now - persistentEntry.firstIdleAt;
|
|
272
|
+
if (persistentIdleForMs < persistentGrace || persistentEntry.lastFlushedAt) continue;
|
|
273
|
+
persistentEntry.lastFlushedAt = now;
|
|
274
|
+
markAgentWorkFinal(agent.id, "edge-fallback");
|
|
275
|
+
persistentFinalsFlushed.push(agent.id);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
240
278
|
if (agent.lifecycle !== "transient") continue;
|
|
241
279
|
seen.add(agent.id);
|
|
242
280
|
// A held agent (rate-limit hold #286, or any provider-blocked state) reads as
|
|
@@ -282,5 +320,6 @@ export async function reapTransientAgents(): Promise<Record<string, unknown>> {
|
|
|
282
320
|
reaped.push(agent.id);
|
|
283
321
|
}
|
|
284
322
|
for (const key of transientTracker.keys()) if (!seen.has(key)) transientTracker.delete(key);
|
|
285
|
-
|
|
323
|
+
for (const key of persistentIdleTracker.keys()) if (!seenPersistent.has(key)) persistentIdleTracker.delete(key);
|
|
324
|
+
return { scanned: seen.size, waiting, held, reaped, tracked: transientTracker.size, persistentFinalsFlushed };
|
|
286
325
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// #1084 (Part D of #1079) — the work-window finality service. A "work-final" is a RELAY-LEVEL
|
|
2
|
+
// mark anchored to explicit completion signals the platform already owns: `relay_workspace_ready`
|
|
3
|
+
// (src/mcp/tools-workspace.ts), a task's execution status reaching `done` (src/services/task-entity.ts),
|
|
4
|
+
// and agent teardown/reap (src/services/shutdown-agent.ts). It is deliberately NOT anchored to a
|
|
5
|
+
// per-turn busy→idle bus edge — that heuristic is exactly what produced the false-final flood this
|
|
6
|
+
// issue supersedes (#1078/#1058/#1032/#976): a child idling briefly between two subtasks is
|
|
7
|
+
// indistinguishable, on the wire, from a child that just finished.
|
|
8
|
+
//
|
|
9
|
+
// markAgentWorkFinal is the single entry point every anchor calls. It closes the child's held
|
|
10
|
+
// turn-final group (db/messages.ts flushWorkFinalReports) as ONE work-final unit and fans the
|
|
11
|
+
// result out over SSE: newly-forwarded chat reports as `message.new` (the coordinator's inbox), and
|
|
12
|
+
// the original already-rendered session captures as `message.finalized` (so a bubble the dashboard
|
|
13
|
+
// showed as ordinary mid-turn narration can be retroactively upgraded once its window closes).
|
|
14
|
+
import type { MessageWorkFinalitySource } from "agent-relay-sdk";
|
|
15
|
+
import { flushWorkFinalReports, type WorkFinalFlushResult } from "../db/messages.ts";
|
|
16
|
+
import { emitMessageFinalized, emitNewMessages } from "../sse.ts";
|
|
17
|
+
|
|
18
|
+
export function markAgentWorkFinal(agentId: string | undefined | null, source: MessageWorkFinalitySource): WorkFinalFlushResult {
|
|
19
|
+
if (!agentId) return { forwarded: [], finalizedOriginals: [] };
|
|
20
|
+
const result = flushWorkFinalReports(agentId, source);
|
|
21
|
+
const created = result.forwarded.filter((entry) => entry.created).map((entry) => entry.message);
|
|
22
|
+
if (created.length > 0) emitNewMessages(created);
|
|
23
|
+
for (const original of result.finalizedOriginals) emitMessageFinalized(original);
|
|
24
|
+
return result;
|
|
25
|
+
}
|