agent-relay-server 0.121.5 → 0.122.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +6 -6
  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/adapters/claude-delivery.ts +127 -0
  88. package/runner/src/adapters/claude-quota-harvest.ts +92 -0
  89. package/runner/src/adapters/claude-session-probe.ts +225 -0
  90. package/runner/src/adapters/claude-status-detectors.ts +147 -0
  91. package/runner/src/adapters/claude-transcript-tail.ts +128 -0
  92. package/runner/src/adapters/claude-transcript.ts +414 -0
  93. package/runner/src/adapters/claude.ts +672 -0
  94. package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
  95. package/runner/src/adapters/codex-client.ts +279 -0
  96. package/runner/src/adapters/codex.ts +1345 -0
  97. package/runner/src/attachment-cache.ts +189 -0
  98. package/runner/src/busy-reconciler.ts +102 -0
  99. package/runner/src/claim-tracker.ts +140 -0
  100. package/runner/src/claude-prompt-gates.ts +268 -0
  101. package/runner/src/config.ts +1 -4
  102. package/runner/src/context-probe-state.ts +6 -0
  103. package/runner/src/continuation-archive.ts +179 -0
  104. package/runner/src/control-server.ts +639 -0
  105. package/runner/src/index.ts +396 -0
  106. package/runner/src/launch-assembly.ts +508 -0
  107. package/runner/src/liveness.ts +50 -0
  108. package/runner/src/logger.ts +99 -0
  109. package/runner/src/mcp-outbox.ts +120 -0
  110. package/runner/src/message-body-config/config.ts +3 -0
  111. package/runner/src/message-body-config.ts +1 -0
  112. package/runner/src/native-self-resume.ts +202 -0
  113. package/runner/src/outbox.ts +342 -0
  114. package/runner/src/process-meta.ts +9 -0
  115. package/runner/src/profile-home.ts +260 -0
  116. package/runner/src/profile-projection.ts +305 -0
  117. package/runner/src/providers.ts +20 -0
  118. package/runner/src/provisioning.ts +314 -0
  119. package/runner/src/quota.ts +40 -0
  120. package/runner/src/rate-limit.ts +189 -0
  121. package/runner/src/relay-injection-events.ts +102 -0
  122. package/runner/src/relay-instructions.ts +60 -0
  123. package/runner/src/relay-mcp-proxy.ts +383 -0
  124. package/runner/src/relay-mcp.ts +156 -0
  125. package/runner/src/reply-obligation-cache.ts +136 -0
  126. package/runner/src/response-capture-report.ts +63 -0
  127. package/runner/src/runner-core.ts +2409 -0
  128. package/runner/src/runner-helpers.ts +435 -0
  129. package/runner/src/runner-insights.ts +105 -0
  130. package/runner/src/runner.ts +14 -0
  131. package/runner/src/session-destroy.ts +22 -0
  132. package/runner/src/session-insights.ts +118 -0
  133. package/runner/src/session-scratch.ts +271 -0
  134. package/runner/src/template-resolver.ts +29 -0
  135. package/runner/src/version.ts +43 -0
  136. package/src/agent-card-projection.ts +5 -1
  137. package/src/agent-command-activity.ts +39 -0
  138. package/src/automations/reconcile.ts +2 -2
  139. package/src/cli/workspace.ts +3 -1
  140. package/src/config.ts +10 -1
  141. package/src/db/agents.ts +118 -37
  142. package/src/db/message-reads.ts +50 -0
  143. package/src/db/messages.ts +48 -9
  144. package/src/db/migrations.ts +54 -0
  145. package/src/db/provider-quota-burn.ts +96 -67
  146. package/src/db/provider-quotas.ts +29 -7
  147. package/src/db/scheduled-timers.ts +21 -0
  148. package/src/db/schema.ts +1 -0
  149. package/src/db/tasks.ts +33 -3
  150. package/src/db/work-evidence.ts +10 -7
  151. package/src/event-loop-lag.ts +15 -0
  152. package/src/gate-resolver.ts +19 -1
  153. package/src/maintenance/jobs.ts +23 -6
  154. package/src/managed-policy.ts +5 -12
  155. package/src/mcp-plan-tools.ts +13 -1
  156. package/src/project-instructions.ts +36 -2
  157. package/src/quota-advisory.ts +6 -4
  158. package/src/quota-band-transition.ts +23 -14
  159. package/src/routes/agent-sessions.ts +39 -7
  160. package/src/routes/agents.ts +3 -2
  161. package/src/routes/tasks.ts +16 -4
  162. package/src/routes/workspaces.ts +35 -10
  163. package/src/services/plan-graph.ts +27 -9
  164. package/src/services/register-agent.ts +12 -12
  165. package/src/services/send-message.ts +43 -15
  166. package/src/services/spawn-agent.ts +3 -0
  167. package/src/spawn-command.ts +8 -0
  168. package/src/spawn-targets.ts +19 -34
  169. package/src/sse.ts +25 -3
  170. package/src/workspace-merge.ts +25 -5
  171. package/src/workspace-orphans.ts +13 -6
  172. package/public/assets/display-exQDWum3.js +0 -3
  173. package/public/assets/display-exQDWum3.js.map +0 -1
  174. package/public/assets/index-D9OogaB5.js +0 -25
  175. package/public/assets/projects-DdPNPFJI.js +0 -2
  176. package/public/assets/prompt-settings-BpZse7-X.js +0 -2
  177. package/public/assets/store-DKTLubBT.js.map +0 -1
  178. package/public/assets/teams-B8f2pGJe.js +0 -2
  179. package/public/assets/types-uVOXgvMT.js.map +0 -1
