agent-relay-server 0.121.4 → 0.122.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 +5 -4
- package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
- package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
- package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
- package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
- package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
- package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
- package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
- package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
- package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
- package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
- package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
- package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
- package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
- package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
- package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
- package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
- package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
- package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
- package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
- package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
- package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
- package/public/assets/display-CJzSoTMq.js +3 -0
- package/public/assets/display-CJzSoTMq.js.map +1 -0
- package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
- package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
- package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
- package/public/assets/index-D0xMQQb_.js +25 -0
- package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
- package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
- package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
- package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
- package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
- package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
- package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
- package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
- package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
- package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
- package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
- package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
- package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
- package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
- package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
- package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
- package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
- package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
- package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
- package/public/assets/projects-Dzdf-gkK.js +2 -0
- package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
- package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
- package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
- package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
- package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
- package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
- package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
- package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
- package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
- package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
- package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
- package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
- package/public/assets/store-BPrVuKiv.js.map +1 -0
- package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
- package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
- package/public/assets/teams-DWv6UwFV.js +2 -0
- package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
- package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
- package/public/assets/types-BeJUSfDj.js.map +1 -0
- package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
- package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
- package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
- package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
- package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
- package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
- package/runner/src/adapter.ts +53 -18
- package/runner/src/config.ts +1 -4
- package/src/agent-card-projection.ts +5 -1
- package/src/agent-command-activity.ts +39 -0
- package/src/automations/reconcile.ts +2 -2
- package/src/bus.ts +6 -4
- package/src/cli/workspace.ts +3 -1
- package/src/config.ts +10 -1
- package/src/db/agents.ts +138 -32
- package/src/db/message-reads.ts +50 -0
- package/src/db/messages.ts +48 -9
- package/src/db/migrations.ts +54 -0
- package/src/db/provider-quota-burn.ts +96 -67
- package/src/db/provider-quotas.ts +29 -7
- package/src/db/scheduled-timers.ts +21 -0
- package/src/db/schema.ts +1 -0
- package/src/db/tasks.ts +33 -3
- package/src/db/work-evidence.ts +10 -7
- package/src/event-loop-lag.ts +15 -0
- package/src/gate-resolver.ts +24 -1
- package/src/maintenance/jobs.ts +23 -6
- package/src/managed-policy.ts +35 -5
- package/src/mcp-plan-tools.ts +13 -1
- package/src/project-instructions.ts +36 -2
- package/src/quota-advisory.ts +6 -4
- package/src/quota-band-transition.ts +23 -14
- package/src/routes/agent-sessions.ts +39 -7
- package/src/routes/agents.ts +3 -2
- package/src/routes/tasks.ts +16 -4
- package/src/routes/workspaces.ts +35 -10
- package/src/services/plan-graph.ts +27 -9
- package/src/services/register-agent.ts +12 -1
- package/src/services/send-message.ts +43 -15
- package/src/services/spawn-agent.ts +3 -0
- package/src/spawn-command.ts +8 -0
- package/src/spawn-targets.ts +19 -34
- package/src/sse.ts +25 -3
- package/src/workspace-merge.ts +25 -5
- package/src/workspace-orphans.ts +13 -6
- package/public/assets/display-exQDWum3.js +0 -3
- package/public/assets/display-exQDWum3.js.map +0 -1
- package/public/assets/index-D9OogaB5.js +0 -25
- package/public/assets/projects-DdPNPFJI.js +0 -2
- package/public/assets/prompt-settings-BpZse7-X.js +0 -2
- package/public/assets/store-DKTLubBT.js.map +0 -1
- package/public/assets/teams-B8f2pGJe.js +0 -2
- package/public/assets/types-uVOXgvMT.js.map +0 -1
package/src/event-loop-lag.ts
CHANGED
|
@@ -53,6 +53,21 @@ export function eventLoopLagMaxMs(): number {
|
|
|
53
53
|
return maxMs;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Cumulative retained lag — the SUM of every sample in the ring, i.e. the total
|
|
57
|
+
// event-loop processing debt across the retained window. SIDE-EFFECT-FREE (like
|
|
58
|
+
// eventLoopLagMaxMs it never touches maxSinceLastReadMs, so reading it doesn't steal
|
|
59
|
+
// the /health poller's spike). This is the stale/bus reaper's grace (#1055): the max
|
|
60
|
+
// SINGLE sample (#1048) covers one contiguous stall (the 337s incident) but UNDER-
|
|
61
|
+
// covers sustained NON-contiguous degradation — many sub-TTL stalls whose backlog
|
|
62
|
+
// still sums past STALE_TTL. Summing every stall in the window extends the grace to the
|
|
63
|
+
// real accumulated heartbeat backlog. Always >= eventLoopLagMaxMs(), so it can only
|
|
64
|
+
// WIDEN the #1048 grace, never shrink it — no new false-reap risk introduced.
|
|
65
|
+
export function eventLoopLagGraceMs(): number {
|
|
66
|
+
let total = 0;
|
|
67
|
+
for (let i = 0; i < sampleCount; i += 1) total += samples[i] ?? 0;
|
|
68
|
+
return total;
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
export function getEventLoopLagSnapshot(): EventLoopLagSnapshot {
|
|
57
72
|
const maxMs = eventLoopLagMaxMs();
|
|
58
73
|
const maxSinceLastRead = maxSinceLastReadMs;
|
package/src/gate-resolver.ts
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
// outright. A project that declared gates knows better than any heuristic.
|
|
10
10
|
// 2. Derive from `package.json` — run the repo's DECLARED scripts via its detected
|
|
11
11
|
// package manager (from `packageManager` field or lockfile). A repo whose
|
|
12
|
-
// `scripts.test` is `jest` gets `<pm> run test`, never `bun test`.
|
|
12
|
+
// `scripts.test` is `jest` gets `<pm> run test`, never `bun test`. When the repo
|
|
13
|
+
// declares a full-gate script (`ci:land` preferred, else `ci`), that IS its gate
|
|
14
|
+
// (the set the release enforces): the steward runs it wholesale so land gates ==
|
|
15
|
+
// release gates (#1044), instead of a file-relevance subset that would let
|
|
16
|
+
// drift/lint/doc breakage land. `ci:land` exists for the worktree-safe subset — see
|
|
17
|
+
// the note at the derivation for why land gates can't run every release check.
|
|
13
18
|
// 3. Default — the legacy bun heuristic, only when there's nothing to derive from
|
|
14
19
|
// (no repo path, or no readable package.json).
|
|
15
20
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -98,6 +103,24 @@ export function resolveStewardChecks(files: string[], repoRoot?: string): Stewar
|
|
|
98
103
|
const pkg = repoRoot ? readPackageInfo(repoRoot) : null;
|
|
99
104
|
if (pkg) {
|
|
100
105
|
const runScript = (name: string) => `${pkg.pm} run ${name}`;
|
|
106
|
+
// 2a. Land==release parity (#1044): a repo that declares its full validation gate as
|
|
107
|
+
// a script has ALREADY named the set the release enforces. Run that ONE gate wholesale
|
|
108
|
+
// instead of a file-relevance subset (typecheck+test), so a land can't introduce
|
|
109
|
+
// drift/lint/doc breakage that only a narrower gate misses and that would otherwise
|
|
110
|
+
// surface at release time (failing `*:check`/lint gates).
|
|
111
|
+
//
|
|
112
|
+
// Prefer a `ci:land` script when the repo declares one: land gates run inside a git
|
|
113
|
+
// WORKTREE, and some release checks are not worktree-safe. agent-relay's own
|
|
114
|
+
// `build:claude-monitor:check` bundles provider modules whose resolution depends on
|
|
115
|
+
// checkout topology — a worktree's symlinked `node_modules` double-inlines them, so
|
|
116
|
+
// that check false-fails in every worktree (its topology-determinism root fix is
|
|
117
|
+
// tracked in #1045). `ci:land` is the worktree-safe subset of `ci` (ci == the two
|
|
118
|
+
// bundle steps + ci:land), so the steward and the release share ONE source of truth
|
|
119
|
+
// for the land-relevant gates without the steward hitting a structural false failure.
|
|
120
|
+
const fullGate = pkg.scripts["ci:land"] ? "ci:land" : pkg.scripts.ci ? "ci" : undefined;
|
|
121
|
+
if (fullGate && files.length) {
|
|
122
|
+
return [{ command: runScript(fullGate), reason: `repo declares a full "${fullGate}" gate (land==release parity)` }];
|
|
123
|
+
}
|
|
101
124
|
if (has(/\.(ts|tsx|mts|cts)$/) && pkg.scripts.typecheck) {
|
|
102
125
|
checks.push({ command: runScript("typecheck"), reason: "TypeScript files changed" });
|
|
103
126
|
}
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getArtifactStorage } from "../artifact-storage";
|
|
2
2
|
import { deriveAgentBusyHealth } from "../agent-busy-health";
|
|
3
3
|
import { expireStaleBusAgents } from "../bus";
|
|
4
|
-
import {
|
|
4
|
+
import { eventLoopLagGraceMs } from "../event-loop-lag";
|
|
5
5
|
import { pruneOutbox } from "../bus-outbox";
|
|
6
6
|
import { expireCommands } from "../commands-db";
|
|
7
7
|
import { getRetentionConfig } from "../retention-config-store";
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
releaseExpiredClaims,
|
|
23
23
|
releaseExpiredDriveLeases,
|
|
24
24
|
releaseOrphanedTasks,
|
|
25
|
+
revokeOrphanedRuntimeTokens,
|
|
25
26
|
runDbMaintenanceAsync,
|
|
26
27
|
sweepArtifacts,
|
|
27
28
|
} from "../db";
|
|
@@ -278,7 +279,7 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
278
279
|
intervalMs: REAP_INTERVAL_MS,
|
|
279
280
|
runOnStart: true,
|
|
280
281
|
handler() {
|
|
281
|
-
const expired = expireStaleBusAgents(undefined,
|
|
282
|
+
const expired = expireStaleBusAgents(undefined, eventLoopLagGraceMs());
|
|
282
283
|
for (const id of expired.agentIds) {
|
|
283
284
|
getLifecycleManager().onAgentDisappeared(id);
|
|
284
285
|
// #636/#659 — only after the stale reconnect grace expires do we turn a bare
|
|
@@ -301,11 +302,13 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
301
302
|
intervalMs: REAP_INTERVAL_MS,
|
|
302
303
|
runOnStart: true,
|
|
303
304
|
handler() {
|
|
304
|
-
// #1048 — gate staleness on the relay's OWN event-loop health.
|
|
305
|
-
// is side-effect-free (does not consume the /health poller's
|
|
306
|
-
//
|
|
305
|
+
// #1048/#1055 — gate staleness on the relay's OWN event-loop health.
|
|
306
|
+
// eventLoopLagGraceMs() is side-effect-free (does not consume the /health poller's
|
|
307
|
+
// spike max) and sums the retained window's lag, so BOTH one contiguous stall and
|
|
308
|
+
// sustained non-contiguous overload (many sub-TTL stalls whose backlog sums past the
|
|
309
|
+
// TTL) extend the grace. Keeps live agents whose heartbeats we simply couldn't
|
|
307
310
|
// process from being reaped as dead.
|
|
308
|
-
const lagGraceMs =
|
|
311
|
+
const lagGraceMs = eventLoopLagGraceMs();
|
|
309
312
|
const reapedAgentIds = reapStaleAgents(STALE_TTL_MS, lagGraceMs);
|
|
310
313
|
for (const id of reapedAgentIds) {
|
|
311
314
|
emitAgentStatus(id);
|
|
@@ -336,6 +339,20 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
336
339
|
return { prunedAgentIds };
|
|
337
340
|
},
|
|
338
341
|
},
|
|
342
|
+
{
|
|
343
|
+
id: "orphan-token-revoker",
|
|
344
|
+
title: "Orphaned runtime-token revoker",
|
|
345
|
+
description: "Revoke ('stale', re-mintable) the runtime tokens of agents whose process the orchestrator confirmed dead and that have not been heard from since.",
|
|
346
|
+
intervalMs: REAP_INTERVAL_MS,
|
|
347
|
+
runOnStart: false,
|
|
348
|
+
handler() {
|
|
349
|
+
// #1057(1) — same lag discipline as the stale reaper (#1048/#1055): while the relay's own
|
|
350
|
+
// event loop was stalled, a live replacement's heartbeats may still be unprocessed, so the
|
|
351
|
+
// grace widens by the observed lag instead of misreading the backlog as post-mortem silence.
|
|
352
|
+
const revokedOrphanTokens = revokeOrphanedRuntimeTokens(STALE_TTL_MS, eventLoopLagGraceMs());
|
|
353
|
+
return { revokedOrphanTokens };
|
|
354
|
+
},
|
|
355
|
+
},
|
|
339
356
|
{
|
|
340
357
|
id: "orphaned-session-reaper",
|
|
341
358
|
title: "Orphaned session reaper",
|
package/src/managed-policy.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getManifest } from "agent-relay-providers";
|
|
2
2
|
import { getAgentProfile } from "./config-store";
|
|
3
3
|
import { withResolvedProvisioning } from "./db";
|
|
4
|
+
import { composeProfileSpawnInstructions } from "./project-instructions";
|
|
4
5
|
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
5
6
|
import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
|
|
6
7
|
import { applyDefaultSharedMcp } from "./shared-mcp";
|
|
@@ -10,11 +11,19 @@ function managedPolicyPromptMode(policy: SpawnPolicy): "prompt" | "append-system
|
|
|
10
11
|
return getManifest(policy.provider)?.launch?.managedPolicyPrompt?.alwaysOn ?? "prompt";
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The always-on keepalive prompt an `append-system-prompt`-mode provider (Claude) carries as a
|
|
16
|
+
* system-prompt append (Codex uses the launch `prompt` channel instead — see
|
|
17
|
+
* `managedPolicyLaunchPrompt`). Returns undefined when it does not apply. #1052 folds this into
|
|
18
|
+
* `composeSpawnInstructionsWithEvents` as the caller append so there is a SINGLE ordered
|
|
19
|
+
* `--append-system-prompt` block (profile role charter + project knowledge + this keepalive),
|
|
20
|
+
* instead of a second stray `--append-system-prompt` arg that could stack on the composed one.
|
|
21
|
+
*/
|
|
22
|
+
export function managedPolicyAppendSystemPrompt(policy: SpawnPolicy): string | undefined {
|
|
14
23
|
if (policy.mode === "always-on" && policy.prompt?.trim() && managedPolicyPromptMode(policy) === "append-system-prompt") {
|
|
15
|
-
return
|
|
24
|
+
return policy.prompt.trim();
|
|
16
25
|
}
|
|
17
|
-
return
|
|
26
|
+
return undefined;
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
export function managedPolicyLaunchPrompt(policy: SpawnPolicy): string | undefined {
|
|
@@ -33,10 +42,29 @@ interface ManagedSpawnContext {
|
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string, ctx: ManagedSpawnContext): Record<string, unknown> {
|
|
36
|
-
const providerArgs = managedPolicyProviderArgs(policy);
|
|
37
45
|
const prompt = managedPolicyLaunchPrompt(policy);
|
|
38
46
|
const resolvedProfile = withResolvedProvisioning(policy.profile ? getAgentProfile(policy.profile)?.value : undefined, policy.provider);
|
|
39
47
|
const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
|
|
48
|
+
|
|
49
|
+
// #1052 — run the SAME instruction projection a fresh interactive spawn gets
|
|
50
|
+
// (composeSpawnInstructionsWithEvents, the single home in spawn-agent.ts), so a MANAGED agent
|
|
51
|
+
// (e.g. the always-on coordinator) is onboarded IDENTICALLY on its first launch AND on every
|
|
52
|
+
// restart / deploy-bounce relaunch — this builder feeds both `spawnAgent` and `restartAgent`.
|
|
53
|
+
// Previously the managed path forwarded only `agentProfile` (for provisioning) + providerArgs
|
|
54
|
+
// and silently dropped the profile ROLE CHARTER (`instructions.system`/`append`), project-
|
|
55
|
+
// knowledge preamble, and scoped {profile|user} memory, so a restarted coordinator reverted to
|
|
56
|
+
// provider-default IC behavior until a human re-injected the role file (#1052). The always-on
|
|
57
|
+
// keepalive prompt folds in as the caller append → one ordered `--append-system-prompt` block,
|
|
58
|
+
// no double-inject. The projection is deterministic (sorted + deduped), so re-running it on a
|
|
59
|
+
// native-resume relaunch reproduces a byte-identical block rather than stacking a second copy.
|
|
60
|
+
const alwaysOnAppend = managedPolicyAppendSystemPrompt(policy);
|
|
61
|
+
const composed = composeProfileSpawnInstructions({
|
|
62
|
+
cwd: policy.cwd,
|
|
63
|
+
...(policy.profile ? { profileName: policy.profile } : {}),
|
|
64
|
+
...(agentProfile ? { profile: agentProfile } : {}),
|
|
65
|
+
...(alwaysOnAppend ? { callerSystemPromptAppend: alwaysOnAppend } : {}),
|
|
66
|
+
});
|
|
67
|
+
|
|
40
68
|
return buildSpawnCommand({
|
|
41
69
|
provider: policy.provider,
|
|
42
70
|
orchestratorId: policy.orchestratorId,
|
|
@@ -51,8 +79,10 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
|
|
|
51
79
|
capabilities: policy.capabilities,
|
|
52
80
|
approvalMode: policy.permissionMode,
|
|
53
81
|
permissionMode: policy.permissionMode,
|
|
54
|
-
providerArgs,
|
|
82
|
+
providerArgs: policy.providerArgs,
|
|
55
83
|
prompt: prompt || undefined,
|
|
84
|
+
...(composed.systemPromptAppend ? { systemPromptAppend: composed.systemPromptAppend } : {}),
|
|
85
|
+
...(composed.relayInjectionEvents.length ? { relayInjectionEvents: composed.relayInjectionEvents } : {}),
|
|
56
86
|
headless: true,
|
|
57
87
|
policyName: policy.name,
|
|
58
88
|
spawnRequestId: requestId,
|
package/src/mcp-plan-tools.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { McpNotFoundError } from "./mcp-errors";
|
|
1
|
+
import { McpAuthError, McpNotFoundError } from "./mcp-errors";
|
|
2
2
|
import { ValidationError } from "./db";
|
|
3
|
+
import { ServiceAuthError } from "./services/errors";
|
|
3
4
|
import { authContextFromMcp } from "./services/auth-context";
|
|
4
5
|
import {
|
|
5
6
|
addPlanEdge,
|
|
@@ -126,6 +127,17 @@ function stringArg(value: unknown, field: string): string {
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
export function relayPlanTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
|
|
130
|
+
try {
|
|
131
|
+
return dispatchPlanTool(auth, name, args);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
// A service-layer auth failure (e.g. #1043's message:send gate on the auto-mint) is an auth
|
|
134
|
+
// error on the wire — map it like the messaging tools do so the MCP edge returns 403.
|
|
135
|
+
if (e instanceof ServiceAuthError) throw new McpAuthError(e.message);
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function dispatchPlanTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
|
|
129
141
|
const ctx = authContextFromMcp(auth);
|
|
130
142
|
if (name === "relay_plan_create") return createPlan(args as unknown as Parameters<typeof createPlan>[0], ctx) as unknown as Record<string, unknown>;
|
|
131
143
|
if (name === "relay_plan_add_node") {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// content — a policy switch here, zero schema/composer change. `project_entries.source`
|
|
11
11
|
// is that lever: the included-source set is a policy PARAMETER, and the `vanilla`
|
|
12
12
|
// policy includes `manual`+`promoted` while excluding `ingested`.
|
|
13
|
-
import {
|
|
13
|
+
import { DEFAULT_USER_ID } from "agent-relay-sdk";
|
|
14
|
+
import { resolveProjectEntries, resolveProjectForCwd, resolveScopedAnchor } from "./db";
|
|
14
15
|
import { renderTemplate } from "./prompt-resolver";
|
|
15
16
|
import { PROJECT_ENTRY_SOURCES, type AgentProfile, type Project, type ProjectEntryKind, type ProjectEntrySource, type RelayInjectionCategory, type ResolvedProjectEntry } from "./types";
|
|
16
17
|
|
|
@@ -57,7 +58,7 @@ interface SpawnInstructionInjectionEvent {
|
|
|
57
58
|
detail: Record<string, unknown>;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
interface ComposedSpawnInstructions {
|
|
61
|
+
export interface ComposedSpawnInstructions {
|
|
61
62
|
systemPromptAppend?: string;
|
|
62
63
|
relayInjectionEvents: SpawnInstructionInjectionEvent[];
|
|
63
64
|
}
|
|
@@ -295,6 +296,39 @@ export function composeSpawnInstructions(input: Parameters<typeof composeSpawnIn
|
|
|
295
296
|
return composeSpawnInstructionsWithEvents(input).systemPromptAppend;
|
|
296
297
|
}
|
|
297
298
|
|
|
299
|
+
/**
|
|
300
|
+
* #1052 — the SINGLE home that resolves a profile's spawn-time knowledge world (project
|
|
301
|
+
* preamble for the cwd + scoped {profile|user} memory anchors) and runs
|
|
302
|
+
* `composeSpawnInstructionsWithEvents`. Every path that (re)builds a profile-bearing spawn —
|
|
303
|
+
* fresh managed spawn, managed-policy restart, AND the dashboard "restart Runner/Agent" relaunch
|
|
304
|
+
* for a NON-policy agent — must call this so a restarted agent is onboarded byte-identically to
|
|
305
|
+
* its first launch (role charter + project knowledge + scoped memory). Before #1052-v2 the
|
|
306
|
+
* dashboard-restart path for a dashboard-spawned (non-policy) coordinator called
|
|
307
|
+
* `buildSpawnCommand` WITHOUT this projection, so the relaunched process came back with EMPTY
|
|
308
|
+
* injection logs and silently reverted to provider-default IC behavior.
|
|
309
|
+
*/
|
|
310
|
+
export function composeProfileSpawnInstructions(input: {
|
|
311
|
+
cwd?: string;
|
|
312
|
+
profileName?: string;
|
|
313
|
+
profile?: Pick<AgentProfile, "instructions" | "maxSpawnedAgents" | "projectInstructions">;
|
|
314
|
+
callerSystemPromptAppend?: string;
|
|
315
|
+
/** Operator/user anchor key; defaults to the single-operator default store. */
|
|
316
|
+
operatorId?: string;
|
|
317
|
+
}): ComposedSpawnInstructions {
|
|
318
|
+
const callerProject = input.cwd ? resolveProjectForCwd(input.cwd) ?? undefined : undefined;
|
|
319
|
+
const projectInstructions = input.profile?.projectInstructions;
|
|
320
|
+
const profileAnchor = input.profileName ? resolveScopedAnchor("profile", input.profileName) ?? undefined : undefined;
|
|
321
|
+
const userAnchor = resolveScopedAnchor("user", input.operatorId ?? DEFAULT_USER_ID) ?? undefined;
|
|
322
|
+
return composeSpawnInstructionsWithEvents({
|
|
323
|
+
...(callerProject ? { callerProject } : {}),
|
|
324
|
+
...(projectInstructions ? { projectInstructions } : {}),
|
|
325
|
+
...(profileAnchor ? { profileAnchor } : {}),
|
|
326
|
+
...(userAnchor ? { userAnchor } : {}),
|
|
327
|
+
...(input.profile ? { profile: input.profile } : {}),
|
|
328
|
+
...(input.callerSystemPromptAppend ? { callerSystemPromptAppend: input.callerSystemPromptAppend } : {}),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
298
332
|
/**
|
|
299
333
|
* Parse a spawn-transport's `projectInstructions` wire value into the normalized policy
|
|
300
334
|
* (or undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift.
|
package/src/quota-advisory.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { emitRelayEvent } from "./events";
|
|
|
5
5
|
import { routeNotification } from "./notification-router";
|
|
6
6
|
import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./quota-threshold-config";
|
|
7
7
|
import { parseJson } from "./utils";
|
|
8
|
-
import { setProviderQuotaAdvisoryHandler, type ProviderQuotaAdvisoryInput } from "./db/provider-quotas.ts";
|
|
8
|
+
import { setProviderQuotaAdvisoryHandler, type ProviderQuotaAdvisoryInput, type ProviderQuotaAdvisoryResult } from "./db/provider-quotas.ts";
|
|
9
9
|
import { processBandTransitions } from "./quota-band-transition";
|
|
10
10
|
import type { QuotaState } from "./types";
|
|
11
11
|
|
|
@@ -40,7 +40,7 @@ export function installQuotaAdvisoryHandler(): void {
|
|
|
40
40
|
setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput):
|
|
43
|
+
function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): ProviderQuotaAdvisoryResult {
|
|
44
44
|
const config = effectiveQuotaNotificationConfig().quotaThreshold;
|
|
45
45
|
const state = parseJson<QuotaAdvisoryState>(input.previousAdvisoryState ?? "{}", {});
|
|
46
46
|
const nextState: QuotaAdvisoryState = { ...state };
|
|
@@ -101,8 +101,10 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
// #1074 — thread the persisted band-hysteresis state through the transition pipeline and return
|
|
105
|
+
// the new state so it is written back to quota_band_state, surviving relay restarts.
|
|
106
|
+
const bandState = processBandTransitions(input, input.previousBandState);
|
|
107
|
+
return { advisoryState: JSON.stringify(nextState), bandState };
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
function hasLastQuotaAdvisory(previous: QuotaAdvisoryWindowState | undefined): boolean {
|
|
@@ -38,6 +38,7 @@ import { getSpawnCapableAgentIds } from "./routing-policy-push";
|
|
|
38
38
|
import { getProviderBandInfo, quotaBandFromSignal, windowRecommendedAction } from "./spawn-targets";
|
|
39
39
|
import type { ProviderBandInfo, ProviderBandWindow, QuotaBand } from "./spawn-targets";
|
|
40
40
|
import type { ProviderQuotaAdvisoryInput } from "./db/provider-quotas";
|
|
41
|
+
import { parseJson } from "./utils";
|
|
41
42
|
|
|
42
43
|
const DEBOUNCE_SAMPLES = 3;
|
|
43
44
|
// #798 — EMA time constant for the low-pass. "A few minutes": longer than the ~5-min poll-cadence
|
|
@@ -68,6 +69,13 @@ interface TransitionState {
|
|
|
68
69
|
emaUpdatedAt: number;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
// #1074 — the per-`provider:role` hysteresis state (EMA low-pass, lastNotifiedBand, debounce
|
|
73
|
+
// counters, lastTransitionAt). Serialized to the provider_quotas.quota_band_state DB column so it
|
|
74
|
+
// survives a relay restart: without it every restart re-seeds lastNotifiedBand="normal" and re-fires
|
|
75
|
+
// an already-sent degradation to the whole fleet. Mirrors the previousAdvisoryState round-trip in
|
|
76
|
+
// quota-advisory.ts — state is passed IN and returned OUT, this module holds no cross-call memory.
|
|
77
|
+
type BandTransitionState = Record<string, TransitionState>;
|
|
78
|
+
|
|
71
79
|
// #798 — dt-based EMA blend factor: 1 − e^(−dt/τ). dt = 0 (or first sample) → seed verbatim.
|
|
72
80
|
function emaAlpha(dtMs: number): number {
|
|
73
81
|
if (!(dtMs > 0)) return 1;
|
|
@@ -79,12 +87,6 @@ function emaStep(prev: number | null, value: number, dtMs: number): number {
|
|
|
79
87
|
return prev + emaAlpha(dtMs) * (value - prev);
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
const bandState = new Map<string, TransitionState>();
|
|
83
|
-
|
|
84
|
-
export function _resetBandTransitionStateForTesting(): void {
|
|
85
|
-
bandState.clear();
|
|
86
|
-
}
|
|
87
|
-
|
|
88
90
|
// Test hook: supply a controlled band-info function to exercise hysteresis logic
|
|
89
91
|
// without a full DB setup. Set to null to restore the real implementation.
|
|
90
92
|
let _bandInfoSupplier: ((provider: string, now: number) => ProviderBandInfo | undefined) | null = null;
|
|
@@ -95,15 +97,24 @@ export function _clearBandInfoSupplierForTesting(): void {
|
|
|
95
97
|
_bandInfoSupplier = null;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
|
|
100
|
+
// #1074 — pure state-threading transition step. Takes the previously persisted band state
|
|
101
|
+
// (serialized `quota_band_state`, may be null/empty on a fresh provider), applies the band-transition
|
|
102
|
+
// hysteresis pipeline for this sample, and returns the new serialized state to persist. Holds no
|
|
103
|
+
// module-level memory, so a relay restart that re-hydrates from the DB continues hysteresis seamlessly
|
|
104
|
+
// instead of re-seeding to "normal" and re-firing an already-sent degradation.
|
|
105
|
+
export function processBandTransitions(
|
|
106
|
+
input: ProviderQuotaAdvisoryInput,
|
|
107
|
+
previousBandState?: string | null,
|
|
108
|
+
): string {
|
|
109
|
+
const bandState = parseJson<BandTransitionState>(previousBandState ?? "{}", {});
|
|
99
110
|
const info = (_bandInfoSupplier ?? getProviderBandInfo)(input.provider, input.now);
|
|
100
|
-
if (!info) return;
|
|
111
|
+
if (!info) return JSON.stringify(bandState);
|
|
101
112
|
|
|
102
113
|
for (const window of info.windows) {
|
|
103
114
|
if (window.confidence === "low" || window.burnRatio === null) continue;
|
|
104
115
|
|
|
105
116
|
const key = `${input.provider}:${window.role}`;
|
|
106
|
-
const state: TransitionState = bandState
|
|
117
|
+
const state: TransitionState = bandState[key] ?? {
|
|
107
118
|
lastNotifiedBand: "normal",
|
|
108
119
|
pendingBand: null,
|
|
109
120
|
pendingCount: 0,
|
|
@@ -124,7 +135,7 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
|
|
|
124
135
|
state.emaUpdatedAt = input.now;
|
|
125
136
|
// Persist immediately so the low-pass state survives every early-continue below (the state is
|
|
126
137
|
// then mutated in place by reference — the EMA must carry across ticks to smooth excursions).
|
|
127
|
-
bandState
|
|
138
|
+
bandState[key] = state;
|
|
128
139
|
const smoothedBand = quotaBandFromSignal(emaPenalty, emaSlack, info.quotaBands, info.preferSlackAt);
|
|
129
140
|
|
|
130
141
|
// #756 (layer 2): asymmetric hysteresis — recovery needs penalty HYSTERESIS_MARGIN below the
|
|
@@ -149,7 +160,6 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
|
|
|
149
160
|
// #756: rate-limit recovery — it is not an actionable signal for coordinators.
|
|
150
161
|
const isRecovery = bandRank(effectiveBand) < bandRank(state.lastNotifiedBand);
|
|
151
162
|
if (isRecovery && input.now - state.lastTransitionAt < RECOVERY_MIN_INTERVAL_MS) {
|
|
152
|
-
bandState.set(key, state);
|
|
153
163
|
continue;
|
|
154
164
|
}
|
|
155
165
|
|
|
@@ -158,7 +168,6 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
|
|
|
158
168
|
state.pendingBand = null;
|
|
159
169
|
state.pendingCount = 0;
|
|
160
170
|
state.lastTransitionAt = input.now;
|
|
161
|
-
bandState.set(key, state);
|
|
162
171
|
emitBandTransition({
|
|
163
172
|
provider: input.provider,
|
|
164
173
|
window,
|
|
@@ -169,10 +178,10 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
|
|
|
169
178
|
recommendedAction: windowRecommendedAction(effectiveBand, window.slack),
|
|
170
179
|
now: input.now,
|
|
171
180
|
});
|
|
172
|
-
} else {
|
|
173
|
-
bandState.set(key, state);
|
|
174
181
|
}
|
|
175
182
|
}
|
|
183
|
+
|
|
184
|
+
return JSON.stringify(bandState);
|
|
176
185
|
}
|
|
177
186
|
|
|
178
187
|
// Asymmetric hysteresis: degradation uses the normal (entry) threshold; recovery requires
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { RELAY_TOKEN_HEADER, isRecord, isSpawnProvider } from "agent-relay-sdk";
|
|
3
3
|
import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeAgentRoute, authorizeRoute, canViewAgentRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, requestUserId, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
|
|
4
4
|
import { arbitrateDrive, currentDriver, drivingMessage, releaseDrive, type DriveArbitration } from "../agent-drive-arbiter";
|
|
5
|
-
import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage, withResolvedProvisioning } from "../db";
|
|
5
|
+
import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, readSpawnCallerSystemPromptAppend, resolveAgentOwnership, sendMessage, withResolvedProvisioning } from "../db";
|
|
6
6
|
import { announcePresence, viewersForAgent } from "../dashboard-presence";
|
|
7
7
|
import { buildManagedSpawnParams } from "../managed-policy";
|
|
8
8
|
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
|
|
@@ -19,6 +19,7 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../servic
|
|
|
19
19
|
import { type AgentCard, type SpawnApprovalMode } from "../types";
|
|
20
20
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
21
21
|
import { getManifest } from "agent-relay-providers";
|
|
22
|
+
import { composeProfileSpawnInstructions } from "../project-instructions";
|
|
22
23
|
import { relayToken } from "../config";
|
|
23
24
|
|
|
24
25
|
// 409 when a second human tries to drive an agent another user is already driving (#394). The
|
|
@@ -95,7 +96,7 @@ function managedControlOrchestrator(agent: AgentCard): NonNullable<ReturnType<ty
|
|
|
95
96
|
return candidates.find((orch) => orch.status === "online") ?? null;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
function restartSpawnParamsForAgent(
|
|
99
|
+
export function restartSpawnParamsForAgent(
|
|
99
100
|
agent: AgentCard,
|
|
100
101
|
orchestrator: NonNullable<ReturnType<typeof getOrchestrator>> | null,
|
|
101
102
|
policyName?: string,
|
|
@@ -103,7 +104,15 @@ function restartSpawnParamsForAgent(
|
|
|
103
104
|
opts: { resumeId?: string } = {},
|
|
104
105
|
): Record<string, unknown> | undefined {
|
|
105
106
|
if (!orchestrator) return undefined;
|
|
106
|
-
|
|
107
|
+
// #1065 — mint a FRESH spawnRequestId for the successor, NEVER reuse the predecessor's. Reusing
|
|
108
|
+
// it voided the #1059 instance guard (exitCertificateMatchesInstance keys death-certificate
|
|
109
|
+
// identity on spawnRequestId): a late/duplicate exit report for the dead predecessor carried the
|
|
110
|
+
// same id as the live successor, so the guard treated the stale certificate as a match and
|
|
111
|
+
// false-reaped the running session (#1054/#1059 reopener). Every other respawn path mints fresh
|
|
112
|
+
// (lifecycle-manager, spawn-policy) and the runner documents the invariant (control.ts:284). The
|
|
113
|
+
// predecessor id (`spawnRequestId`) is still used below to recover the #1062 parent brief from the
|
|
114
|
+
// ORIGINAL agent.spawn command — read before the mint so the brief follows the agent, not the id.
|
|
115
|
+
const requestId = generateSpawnRequestId();
|
|
107
116
|
const policy = policyName ? getSpawnPolicy(policyName) : null;
|
|
108
117
|
const requestedBy = opts.resumeId ? "dashboard-resume" : "dashboard-restart";
|
|
109
118
|
if (policy) {
|
|
@@ -124,6 +133,27 @@ function restartSpawnParamsForAgent(
|
|
|
124
133
|
const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
|
|
125
134
|
const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
|
|
126
135
|
const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
|
|
136
|
+
// #1052-v2 — a dashboard-spawned agent has NO spawn policy, so its restart skips the
|
|
137
|
+
// `buildManagedSpawnParams` policy branch above and lands here. Re-project the profile role
|
|
138
|
+
// charter / project knowledge / scoped memory (the SAME projection a fresh spawn gets) so the
|
|
139
|
+
// relaunch is onboarded byte-identically instead of reverting to provider-default IC behavior.
|
|
140
|
+
// #1062 — two remaining fresh-spawn divergences closed here: (1) anchor the user/operator scoped
|
|
141
|
+
// memory to the agent's OWNER via resolveAgentOwnership (a fresh spawn does the same), not the
|
|
142
|
+
// default operator; and (2) re-thread the parent's ORIGINAL caller brief, recovered from the
|
|
143
|
+
// persisted agent.spawn command, so a restarted spawned child keeps its task brief.
|
|
144
|
+
// #1065 — key this on the PREDECESSOR id (`spawnRequestId`), NOT the freshly-minted `requestId`:
|
|
145
|
+
// the caller brief was persisted on the original agent.spawn command, which the successor's fresh
|
|
146
|
+
// id has no row for. Recovering it here re-injects the brief onto the new instance.
|
|
147
|
+
// #1067 — recovery must survive N restarts, not just one. Because #1065 rotates the id every
|
|
148
|
+
// restart and never writes an agent.spawn row for the successor, a naive single-hop lookup found
|
|
149
|
+
// the brief on restart #1 (predecessor = original id) but lost it on restart #2 (predecessor =
|
|
150
|
+
// the fresh id from restart #1, which has no spawn row). Fix: re-persist the RAW brief under the
|
|
151
|
+
// fresh id below (passed through to `callerSystemPromptAppend` on this restart's `restartSpawn`
|
|
152
|
+
// params — the SAME field a fresh agent.spawn stashes it in, see spawn-agent.ts). The reader then
|
|
153
|
+
// recovers it from either the original agent.spawn row OR a later agent.restart row, so each
|
|
154
|
+
// generation has its own recoverable copy and the brief follows the agent across every rotation.
|
|
155
|
+
const callerAppend = readSpawnCallerSystemPromptAppend(spawnRequestId);
|
|
156
|
+
const composed = composeProfileSpawnInstructions({ cwd, operatorId: resolveAgentOwnership(agent.id).ownerUserId, ...(profileName ? { profileName } : {}), ...(agentProfile ? { profile: agentProfile } : {}), ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}) });
|
|
127
157
|
const params = buildSpawnCommand({
|
|
128
158
|
provider,
|
|
129
159
|
modelParams: resolvedModel,
|
|
@@ -138,6 +168,12 @@ function restartSpawnParamsForAgent(
|
|
|
138
168
|
approvalMode: approvalMode ?? "guarded",
|
|
139
169
|
permissionMode: approvalMode ?? "guarded",
|
|
140
170
|
providerArgs: providerArgs.length ? providerArgs : undefined,
|
|
171
|
+
...(composed.systemPromptAppend ? { systemPromptAppend: composed.systemPromptAppend } : {}),
|
|
172
|
+
// #1067 — re-persist the RAW brief (pre-composition) under this restart's fresh spawnRequestId so
|
|
173
|
+
// the NEXT restart, whose predecessor id is this fresh id, can recover it. Without this the brief
|
|
174
|
+
// survives only one restart (the original agent.spawn row is keyed on the very first id).
|
|
175
|
+
...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}),
|
|
176
|
+
...(composed.relayInjectionEvents.length ? { relayInjectionEvents: composed.relayInjectionEvents } : {}),
|
|
141
177
|
policyName: policyName || undefined,
|
|
142
178
|
headless: true,
|
|
143
179
|
spawnRequestId: requestId,
|
|
@@ -157,10 +193,6 @@ function restartSpawnParamsForAgent(
|
|
|
157
193
|
return opts.resumeId ? withResumeParams(params, opts.resumeId, agent.id) : params;
|
|
158
194
|
}
|
|
159
195
|
|
|
160
|
-
function spawnRequestIdForRestart(): string {
|
|
161
|
-
return generateSpawnRequestId();
|
|
162
|
-
}
|
|
163
|
-
|
|
164
196
|
function withResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
|
|
165
197
|
return {
|
|
166
198
|
...params,
|
package/src/routes/agents.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, canViewAgentRoute, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, visibleAgentIdsForRequest, type Handler } from "./_shared";
|
|
3
3
|
import { ValidationError, deleteAgent, getAgent, getAgentTimeline, getCrashReport, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
|
|
4
4
|
import { attachBranchState, attachBranchStates } from "../agent-branch-state";
|
|
5
|
+
import { attachCommandActivities, attachCommandActivity } from "../agent-command-activity";
|
|
5
6
|
import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
|
|
6
7
|
import { cleanStringArray } from "../validation";
|
|
7
8
|
import { clearActiveMemories, memoryBroker } from "../memory-service";
|
|
@@ -53,7 +54,7 @@ export const getAgents: Handler = (req) => {
|
|
|
53
54
|
// (visibleAgentIdsForRequest returns every id), so today's list is byte-for-byte unchanged.
|
|
54
55
|
const agents = listAgents({ tag, machine, status });
|
|
55
56
|
const visible = visibleAgentIdsForRequest(req, agents.map((a) => a.id));
|
|
56
|
-
return json(attachIssueRepos(attachBranchStates(agents.filter((a) => visible.has(a.id)))));
|
|
57
|
+
return json(attachCommandActivities(attachIssueRepos(attachBranchStates(agents.filter((a) => visible.has(a.id))))));
|
|
57
58
|
};
|
|
58
59
|
|
|
59
60
|
export const findAgents: Handler = (req) => {
|
|
@@ -69,7 +70,7 @@ export const getAgentById: Handler = (req, params) => {
|
|
|
69
70
|
if (!agent) return error("agent not found", 404);
|
|
70
71
|
// #392: a non-visible agent must look indistinguishable from a missing one (no existence leak).
|
|
71
72
|
if (!canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
|
|
72
|
-
return json(attachIssueRepo(attachBranchState(agent)));
|
|
73
|
+
return json(attachCommandActivity(attachIssueRepo(attachBranchState(agent))));
|
|
73
74
|
};
|
|
74
75
|
|
|
75
76
|
// #636 — "why did it die" data source. The crash report outlives the agent row (separate table),
|
package/src/routes/tasks.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { MAX_BODY_BYTES } from "../config";
|
|
|
3
3
|
import { VALID_TASK_STATUSES, agentSessionStatus, auditEvent, dispatchTaskCallbacks, emitCommand, error, json, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, resolveActorLabel, type Handler } from "./_shared";
|
|
4
4
|
import { getComponentAuth, isFullReachRequest } from "../security";
|
|
5
5
|
import { authContextFromRequest } from "../services/auth-context";
|
|
6
|
-
import { agentIdFromComponent, isTaskDispatchAuthorized, isTaskReadAuthorized } from "../task-authz";
|
|
6
|
+
import { agentIdFromComponent, hasTaskReadAdminScope, isTaskDispatchAuthorized, isTaskReadAuthorized } from "../task-authz";
|
|
7
7
|
import { dispatchShapeArg, pinArg, runTaskDispatch } from "../mcp-dispatch-tools";
|
|
8
|
-
import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTasks, recordTaskEvent, renewTaskClaim, updateTaskStatus } from "../db";
|
|
8
|
+
import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTasks, recordTaskEvent, renewTaskClaim, updateTaskStatus, type TaskStatusActor } from "../db";
|
|
9
9
|
import { readTaskMemoryEnvelope } from "../db/task-memory";
|
|
10
10
|
import {
|
|
11
11
|
addTaskEntityDependency, addTaskEntityRef, assignTaskEntityAgent, createTaskEntity, getTaskEntity,
|
|
@@ -531,9 +531,21 @@ export const patchTaskStatus: Handler = async (req, params) => {
|
|
|
531
531
|
if (id === null) return error("invalid task id");
|
|
532
532
|
const parsed = await parseBody<unknown>(req);
|
|
533
533
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
534
|
+
// Ownership fence (#1063): bind the write to the AUTHENTICATED caller, not the request body.
|
|
535
|
+
// Full-reach/system callers and task-admin scopes bypass (same convention as isTaskDispatchAuthorized
|
|
536
|
+
// / #1043's ensureCanMintThread); an ordinary task:write holder is pinned to its own agent id and
|
|
537
|
+
// can only advance a task it actually owns. An unidentified non-privileged caller is refused rather
|
|
538
|
+
// than silently falling back to the owner id.
|
|
539
|
+
const ctx = authContextFromRequest(req);
|
|
540
|
+
const privileged = ctx.actor.kind === "system" || hasTaskReadAdminScope(ctx.scopes);
|
|
541
|
+
if (!privileged && !ctx.callerAgentId) return error("task status requires an identified caller", 403);
|
|
542
|
+
const actor: TaskStatusActor = privileged ? { privileged: true } : { agentId: ctx.callerAgentId };
|
|
534
543
|
try {
|
|
535
|
-
const result = updateTaskStatus(id, normalizeTaskStatusInput(parsed.body));
|
|
536
|
-
if (!result.ok)
|
|
544
|
+
const result = updateTaskStatus(id, normalizeTaskStatusInput(parsed.body), actor);
|
|
545
|
+
if (!result.ok) {
|
|
546
|
+
const status = result.error === "task not found" ? 404 : result.conflict ? 409 : agentSessionStatus(result.error);
|
|
547
|
+
return error(result.error!, status);
|
|
548
|
+
}
|
|
537
549
|
emitTaskChanged(result.task!, "task.status");
|
|
538
550
|
const capturedMemory = result.task!.status === "done"
|
|
539
551
|
? await captureTaskResultMemory(result.task!, result.task!.result)
|