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.
Files changed (132) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -4
  3. package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
  4. package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
  7. package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
  8. package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
  10. package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
  12. package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
  13. package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
  14. package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
  15. package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
  16. package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
  17. package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
  18. package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
  20. package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
  21. package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
  22. package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
  23. package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
  24. package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
  25. package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
  26. package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
  27. package/public/assets/display-CJzSoTMq.js +3 -0
  28. package/public/assets/display-CJzSoTMq.js.map +1 -0
  29. package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
  30. package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
  32. package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
  33. package/public/assets/index-D0xMQQb_.js +25 -0
  34. package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
  35. package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
  36. package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
  37. package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
  38. package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
  39. package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
  40. package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
  41. package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
  42. package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
  43. package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
  44. package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
  45. package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
  46. package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
  47. package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
  48. package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
  49. package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
  50. package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
  51. package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
  52. package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
  53. package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
  54. package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
  55. package/public/assets/projects-Dzdf-gkK.js +2 -0
  56. package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
  57. package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
  58. package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
  59. package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
  60. package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
  61. package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
  62. package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
  63. package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
  64. package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
  65. package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
  66. package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
  67. package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
  68. package/public/assets/store-BPrVuKiv.js.map +1 -0
  69. package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
  70. package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
  71. package/public/assets/teams-DWv6UwFV.js +2 -0
  72. package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
  73. package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
  74. package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
  75. package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
  76. package/public/assets/types-BeJUSfDj.js.map +1 -0
  77. package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
  78. package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
  79. package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
  80. package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
  81. package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
  82. package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
  83. package/public/index.html +6 -6
  84. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  85. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
  86. package/runner/src/adapter.ts +53 -18
  87. package/runner/src/config.ts +1 -4
  88. package/src/agent-card-projection.ts +5 -1
  89. package/src/agent-command-activity.ts +39 -0
  90. package/src/automations/reconcile.ts +2 -2
  91. package/src/bus.ts +6 -4
  92. package/src/cli/workspace.ts +3 -1
  93. package/src/config.ts +10 -1
  94. package/src/db/agents.ts +138 -32
  95. package/src/db/message-reads.ts +50 -0
  96. package/src/db/messages.ts +48 -9
  97. package/src/db/migrations.ts +54 -0
  98. package/src/db/provider-quota-burn.ts +96 -67
  99. package/src/db/provider-quotas.ts +29 -7
  100. package/src/db/scheduled-timers.ts +21 -0
  101. package/src/db/schema.ts +1 -0
  102. package/src/db/tasks.ts +33 -3
  103. package/src/db/work-evidence.ts +10 -7
  104. package/src/event-loop-lag.ts +15 -0
  105. package/src/gate-resolver.ts +24 -1
  106. package/src/maintenance/jobs.ts +23 -6
  107. package/src/managed-policy.ts +35 -5
  108. package/src/mcp-plan-tools.ts +13 -1
  109. package/src/project-instructions.ts +36 -2
  110. package/src/quota-advisory.ts +6 -4
  111. package/src/quota-band-transition.ts +23 -14
  112. package/src/routes/agent-sessions.ts +39 -7
  113. package/src/routes/agents.ts +3 -2
  114. package/src/routes/tasks.ts +16 -4
  115. package/src/routes/workspaces.ts +35 -10
  116. package/src/services/plan-graph.ts +27 -9
  117. package/src/services/register-agent.ts +12 -1
  118. package/src/services/send-message.ts +43 -15
  119. package/src/services/spawn-agent.ts +3 -0
  120. package/src/spawn-command.ts +8 -0
  121. package/src/spawn-targets.ts +19 -34
  122. package/src/sse.ts +25 -3
  123. package/src/workspace-merge.ts +25 -5
  124. package/src/workspace-orphans.ts +13 -6
  125. package/public/assets/display-exQDWum3.js +0 -3
  126. package/public/assets/display-exQDWum3.js.map +0 -1
  127. package/public/assets/index-D9OogaB5.js +0 -25
  128. package/public/assets/projects-DdPNPFJI.js +0 -2
  129. package/public/assets/prompt-settings-BpZse7-X.js +0 -2
  130. package/public/assets/store-DKTLubBT.js.map +0 -1
  131. package/public/assets/teams-B8f2pGJe.js +0 -2
  132. package/public/assets/types-uVOXgvMT.js.map +0 -1
@@ -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;
@@ -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
  }
@@ -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 { eventLoopLagMaxMs } from "../event-loop-lag";
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, eventLoopLagMaxMs());
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. eventLoopLagMaxMs()
305
- // is side-effect-free (does not consume the /health poller's spike max). During a
306
- // relay self-stall this grace keeps live agents whose heartbeats we simply couldn't
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 = eventLoopLagMaxMs();
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",
@@ -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
- export function managedPolicyProviderArgs(policy: SpawnPolicy): string[] {
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 [...policy.providerArgs, "--append-system-prompt", policy.prompt.trim()];
24
+ return policy.prompt.trim();
16
25
  }
17
- return policy.providerArgs;
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,
@@ -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 { resolveProjectEntries } from "./db";
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.
@@ -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): string {
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
- processBandTransitions(input);
105
- return JSON.stringify(nextState);
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
- export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void {
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.get(key) ?? {
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.set(key, state);
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
- const requestId = spawnRequestId ?? spawnRequestIdForRestart();
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,
@@ -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),
@@ -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) return error(result.error!, result.error === "task not found" ? 404 : agentSessionStatus(result.error));
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)