@@ -1,28 +1,38 @@
1
- import { DEFAULT_QUOTA_TARGET_UTILIZATION, quotaPaceMetrics, quotaWindowMetrics } from "agent-relay-sdk/provider-quota";
1
+ import { DEFAULT_QUOTA_TARGET_UTILIZATION, quotaPaceMetrics, quotaTrendProjection, quotaWindowMetrics } from "agent-relay-sdk/provider-quota";
2
2
  import type { ProviderQuotaBurn, ProviderQuotaBurnRole, ProviderQuotaBurnWindow, ProviderQuotaSnapshot, QuotaState } from "../types";
3
3
 
4
4
  const QUOTA_BURN_MIN_SAMPLE_SPAN_MS = 10 * 60_000;
5
5
 
6
- // #760 — nominal window length per role, used to scale the recency half-life.
6
+ // #760 — nominal window length per role. windowStartAt = resetAt − this, and it scales the
7
+ // evidence-confidence horizon below.
7
8
  const WINDOW_NOMINAL_MS: Record<ProviderQuotaBurnRole, number> = {
8
9
  "5h": 5 * 3_600_000,
9
10
  "7d": 7 * 24 * 3_600_000,
10
11
  };
11
12
 
12
- // #760recency half-life as a fraction of the window length. 0.25 ~75min for the 5h
13
- // window (within the issue's ~60–90min trailing-window guidance) and ~42h for the 7d window.
14
- // A burst at the START of the history span decays out as it ages, so an idle stretch pulls the
15
- // observed rate (and burnRatio) back toward 0 instead of freezing the endpoint slope.
16
- const RECENCY_HALFLIFE_FRACTION = 0.25;
17
-
18
- // #798 — idle de-bias: once a provider's utilization has held flat for this long, any residual
19
- // forward-burn rate is a stale-burst artifact (the recency half-life decays it, but for the 7d
20
- // windowhalf-life ~42h far too slowly: codex idle ~24h still projected ~1.07 at reset,
21
- // walking the band boundary tick-to-tick). When idle this long the forward-burn term is collapsed
22
- // so projectedUtilAtReset current utilization. The threshold is absolute (NOT window-scaled): a
23
- // 5h window resets long before reaching it, so this only de-biases the slow-decaying 7d window;
24
- // shorter idle stretches keep the graceful #760 recency decay. The over-target avoid is preserved
25
- // by the position penalty in spawn-targets (a stable, position-based floor), not by this rate.
13
+ // #1072SLOW EWMA time constant (dt-correct). This is the sustained-trend requirement made
14
+ // mechanical: a 15-min onset burst barely moves a 24h-τ average, so a transient spawn-burst spike
15
+ // cannot flip the band only a trend that holds for hours does. Drives the band projection.
16
+ const EWMA_TAU_SLOW_MS: Record<ProviderQuotaBurnRole, number> = {
17
+ "5h": 2 * 3_600_000,
18
+ "7d": 24 * 3_600_000,
19
+ };
20
+
21
+ // #1072continuous trend confidence reaches 1 once the observed span covers this horizon (the
22
+ // slow-EWMA τ). A fresh window (tiny observed span) ~0 confidence the projected (not the
23
+ // measured) overshoot cannot hard-escalate the band; by the time the span covers τ, a sustained
24
+ // trend is real. Absorbs the #1073 confidence-gate stopgap into the model.
25
+ const CONFIDENCE_FULL_EVIDENCE_MS: Record<ProviderQuotaBurnRole, number> = {
26
+ "5h": 2 * 3_600_000,
27
+ "7d": 24 * 3_600_000,
28
+ };
29
+
30
+ // #798 — idle collapse, retained for STABILITY (not for the penalty). Once utilization has held
31
+ // flat for this long, the residual slow-EWMA rate is a stale-burst tail: the slow average decays
32
+ // but never reaches exactly 0 in finite time, so a small rate × a multi-day horizon would still
33
+ // creep the projection tick-to-tick (the band walk from #798). Pinning the rate to 0 once idle
34
+ // this long fixes the projection at current utilization so the band is flat across ticks. The
35
+ // threshold is absolute (a 5h window resets long before reaching it, so this only affects 7d).
26
36
  const IDLE_FORWARD_COLLAPSE_MS = 6 * 3_600_000;
27
37
  const IDLE_UTILIZATION_EPSILON = 1e-9;
28
38
 
@@ -63,32 +73,38 @@ function lowConfidenceBurnWindow(role: ProviderQuotaBurnRole, window: QuotaWindo
63
73
  projectedUtilAtReset: null,
64
74
  slack: null,
65
75
  target: null,
76
+ confidenceScore: null,
66
77
  };
67
78
  }
68
79
 
69
- // #760recency-weighted consumption rate (utilization-fraction per ms). A weighted slope:
70
- // each consecutive interval contributes its actual Δutilization and Δt, decayed by the age of
71
- // its midpoint with the given half-life. A recent idle interval (large Δt, ~0 Δutil, high
72
- // weight) dominates the denominator and drives the rate toward 0; an old burst is down-weighted.
73
- function recencyWeightedRatePerMs(
80
+ // #1072dt-correct EWMA of the consumption rate (utilization-fraction per ms). Each consecutive
81
+ // interval contributes its mean rate Δutil/Δt, held constant over Δt and decayed by exp(−Δt/τ), so
82
+ // a long idle interval (Δt≫τ, rate ~0) drives the average toward 0 while a short burst barely
83
+ // moves a long-τ average. The EWMA is SEEDED AT 0 (not at the first interval's raw slope) so a
84
+ // sustained trend is required to build the rate up — a single onset interval can never set the
85
+ // full slope. Interval rates are clamped ≥0 (a within-window utilization drop is treated as no
86
+ // consumption, not negative burn).
87
+ function ewmaRatePerMs(
74
88
  samples: Array<{ capturedAt: number; utilization: number }>,
75
89
  now: number,
76
- halfLifeMs: number,
90
+ tauMs: number,
77
91
  ): number {
78
- let weightedDelta = 0;
79
- let weightedDt = 0;
92
+ let r = 0;
80
93
  for (let i = 1; i < samples.length; i++) {
81
- const prev = samples[i - 1]!;
82
- const cur = samples[i]!;
83
- const dt = cur.capturedAt - prev.capturedAt;
94
+ const dt = samples[i]!.capturedAt - samples[i - 1]!.capturedAt;
84
95
  if (dt <= 0) continue;
85
- const midAge = Math.max(0, now - (prev.capturedAt + cur.capturedAt) / 2);
86
- const weight = Math.pow(0.5, midAge / halfLifeMs);
87
- weightedDelta += weight * (cur.utilization - prev.utilization);
88
- weightedDt += weight * dt;
96
+ const rate = Math.max(0, (samples[i]!.utilization - samples[i - 1]!.utilization) / dt);
97
+ const decay = Math.exp(-dt / tauMs);
98
+ r = decay * r + (1 - decay) * rate;
99
+ }
100
+ // Idle observability: extend to `now` with a zero-rate interval so a deduped idle stretch (no new
101
+ // distinct samples arrive while utilization holds) still decays the average instead of freezing
102
+ // the last observed slope — the same synthetic-tail trick #760 used, applied to the EWMA.
103
+ const last = samples[samples.length - 1];
104
+ if (last && now > last.capturedAt) {
105
+ r *= Math.exp(-(now - last.capturedAt) / tauMs);
89
106
  }
90
- if (weightedDt <= 0) return 0;
91
- return Math.max(0, weightedDelta / weightedDt);
107
+ return Math.max(0, r);
92
108
  }
93
109
 
94
110
  // #798 — when did utilization last differ from its current value? Scanning back from the newest
@@ -148,62 +164,75 @@ function deriveProviderQuotaBurnWindow(input: {
148
164
  const timeToResetMs = resetAt - input.now;
149
165
  if (remainingQuota <= 0 || timeToResetMs <= 0) return low;
150
166
 
151
- // #760 (Defect 1) recency-weighted rate so an old burst decays and idle relaxes toward 0,
152
- // instead of a frozen two-point endpoint slope over the whole span.
153
- //
154
- // Idle is invisible in the raw samples: identical utilization snapshots are deduped, so during
155
- // an idle stretch no new rows arrive and the latest sample stays frozen at the last CHANGE
156
- // (this is why codex sat at burn×6.3 a stale burst slope). We make idle observable by
157
- // appending a synthetic "utilization is still X as of now" point, which adds a recent
158
- // zero-delta interval [lastSample, now] that the recency weighting lets dominate as it grows.
159
- const halfLifeMs = WINDOW_NOMINAL_MS[role] * RECENCY_HALFLIFE_FRACTION;
160
- const rateSamples = input.now > current.capturedAt
161
- ? [...samples, { capturedAt: input.now, utilization: utilizationNow }]
162
- : samples;
163
- const observedRatePerMs = recencyWeightedRatePerMs(rateSamples, input.now, halfLifeMs);
164
- // #798 — idle de-bias: if utilization has held flat (no change vs. now) for IDLE_FORWARD_COLLAPSE_MS,
165
- // treat the forward-burn term as a stale-burst artifact and collapse it to 0 so the projection
166
- // pins to current utilization instead of walking the band boundary. lastChangeAt is the moment
167
- // utilization last differed from the current value (the synthetic "still X as of now" interval is
168
- // what makes a deduped idle stretch observable here).
167
+ // #1072 the SLOW EWMA is the burn rate: a sustained trend is required by construction, so a
168
+ // transient onset burst (100% of a young window's evidence) can't drive the band. The EWMA walks
169
+ // the real sample timeline and extends a zero-rate tail to `now`, so a deduped idle stretch
170
+ // decays the rate instead of freezing the last observed slope (the #760 burn×6.3 stale-slope bug).
171
+ const rSlow = ewmaRatePerMs(samples, input.now, EWMA_TAU_SLOW_MS[role]);
172
+ // #798 idle collapse (stability only): once utilization has held flat IDLE_FORWARD_COLLAPSE_MS
173
+ // the residual slow rate is a stale-burst tail pin it to 0 so the projection == current
174
+ // utilization and stays flat tick-to-tick (no band walk). lastChangeAt is when utilization last
175
+ // differed from the current value.
169
176
  const lastChangeAt = lastUtilizationChangeAt(samples, utilizationNow);
170
- const effectiveRate = input.now - lastChangeAt >= IDLE_FORWARD_COLLAPSE_MS ? 0 : observedRatePerMs;
171
- // #760 (Defect 2) — pace against a configurable target (<100%); slack carries the underburn
172
- // signal. The query layer (spawn-targets) may re-derive these against a per-provider target;
173
- // the ingest-time values use the default so direct burn consumers (dashboard) have a number.
177
+ const bandRate = input.now - lastChangeAt >= IDLE_FORWARD_COLLAPSE_MS ? 0 : rSlow;
178
+
179
+ // #1072 evidence span and continuous trend confidence. evidenceMs is how long we've actually
180
+ // observed this window instance (bounded by both the window's own start and our first sample);
181
+ // it caps the projection horizon and drives the confidence that gates hard-escalation.
182
+ const windowStartAt = resetAt - WINDOW_NOMINAL_MS[role];
183
+ const evidenceMs = Math.max(0, input.now - Math.max(windowStartAt, oldest.capturedAt));
184
+ const confidence = Math.max(0, Math.min(1, evidenceMs / CONFIDENCE_FULL_EVIDENCE_MS[role]));
185
+
186
+ // #1072 — evidence-capped band projection off the slow rate. The query layer (spawn-targets)
187
+ // re-derives slack/penalty against the per-provider target from this (target-independent)
188
+ // projection + confidenceScore; the ingest-time values use the default target so direct burn
189
+ // consumers (dashboard) have a number.
190
+ const trend = quotaTrendProjection({
191
+ utilization: utilizationNow,
192
+ slowRatePerMs: bandRate,
193
+ timeToResetMs,
194
+ evidenceMs,
195
+ confidence,
196
+ target: DEFAULT_QUOTA_TARGET_UTILIZATION,
197
+ });
198
+ // burnRatio (display) paces the same slow rate against the target, so the widget's burn number
199
+ // and the routing band always agree — a bursty spike shows in the raw sparkline, not here.
174
200
  const pace = quotaPaceMetrics({
175
201
  utilization: utilizationNow,
176
- observedRatePerMs: effectiveRate,
202
+ observedRatePerMs: bandRate,
177
203
  timeToResetMs,
178
204
  target: DEFAULT_QUOTA_TARGET_UTILIZATION,
179
205
  });
180
- if (effectiveRate <= 0) {
181
- // Idle (decayed-to-idle, or #798 idle-collapsed): no projected wall, but slack still flags the
182
- // headroom (positive = underburn) or, when over target, the position the band-floor avoids.
206
+ if (bandRate <= 0) {
207
+ // Idle (flat, decayed-to-idle, or #798 idle-collapsed): no projected wall, but the projection
208
+ // still pins to current utilization and slack flags headroom (positive) or the over-target
209
+ // position the penalty avoids (negative).
183
210
  return {
184
211
  ...low,
185
212
  confidence: "high",
186
213
  burnRatio: 0,
187
214
  observedRatePerMs: 0,
188
- projectedUtilAtReset: pace.projectedUtilAtReset,
189
- slack: pace.slack,
190
- target: pace.target,
215
+ projectedUtilAtReset: trend.projectedUtilAtReset,
216
+ slack: trend.slack,
217
+ target: trend.target,
218
+ confidenceScore: confidence,
191
219
  };
192
220
  }
193
- const timeToWallMs = remainingQuota / effectiveRate;
221
+ const timeToWallMs = remainingQuota / bandRate;
194
222
  const projectedWallAt = input.now + timeToWallMs;
195
223
  return {
196
224
  ...low,
197
225
  confidence: "high",
198
226
  burnRatio: pace.burnRatio,
199
- observedRatePerMs: effectiveRate,
227
+ observedRatePerMs: bandRate,
200
228
  timeToWallMs,
201
229
  projectedWallAt,
202
230
  // #760 (Defect 2) — symmetric: negative means the wall lands AFTER reset (headroom left).
203
231
  earlyWallMs: resetAt - projectedWallAt,
204
- projectedUtilAtReset: pace.projectedUtilAtReset,
205
- slack: pace.slack,
206
- target: pace.target,
232
+ projectedUtilAtReset: trend.projectedUtilAtReset,
233
+ slack: trend.slack,
234
+ target: trend.target,
235
+ confidenceScore: confidence,
207
236
  };
208
237
  }
209
238
 
@@ -26,12 +26,22 @@ export interface ProviderQuotaAdvisoryInput {
26
26
  quota: QuotaState;
27
27
  sourceAgentIds: string[];
28
28
  previousAdvisoryState?: string | null;
29
+ // #1074 — serialized quota-band hysteresis state (quota_band_state column). Threaded in so the
30
+ // band-transition pipeline hydrates instead of re-seeding to "normal" on every relay restart.
31
+ previousBandState?: string | null;
29
32
  now: number;
30
33
  }
31
34
 
32
- let providerQuotaAdvisoryHandler: ((input: ProviderQuotaAdvisoryInput) => string) | undefined;
35
+ // #1074 — the handler returns BOTH persisted blobs: the utilization advisory state and the
36
+ // band-transition hysteresis state. Both are written back to their respective columns.
37
+ export interface ProviderQuotaAdvisoryResult {
38
+ advisoryState: string;
39
+ bandState: string;
40
+ }
41
+
42
+ let providerQuotaAdvisoryHandler: ((input: ProviderQuotaAdvisoryInput) => ProviderQuotaAdvisoryResult) | undefined;
33
43
 
34
- export function setProviderQuotaAdvisoryHandler(handler: ((input: ProviderQuotaAdvisoryInput) => string) | undefined): void {
44
+ export function setProviderQuotaAdvisoryHandler(handler: ((input: ProviderQuotaAdvisoryInput) => ProviderQuotaAdvisoryResult) | undefined): void {
35
45
  providerQuotaAdvisoryHandler = handler;
36
46
  }
37
47
 
@@ -42,6 +52,7 @@ type ProviderQuotaRow = {
42
52
  codex_reset_credits?: string | null;
43
53
  source_agent_ids?: string | null;
44
54
  quota_advisory_state?: string | null;
55
+ quota_band_state?: string | null;
45
56
  last_updated_at?: number | null;
46
57
  last_attempt_at: number;
47
58
  last_error?: string | null;
@@ -182,6 +193,13 @@ function newestQuotaAdvisoryState(rows: ProviderQuotaRow[]): string {
182
193
  return row?.quota_advisory_state ?? "{}";
183
194
  }
184
195
 
196
+ function newestQuotaBandState(rows: ProviderQuotaRow[]): string {
197
+ const row = rows
198
+ .filter((candidate) => candidate.quota_band_state)
199
+ .sort((a, b) => b.updated_at - a.updated_at)[0];
200
+ return row?.quota_band_state ?? "{}";
201
+ }
202
+
185
203
  function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccountKeys: string[], now: number): number {
186
204
  const keys = [...new Set(oldAccountKeys.filter((key) => key && key !== accountKey))];
187
205
  if (keys.length === 0) return 0;
@@ -203,9 +221,9 @@ function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccount
203
221
  getDb().query(`
204
222
  INSERT INTO provider_quotas (
205
223
  provider, account_key, quota_state, codex_reset_credits, source_agent_ids, last_updated_at,
206
- last_attempt_at, last_error, quota_advisory_state, updated_at, created_at
224
+ last_attempt_at, last_error, quota_advisory_state, quota_band_state, updated_at, created_at
207
225
  )
208
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
226
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
209
227
  ON CONFLICT(provider, account_key) DO UPDATE SET
210
228
  quota_state = excluded.quota_state,
211
229
  codex_reset_credits = excluded.codex_reset_credits,
@@ -214,6 +232,7 @@ function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccount
214
232
  last_attempt_at = excluded.last_attempt_at,
215
233
  last_error = excluded.last_error,
216
234
  quota_advisory_state = excluded.quota_advisory_state,
235
+ quota_band_state = excluded.quota_band_state,
217
236
  updated_at = excluded.updated_at
218
237
  `).run(
219
238
  provider,
@@ -225,6 +244,7 @@ function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccount
225
244
  lastAttemptAt || now,
226
245
  newestLastError(rows),
227
246
  newestQuotaAdvisoryState(rows),
247
+ newestQuotaBandState(rows),
228
248
  updatedAt,
229
249
  createdAt,
230
250
  );
@@ -552,17 +572,19 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
552
572
  recordProviderQuotaUnavailable({ provider, accountKey, error: input.lastError, sourceAgentId: input.sourceAgentId }, now);
553
573
  }
554
574
  if (hasFreshSuccessfulProviderQuota(input, now) && mergedQuota?.acceptedIncomingWindow) {
555
- const quotaAdvisoryState = providerQuotaAdvisoryHandler?.({
575
+ const advisoryResult = providerQuotaAdvisoryHandler?.({
556
576
  provider,
557
577
  accountKey,
558
578
  previousQuota: existingQuota ? publicQuotaState(existingQuota) : undefined,
559
579
  quota: publicQuotaState(mergedQuota.quota),
560
580
  sourceAgentIds,
561
581
  previousAdvisoryState: existing?.quota_advisory_state,
582
+ previousBandState: existing?.quota_band_state,
562
583
  now,
563
584
  });
564
- if (quotaAdvisoryState !== undefined) {
565
- getDb().query("UPDATE provider_quotas SET quota_advisory_state = ? WHERE provider = ? AND account_key = ?").run(quotaAdvisoryState, provider, accountKey);
585
+ if (advisoryResult !== undefined) {
586
+ getDb().query("UPDATE provider_quotas SET quota_advisory_state = ?, quota_band_state = ? WHERE provider = ? AND account_key = ?")
587
+ .run(advisoryResult.advisoryState, advisoryResult.bandState, provider, accountKey);
566
588
  }
567
589
  }
568
590
  if (!unstableProviderQuotaAccountKey(accountKey)) {
@@ -185,6 +185,27 @@ export function countActiveScheduledTimers(createdBy: string): number {
185
185
  return row.count;
186
186
  }
187
187
 
188
+ // #1004 — a command timer whose background command is still pending/running is active work
189
+ // executing on the creator's behalf (a shell command on an orchestrator), even though its
190
+ // provider turn already ended and reports idle. A prompt-wakeup timer (no command) is just a
191
+ // future alarm and never counts. These back the running-command → busy DISPLAY derivation; the
192
+ // timer/command row is the source of truth, so the signal self-heals when the command settles.
193
+ const RUNNING_COMMAND_TIMER_PREDICATE =
194
+ "enabled = 1 AND command IS NOT NULL AND json_extract(command, '$.status') IN ('pending', 'running')";
195
+
196
+ export function agentHasRunningCommandTimer(createdBy: string): boolean {
197
+ return getDb()
198
+ .query(`SELECT 1 FROM scheduled_timers WHERE created_by = ? AND ${RUNNING_COMMAND_TIMER_PREDICATE} LIMIT 1`)
199
+ .get(createdBy) != null;
200
+ }
201
+
202
+ export function agentsWithRunningCommandTimers(): Set<string> {
203
+ const rows = getDb()
204
+ .query(`SELECT DISTINCT created_by FROM scheduled_timers WHERE ${RUNNING_COMMAND_TIMER_PREDICATE}`)
205
+ .all() as Array<{ created_by: string }>;
206
+ return new Set(rows.map((row) => row.created_by));
207
+ }
208
+
188
209
  export function listDueScheduledTimerIds(now: number, limit = 25): string[] {
189
210
  const rows = getDb().query(`
190
211
  SELECT id FROM scheduled_timers
package/src/db/schema.ts CHANGED
@@ -366,6 +366,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
366
366
  codex_reset_credits TEXT,
367
367
  source_agent_ids TEXT NOT NULL DEFAULT '[]',
368
368
  quota_advisory_state TEXT NOT NULL DEFAULT '{}',
369
+ quota_band_state TEXT NOT NULL DEFAULT '{}',
369
370
  last_updated_at INTEGER,
370
371
  last_attempt_at INTEGER NOT NULL,
371
372
  last_error TEXT,
package/src/db/tasks.ts CHANGED
@@ -327,15 +327,45 @@ export function renewTaskClaim(taskId: number, agentId: string, guard?: AgentSes
327
327
  return { ok: true, task: getTask(taskId)! };
328
328
  }
329
329
 
330
- export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok: boolean; error?: string; task?: Task; event?: TaskEvent } {
330
+ /**
331
+ * The AUTHENTICATED principal behind a status write, derived at the transport boundary from the
332
+ * signed token (`ctx.callerAgentId` / scopes) — NEVER from the request body. The ownership fence
333
+ * keys on this, not `input.agentId`, so omitting/spoofing the body field cannot advance another
334
+ * agent's claim (#1063).
335
+ */
336
+ export interface TaskStatusActor {
337
+ /** The caller's own agent id (`ctx.callerAgentId`). undefined for full-reach/system/multi-agent tokens. */
338
+ agentId?: string;
339
+ /** Full-reach `*`, internal `system`, or a task-admin scope: may advance a task it does not own. */
340
+ privileged?: boolean;
341
+ }
342
+
343
+ export function updateTaskStatus(taskId: number, input: TaskStatusInput, actor?: TaskStatusActor): { ok: boolean; error?: string; conflict?: boolean; task?: Task; event?: TaskEvent } {
331
344
  const task = getTask(taskId);
332
345
  if (!task) return { ok: false, error: "task not found" };
333
346
  // Fence: the claim-lane status updater writes `claimed_by`; deliverable Tasks must never
334
347
  // be mutated here. They transition through setTaskStatus (src/db/task-entity.ts), which
335
348
  // never touches claimed_by. (#834)
336
349
  if (task.kind === DELIVERABLE_TASK_KIND) return { ok: false, error: "deliverable task: use the task-entity status API" };
350
+ // Ownership fence (#1061, hardened #1063): once a task is claimed, only its current owner may
351
+ // advance the status. The acting identity is the AUTHENTICATED caller (`actor.agentId`, resolved
352
+ // from the signed token at the route) — NEVER the client-supplied `input.agentId`. Trusting the
353
+ // body let any task:write holder omit or spoof it and steal another agent's claim (#1063).
354
+ // A privileged caller (full-reach `*`, internal `system`, or a task-admin scope) may advance a
355
+ // task it does not own — the same escape hatch #1043's `ensureCanMintThread` grants. An undefined
356
+ // `actor` is an in-process trusted caller (automations, tests) and is likewise privileged.
357
+ // A null claimed_by still self-claims (fresh status write).
358
+ const privileged = actor?.privileged ?? actor === undefined;
359
+ const callerAgentId = actor?.agentId;
360
+ if (!privileged && task.claimedBy && callerAgentId !== task.claimedBy) {
361
+ return { ok: false, error: `task claimed by ${task.claimedBy}`, conflict: true };
362
+ }
337
363
  const now = Date.now();
338
- const agentId = input.agentId ?? task.claimedBy ?? null;
364
+ // Effective owner/session identity. A privileged caller may act on behalf of the supplied
365
+ // `input.agentId` (or the existing owner — the automation/system self-claim path); an ordinary
366
+ // caller is bound to its authenticated identity and must NOT fall back to `claimedBy` — that
367
+ // fallback was the #1063 bypass that let a missing `agentId` silently inherit the owner.
368
+ const agentId = privileged ? (input.agentId ?? task.claimedBy ?? null) : (callerAgentId ?? null);
339
369
  if (agentId) {
340
370
  const session = validateAgentSession(agentId, input);
341
371
  if (!session.ok) return { ok: false, error: session.error };
@@ -360,7 +390,7 @@ export function updateTaskStatus(taskId: number, input: TaskStatusInput): { ok:
360
390
  }
361
391
  }
362
392
  const event = insertTaskEvent(taskId, {
363
- source: input.agentId ?? "agent-relay",
393
+ source: agentId ?? "agent-relay",
364
394
  type: `status:${input.status}`,
365
395
  severity: task.severity,
366
396
  title: `Task ${input.status}`,
@@ -24,13 +24,16 @@ export function hasArtifactCreatedBy(createdBy: string, since: number = 0): bool
24
24
 
25
25
  export function listFinalResponseCaptureMessages(from: string, since: number = 0, limit: number = 20): Message[] {
26
26
  const safeLimit = Math.min(Math.max(Math.floor(limit), 1), 100);
27
- const rows = getDb().query(`
28
- ${MSG_SELECT}
29
- WHERE m.from_agent = ?
30
- AND m.kind = 'chat'
31
- AND m.created_at >= ?
32
- AND json_extract(m.payload, '$.responseCapture.type') = 'response'
33
- AND json_extract(m.payload, '$.responseCapture.final') = 1
27
+ const rows = getDb().query(`
28
+ ${MSG_SELECT}
29
+ WHERE m.from_agent = ?
30
+ AND m.kind = 'chat'
31
+ AND m.created_at >= ?
32
+ AND json_extract(m.payload, '$.responseCapture.type') = 'response'
33
+ AND (
34
+ json_extract(m.payload, '$.responseCapture.turnFinal') = 1
35
+ OR json_extract(m.payload, '$.responseCapture.final') = 1
36
+ )
34
37
  ORDER BY m.id DESC
35
38
  LIMIT ?
36
39
  `).all(from, since, safeLimit) as any[];
@@ -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,11 @@
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 repo wants enforced before land/release): the steward runs it
15
+ // wholesale so land gates == release gates (#1044/#1089), instead of a
16
+ // file-relevance subset that would let drift/lint/doc breakage land.
13
17
  // 3. Default — the legacy bun heuristic, only when there's nothing to derive from
14
18
  // (no repo path, or no readable package.json).
15
19
  import { existsSync, readFileSync } from "node:fs";
@@ -98,6 +102,20 @@ export function resolveStewardChecks(files: string[], repoRoot?: string): Stewar
98
102
  const pkg = repoRoot ? readPackageInfo(repoRoot) : null;
99
103
  if (pkg) {
100
104
  const runScript = (name: string) => `${pkg.pm} run ${name}`;
105
+ // 2a. Land==release parity (#1044): a repo that declares its full validation gate as
106
+ // a script has ALREADY named the set the release enforces. Run that ONE gate wholesale
107
+ // instead of a file-relevance subset (typecheck+test), so a land can't introduce
108
+ // drift/lint/doc breakage that only a narrower gate misses and that would otherwise
109
+ // surface at release time (failing `*:check`/lint gates).
110
+ //
111
+ // Prefer an explicit `ci:land` script when the repo declares one: it is the repo's
112
+ // named pre-land full gate, so the steward should respect that contract over a more
113
+ // generic `ci`. Repos that want exact parity can make `ci` delegate to `ci:land`;
114
+ // repos that need a different land-safe full gate still have a first-class name for it.
115
+ const fullGate = pkg.scripts["ci:land"] ? "ci:land" : pkg.scripts.ci ? "ci" : undefined;
116
+ if (fullGate && files.length) {
117
+ return [{ command: runScript(fullGate), reason: `repo declares a full "${fullGate}" gate (land==release parity)` }];
118
+ }
101
119
  if (has(/\.(ts|tsx|mts|cts)$/) && pkg.scripts.typecheck) {
102
120
  checks.push({ command: runScript("typecheck"), reason: "TypeScript files changed" });
103
121
  }
@@ -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,8 +1,7 @@
1
1
  import { getManifest } from "agent-relay-providers";
2
- import { DEFAULT_USER_ID } from "agent-relay-sdk";
3
2
  import { getAgentProfile } from "./config-store";
4
- import { resolveProjectForCwd, resolveScopedAnchor, withResolvedProvisioning } from "./db";
5
- import { composeSpawnInstructionsWithEvents } from "./project-instructions";
3
+ import { withResolvedProvisioning } from "./db";
4
+ import { composeProfileSpawnInstructions } from "./project-instructions";
6
5
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
7
6
  import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
8
7
  import { applyDefaultSharedMcp } from "./shared-mcp";
@@ -58,16 +57,10 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
58
57
  // keepalive prompt folds in as the caller append → one ordered `--append-system-prompt` block,
59
58
  // no double-inject. The projection is deterministic (sorted + deduped), so re-running it on a
60
59
  // native-resume relaunch reproduces a byte-identical block rather than stacking a second copy.
61
- const callerProject = resolveProjectForCwd(policy.cwd) ?? undefined;
62
- const projectInstructions = agentProfile?.projectInstructions;
63
- const profileAnchor = policy.profile ? resolveScopedAnchor("profile", policy.profile) ?? undefined : undefined;
64
- const userAnchor = resolveScopedAnchor("user", DEFAULT_USER_ID) ?? undefined;
65
60
  const alwaysOnAppend = managedPolicyAppendSystemPrompt(policy);
66
- const composed = composeSpawnInstructionsWithEvents({
67
- ...(callerProject ? { callerProject } : {}),
68
- ...(projectInstructions ? { projectInstructions } : {}),
69
- ...(profileAnchor ? { profileAnchor } : {}),
70
- ...(userAnchor ? { userAnchor } : {}),
61
+ const composed = composeProfileSpawnInstructions({
62
+ cwd: policy.cwd,
63
+ ...(policy.profile ? { profileName: policy.profile } : {}),
71
64
  ...(agentProfile ? { profile: agentProfile } : {}),
72
65
  ...(alwaysOnAppend ? { callerSystemPromptAppend: alwaysOnAppend } : {}),
73
66
  });
@@ -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") {