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
@@ -1,8 +1,8 @@
1
1
  import type { AgentProfile, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
2
- import { isRecord } from "agent-relay-sdk";
2
+ import { isRecord } from "agent-relay-sdk/types/guards";
3
3
  import type { LivenessInputs } from "./liveness";
4
4
  import type { SessionEvent } from "./session-insights";
5
- import { messageBodyMaxCharsFromEnv } from "./config";
5
+ import { messageBodyMaxCharsFromEnv } from "./message-body-config";
6
6
  import { relayReplyReminderText } from "./relay-instructions";
7
7
  import { renderRunnerTemplate } from "./template-resolver";
8
8
 
@@ -250,6 +250,53 @@ export function providerMessageBodyMaxChars(): number {
250
250
  return DEFAULT_PROVIDER_MESSAGE_BODY_MAX_CHARS;
251
251
  }
252
252
 
253
+ export interface DeliveredBodyPreview {
254
+ body: string;
255
+ truncated: boolean;
256
+ /** Retrieval-guidance lines (empty when not truncated). Call sites may append provider extras. */
257
+ guidance: string[];
258
+ }
259
+
260
+ // #1058 — one home for the delivered-body cap. Head-only truncation silently ate the TAIL, which is
261
+ // exactly where a report-up final puts its verdict / SHIP recommendation. Preserve BOTH ends: keep a
262
+ // head slice for opening context and a tail slice for the conclusion, eliding the middle and pointing
263
+ // at the untrimmed body. The tail is favored (it holds the verdict). Slices snap to whitespace so we
264
+ // don't deliver a half-word at either seam. Grep before adding another truncation site — this is it.
265
+ export function previewDeliveredBody(
266
+ rawBody: string,
267
+ messageId: number,
268
+ maxChars: number = providerMessageBodyMaxChars(),
269
+ ): DeliveredBodyPreview {
270
+ if (rawBody.length <= maxChars) return { body: rawBody, truncated: false, guidance: [] };
271
+ const fetchLines = [
272
+ `Read full: agent-relay get-message ${messageId}`,
273
+ `Body only: agent-relay get-message ${messageId} --body`,
274
+ ];
275
+ const marker = (elided: number) => `[… ${elided} chars elided from the middle — full report: relay_get_message #${messageId} …]`;
276
+ // Reserve room for the marker and its two blank-line joins; upper-bound the marker length by using
277
+ // the (always-larger) full body length so the reserved budget can never underflow the real marker.
278
+ const budget = maxChars - marker(rawBody.length).length - 4;
279
+ if (budget < 200) {
280
+ // Cap too small to keep a useful head — front-truncate, preserving the tail/verdict whole.
281
+ const tail = rawBody.slice(rawBody.length - maxChars).replace(/^\S*\s+/, "").trimStart();
282
+ return {
283
+ body: tail,
284
+ truncated: true,
285
+ guidance: [`[truncated: showing last ${tail.length} of ${rawBody.length} chars — head trimmed, tail/verdict preserved]`, ...fetchLines],
286
+ };
287
+ }
288
+ const headChars = Math.floor(budget * 0.35);
289
+ const tailChars = budget - headChars;
290
+ const head = rawBody.slice(0, headChars).replace(/\s+\S*$/, "").trimEnd();
291
+ const tail = rawBody.slice(rawBody.length - tailChars).replace(/^\S*\s+/, "").trimStart();
292
+ const elided = rawBody.length - head.length - tail.length;
293
+ return {
294
+ body: `${head}\n\n${marker(elided)}\n\n${tail}`,
295
+ truncated: true,
296
+ guidance: [`[truncated: showing first ${head.length} + last ${tail.length} of ${rawBody.length} chars — tail/verdict preserved]`, ...fetchLines],
297
+ };
298
+ }
299
+
253
300
  function attachmentRefs(message: Message): Record<string, unknown>[] {
254
301
  const payloadRefs = message.payload?.attachments;
255
302
  const topLevelRefs = (message as Message & { attachments?: unknown }).attachments;
@@ -375,23 +422,11 @@ export function providerMessageText(messages: Message[]): string {
375
422
  const subject = message.subject ? `Subject: ${message.subject}\n` : "";
376
423
  const isMemoryContext = isMemoryInjection(message);
377
424
  const canReferenceMessage = isPersistedRelayMessage(message) && !isMemoryContext && !isReactionNotification(message);
378
- const shouldPreview = canReferenceMessage && message.body.length > maxChars;
379
- const preview = shouldPreview
380
- ? {
381
- body: message.body.slice(0, maxChars),
382
- truncated: true,
383
- }
384
- : {
385
- body: message.body,
386
- truncated: false,
387
- };
425
+ const preview = canReferenceMessage
426
+ ? previewDeliveredBody(message.body, message.id, maxChars)
427
+ : { body: message.body, truncated: false, guidance: [] };
388
428
  const truncationGuidance = preview.truncated
389
- ? [
390
- `[truncated: showing first ${maxChars} of ${message.body.length} chars]`,
391
- `Read full: agent-relay get-message ${message.id}`,
392
- `Body only: agent-relay get-message ${message.id} --body`,
393
- `Long reply CLI fallback: agent-relay /reply ${message.id} --body-file response.md`,
394
- ].join("\n")
429
+ ? [...preview.guidance, `Long reply CLI fallback: agent-relay /reply ${message.id} --body-file response.md`].join("\n")
395
430
  : undefined;
396
431
  if (isMemoryContext) {
397
432
  return [
@@ -6,6 +6,7 @@ import { DEFAULT_RELAY_URL, RELAY_TOKEN_HEADER, errMessage, stringValue } from "
6
6
  import type { SettingEntry, WorkspaceMetadata } from "agent-relay-sdk";
7
7
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
8
8
  import { getManifest } from "agent-relay-providers";
9
+ export { messageBodyMaxCharsFromEnv } from "./message-body-config";
9
10
 
10
11
  interface ProviderConfig {
11
12
  command: string;
@@ -241,10 +242,6 @@ export function providerHomeRootFromEnv(): string {
241
242
  return process.env.AGENT_RELAY_PROVIDER_HOME_ROOT || join(homedir(), ".agent-relay", "provider-homes");
242
243
  }
243
244
 
244
- export function messageBodyMaxCharsFromEnv(): string | undefined {
245
- return process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
246
- }
247
-
248
245
  export function loadProviderConfig(provider: string, home = agentRelayHome()): LoadedProviderConfig {
249
246
  const path = join(providersDir(home), `${provider}.json`);
250
247
  const raw = readJson(path);
@@ -9,6 +9,7 @@
9
9
  import { isRecord } from "agent-relay-sdk";
10
10
  import { attachBranchState } from "./agent-branch-state";
11
11
  import { deriveAgentBusyHealth } from "./agent-busy-health";
12
+ import { attachCommandActivity } from "./agent-command-activity";
12
13
  import { attachIssueRepo } from "./agent-issue-repo";
13
14
  import type { AgentCard } from "./types";
14
15
 
@@ -28,8 +29,11 @@ const OMITTED_STATUS_WORKSPACE_KEYS = new Set([
28
29
  /** Enrich an AgentCard with the derived fields the dashboard expects on every surface. */
29
30
  export function projectAgentCard(agent: AgentCard): AgentCard {
30
31
  const projected = attachIssueRepo(attachBranchState(agent));
32
+ // busy-health derives from the RAW provider status, so read it before the #1004 command-busy
33
+ // overlay — a running background command must not make the agent look stuck-busy.
31
34
  const busyHealth = deriveAgentBusyHealth(projected);
32
- return busyHealth ? { ...projected, meta: { ...(projected.meta ?? {}), busyHealth } } : projected;
35
+ const enriched = busyHealth ? { ...projected, meta: { ...(projected.meta ?? {}), busyHealth } } : projected;
36
+ return attachCommandActivity(enriched);
33
37
  }
34
38
 
35
39
  function projectStatusMeta(meta: AgentCard["meta"]): AgentCard["meta"] | undefined {
@@ -0,0 +1,39 @@
1
+ // #1004 — the single home for the running-command → busy DISPLAY rule.
2
+ //
3
+ // A background command scheduled via relay_schedule_timer runs on the creating agent's behalf
4
+ // (on an orchestrator). The provider reports that agent `idle` the instant its own turn ends,
5
+ // so the still-running command is invisible on the dashboard — it reads idle while a real shell
6
+ // command (e.g. a release) executes. Reflect it as `busy` for the command's lifetime.
7
+ //
8
+ // Every agent-card serialization boundary routes through here — the SSE projection
9
+ // (agent-card-projection.ts) AND the REST reads (routes/agents.ts) — mirroring how
10
+ // attachBranchState/attachIssueRepo enrich at those same boundaries, so live ticks and polls
11
+ // agree. A prompt-wakeup timer (no command) is a plain future alarm and never triggers this.
12
+ //
13
+ // Override only a resting `idle`/`online`: never mask a genuine offline/stale/error, and never
14
+ // fabricate work the provider itself already reports (busy stays busy). The busy-ness is derived
15
+ // live from the timer/command rows (the source of truth via agentHasRunningCommandTimer), not a
16
+ // duplicated status flag — so it self-heals when the command settles and can't leak a
17
+ // permanently-busy agent. It is a display overlay only: the stored provider status the reconcilers
18
+ // read (getAgent/listAgents) is untouched, so stuck-busy health and lifecycle gating are unaffected.
19
+ import { agentHasRunningCommandTimer, agentsWithRunningCommandTimers } from "./db";
20
+ import type { AgentCard } from "./types";
21
+
22
+ function applyCommandBusy(agent: AgentCard, running: boolean): AgentCard {
23
+ if (!running) return agent;
24
+ if (agent.status !== "idle" && agent.status !== "online") return agent;
25
+ return { ...agent, status: "busy" };
26
+ }
27
+
28
+ /** Overlay running-command busy on a single card (SSE projection + REST get-by-id). */
29
+ export function attachCommandActivity(agent: AgentCard): AgentCard {
30
+ return applyCommandBusy(agent, agentHasRunningCommandTimer(agent.id));
31
+ }
32
+
33
+ /** Batch variant for list endpoints: one query, then map (REST get-many). */
34
+ export function attachCommandActivities(agents: AgentCard[]): AgentCard[] {
35
+ if (agents.length === 0) return agents;
36
+ const running = agentsWithRunningCommandTimers();
37
+ if (running.size === 0) return agents;
38
+ return agents.map((agent) => applyCommandBusy(agent, running.has(agent.id)));
39
+ }
@@ -202,7 +202,7 @@ function maybeFailNeverReadyOfflineTarget(run: AutomationRun, task: Task, now: n
202
202
  automationTargetNeverReady: true,
203
203
  agentId,
204
204
  },
205
- });
205
+ }, { privileged: true });
206
206
  const updatedTask = taskResult.task ?? getTask(task.id) ?? task;
207
207
  updateRun(run.id, {
208
208
  status: "failed",
@@ -248,7 +248,7 @@ function maybeTimeoutRuntimeBudget(run: AutomationRun, task: Task, now: number):
248
248
  maxRuntimeMs: budget.maxRuntimeMs,
249
249
  deadlineAt: deadline,
250
250
  },
251
- });
251
+ }, { privileged: true });
252
252
  const updatedTask = taskResult.task ?? getTask(task.id) ?? task;
253
253
  const command = requestOnDemandAgentShutdown(run, updatedTask, now, "runtime-budget-timeout");
254
254
  updateRun(run.id, {
package/src/bus.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
- import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus, validateAgentSession } from "./db";
2
+ import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, setStatus, validateAgentSession } from "./db";
3
3
  import { getOutboxCursor, hasReplayGap, replayEventsSlice, type BusEvent } from "./bus-outbox";
4
4
  import { projectAgentStatusData } from "./agent-card-projection";
5
5
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
@@ -127,9 +127,11 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs(), lagGraceMs = 0
127
127
  const rows = getDb().query("SELECT id FROM agents WHERE status = 'stale' AND last_seen < ? AND id NOT IN ('user', 'system')").all(cutoff) as Array<{ id: string }>;
128
128
  const orphanedTasks: Task[] = [];
129
129
  for (const row of rows) {
130
- // Bus-grace staleness across a relay restart/reconnect the process may be alive.
131
- // Tag "stale" so a live runner self-heals via orchestrator re-mint (#484).
132
- revokeRuntimeTokensForAgent(row.id, Date.now(), "stale");
130
+ // #1051 — bus-grace staleness is a liveness observation, not a credential fact: the
131
+ // process may still be alive (relay stall / network blip). Mark offline for routing,
132
+ // but do NOT revoke the token — a reconnect accepts the EXISTING token and re-marks
133
+ // live. The #484 restart-strand revoke now fires on instance takeover in registerAgent
134
+ // (a fresh instanceId → epoch bump), never on a heartbeat lapse.
133
135
  setStatus(row.id, "offline");
134
136
  emitAgentStatusEvent(row.id);
135
137
  // #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
@@ -150,7 +150,9 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
150
150
  }
151
151
 
152
152
  if (action === "cleanup-stale") {
153
- const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute, ...(idExplicit && id ? { workspaceId: id } : {}) });
153
+ // #965 --force discards an abandoned offline-owner worktree even when its git state is a
154
+ // conflict/unlanded (the land-safety gate is right for live work, wrong for a dead branch).
155
+ const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute, force, ...(idExplicit && id ? { workspaceId: id } : {}) });
154
156
  console.log(JSON.stringify(result, null, 2));
155
157
  return;
156
158
  }
package/src/config.ts CHANGED
@@ -65,7 +65,16 @@ export const STUCK_BUSY_STARTUP_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_STAR
65
65
  export const STUCK_BUSY_RECONNECT_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_RECONNECT_MS", STUCK_BUSY_THRESHOLD_MS);
66
66
  export const STUCK_BUSY_SHUTDOWN_MS = envPositiveInt("AGENT_RELAY_STUCK_BUSY_SHUTDOWN_MS", STUCK_BUSY_THRESHOLD_MS);
67
67
  export const EVENT_LOOP_LAG_SAMPLE_MS = envPositiveInt("AGENT_RELAY_EVENT_LOOP_LAG_SAMPLE_MS", 1_000);
68
- export const EVENT_LOOP_LAG_WINDOW_SAMPLES = envPositiveInt("AGENT_RELAY_EVENT_LOOP_LAG_WINDOW_SAMPLES", 60);
68
+ // #1055 invariant: retain STRICTLY MORE than one reaper interval of lag history. The
69
+ // stale/bus reaper reads the cumulative lag grace once per REAP_INTERVAL_MS; if the ring
70
+ // held only one interval's worth (the old 60×1s == 60s == REAP_INTERVAL coincidence),
71
+ // the oldest stall samples could rotate out the instant before the first post-recovery
72
+ // reaper run and collapse the grace exactly when it's needed. Keep a half-interval margin
73
+ // (1.5× reaper interval) so recent overload always survives to the next reaper tick.
74
+ export const EVENT_LOOP_LAG_WINDOW_SAMPLES = envPositiveInt(
75
+ "AGENT_RELAY_EVENT_LOOP_LAG_WINDOW_SAMPLES",
76
+ Math.ceil((REAP_INTERVAL_MS / EVENT_LOOP_LAG_SAMPLE_MS) * 1.5),
77
+ );
69
78
  export const EVENT_LOOP_LAG_WARN_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_WARN_MS", 250);
70
79
  export const EVENT_LOOP_LAG_LOG_MS = envNonNegativeNumber("AGENT_RELAY_EVENT_LOOP_LAG_LOG_MS", EVENT_LOOP_LAG_WARN_MS);
71
80
  // #713 — provider-token silence ALONE is not a wedge signal for Claude (long turns, extended
package/src/db/agents.ts CHANGED
@@ -145,7 +145,7 @@ function tagsWithProvider(input: RegisterAgentInput): string[] {
145
145
  }
146
146
 
147
147
 
148
- export function upsertAgent(input: RegisterAgentInput): AgentCard {
148
+ export function upsertAgent(input: RegisterAgentInput, opts?: { verifiedKeepJtis?: string[] }): AgentCard {
149
149
  const now = Date.now();
150
150
  const kind = inferAgentKind(input);
151
151
  if (kind === "channel") {
@@ -190,35 +190,48 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
190
190
  last_seen = $now
191
191
  `);
192
192
 
193
- stmt.run({
194
- $id: input.id,
195
- $name: input.name,
196
- $kind: kind,
197
- $label: input.label ?? null,
198
- $labelProvided: labelProvided ? 1 : 0,
199
- $tags: JSON.stringify(tags),
200
- $machine: input.machine ?? null,
201
- $rig: input.rig ?? null,
202
- $capabilities: JSON.stringify(input.capabilities ?? []),
203
- $ready: input.ready ? 1 : 0,
204
- $readyProvided: readyProvided ? 1 : 0,
205
- $status: input.status ?? "idle",
206
- $instanceId: input.instanceId ?? null,
207
- $instanceProvided: instanceProvided ? 1 : 0,
208
- $initialEpoch: instanceProvided ? 1 : 0,
209
- $providerCapabilities: input.providerCapabilities ? JSON.stringify(input.providerCapabilities) : null,
210
- $contextState: contextState ? JSON.stringify(contextState) : null,
211
- $quotaState: input.quota ? JSON.stringify(input.quota) : null,
212
- $meta: JSON.stringify(input.meta ?? {}),
213
- $lifecycle: input.lifecycle ?? "persistent",
214
- $lifecycleProvided: lifecycleProvided ? 1 : 0,
215
- $spawnedBy: input.spawnedBy ?? null,
216
- $teamId: input.teamId ?? null,
217
- $now: now,
218
- });
193
+ // #1057(2) — the takeover revoke fires on the SAME predicate as the epoch bump, by observing
194
+ // the bump itself (prior.epoch !== next.epoch) rather than re-deriving the SQL's instance-
195
+ // transition condition. The old guard in registerAgent required a truthy prior instanceId, so a
196
+ // NULL→instanceId takeover bumped the epoch yet skipped the revoke, stranding the prior token
197
+ // until the 24h prune. #1057(4) — write + revoke share one transaction: a revoke failure rolls
198
+ // the epoch bump back, so registration fails loudly with no committed bump / un-revoked token.
199
+ const agent = getDb().transaction(() => {
200
+ const prior = getAgent(input.id);
201
+ stmt.run({
202
+ $id: input.id,
203
+ $name: input.name,
204
+ $kind: kind,
205
+ $label: input.label ?? null,
206
+ $labelProvided: labelProvided ? 1 : 0,
207
+ $tags: JSON.stringify(tags),
208
+ $machine: input.machine ?? null,
209
+ $rig: input.rig ?? null,
210
+ $capabilities: JSON.stringify(input.capabilities ?? []),
211
+ $ready: input.ready ? 1 : 0,
212
+ $readyProvided: readyProvided ? 1 : 0,
213
+ $status: input.status ?? "idle",
214
+ $instanceId: input.instanceId ?? null,
215
+ $instanceProvided: instanceProvided ? 1 : 0,
216
+ $initialEpoch: instanceProvided ? 1 : 0,
217
+ $providerCapabilities: input.providerCapabilities ? JSON.stringify(input.providerCapabilities) : null,
218
+ $contextState: contextState ? JSON.stringify(contextState) : null,
219
+ $quotaState: input.quota ? JSON.stringify(input.quota) : null,
220
+ $meta: JSON.stringify(input.meta ?? {}),
221
+ $lifecycle: input.lifecycle ?? "persistent",
222
+ $lifecycleProvided: lifecycleProvided ? 1 : 0,
223
+ $spawnedBy: input.spawnedBy ?? null,
224
+ $teamId: input.teamId ?? null,
225
+ $now: now,
226
+ });
227
+ const next = getAgent(input.id)!;
228
+ if (prior && next.epoch !== prior.epoch) {
229
+ revokeSupersededRuntimeTokens(prior, opts?.verifiedKeepJtis ?? [], now);
230
+ }
231
+ return next;
232
+ })();
219
233
  if (contextState) recordContextSnapshot(input.id, contextState, now);
220
234
 
221
- const agent = getAgent(input.id)!;
222
235
  seedContinuationObjectiveFromSpawnPrompt(agent.id, stringValue(agent.meta?.spawnRequestId), DEFAULT_SELF_RESUME_CONFIG, now);
223
236
  if (agent.kind === "channel") upsertChannelForAgent(agent);
224
237
  evaluatePoolBindings();
@@ -363,15 +376,49 @@ export function clearAgentMetaKeys(id: string, keys: string[]): boolean {
363
376
  return getDb().query("UPDATE agents SET meta = ? WHERE id = ?").run(JSON.stringify(meta), id).changes > 0;
364
377
  }
365
378
 
379
+ /**
380
+ * #1059 — decide whether a process-exit certificate belongs to an agent row's CURRENT
381
+ * instance. A late/duplicate orchestrator exit report for a SUPERSEDED session (the agent id
382
+ * re-registered as a fresh instance after the report was generated) carries the prior session's
383
+ * `spawnRequestId`; the live successor's meta carries the new one. When both are known and
384
+ * differ, the certificate is for a dead predecessor and must never mark the successor gone.
385
+ * Unknown on either side (legacy / unmanaged sessions that never carried a spawnRequestId) can't
386
+ * be disproven, so it is treated as matching — preserving the pre-#1059 fast-exit path.
387
+ *
388
+ * The single home for the death-certificate/instance identity check, shared by the stamp site
389
+ * (recordAgentExitDiagnostics, below) and the reaper's death predicate (isOwnerProcessConfirmedGone
390
+ * in workspace-merge.ts) so the two can't drift.
391
+ */
392
+ export function exitCertificateMatchesInstance(
393
+ currentMeta: Record<string, unknown> | undefined,
394
+ certificate: { spawnRequestId?: unknown } | undefined,
395
+ ): boolean {
396
+ const current = currentMeta ? stringValue(currentMeta.spawnRequestId) : undefined;
397
+ const cert = certificate ? stringValue(certificate.spawnRequestId) : undefined;
398
+ if (current && cert && current !== cert) return false;
399
+ return true;
400
+ }
401
+
366
402
  export function recordAgentExitDiagnostics(id: string, diagnostics: ManagedSessionExitDiagnostics): AgentCard | null {
367
403
  const agent = getAgent(id);
368
404
  if (!agent) return null;
405
+ // #1059 — drop a stale/duplicate exit report for a SUPERSEDED instance. The agent id
406
+ // re-registered as a fresh session (new spawnRequestId) after this report was generated;
407
+ // stamping its predecessor's certificate here would poison the LIVE successor's row (false
408
+ // offline flip + false death notification via the caller + a certificate the dead-owner reaper
409
+ // would later act on — the #1054 reopener). Returning null makes the caller skip both the status
410
+ // emit and notifyAgentOffline, exactly as for an absent row.
411
+ if (!exitCertificateMatchesInstance(isRecord(agent.meta) ? agent.meta : undefined, diagnostics)) return null;
369
412
  const now = Date.now();
370
413
  const merged = {
371
414
  ...(agent.meta ?? {}),
372
415
  lastError: diagnostics.lastError,
373
416
  lastExit: diagnostics,
374
417
  lastExitAt: diagnostics.detectedAt || now,
418
+ // #1057(1) — RELAY-clock receipt of the process-confirmed death. The orphan-token sweep
419
+ // (revokeOrphanedRuntimeTokens) compares this against last_seen (also relay clock); the
420
+ // orchestrator-clock detectedAt in lastExitAt is not comparable across hosts.
421
+ lastExitReceivedAt: now,
375
422
  };
376
423
  // #636 — do NOT clobber last_seen with `now`: it is the agent's genuine last heartbeat, and the
377
424
  // death diagnosis derives the real heartbeat gap from it (the orchestrator exit path diagnoses
@@ -505,10 +552,13 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS, lagGraceMs = 0): s
505
552
  )
506
553
  .all(cutoff) as any[];
507
554
  for (const row of rows) {
508
- // Staleness == last_seen lapsed, which is exactly the relay-restart strand: the
509
- // process may still be alive. Tag "stale" so a live runner can self-heal via an
510
- // orchestrator re-mint (#484); a genuinely-dead one never re-mints and is pruned.
511
- revokeRuntimeTokensForAgent(row.id, now, "stale");
555
+ // #1051 liveness credential validity. A lapsed heartbeat is a transient,
556
+ // recoverable observation (the relay may have stalled, or the network blipped);
557
+ // it marks status only and must NOT void a still-unexpired token. On reconnect the
558
+ // relay accepts the agent's EXISTING token and re-marks it live. The #484 restart-
559
+ // strand invariant (an orphaned prior-instance token must not stay usable) is now
560
+ // enforced on instance takeover instead — see revokeSupersededRuntimeTokens, fired
561
+ // from registerAgent when a fresh process re-registers under a new instanceId.
512
562
  closeOpenPairsForAgent(row.id, now);
513
563
  }
514
564
  return rows.map((r: any) => r.id);
@@ -582,6 +632,35 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
582
632
  return deleted ? { ok: true } : { ok: false, error: "agent not found" };
583
633
  }
584
634
 
635
+ // #1057(1) — close the orphan-token window #1051 left open. A dead runner with no replacement
636
+ // used to keep its token valid until the 24h offline prune (~720× the pre-#1051 ~2min horizon).
637
+ // This sweep revokes ("stale", re-mintable — #484) the runtime tokens of agents whose process the
638
+ // orchestrator CONFIRMED dead (meta.lastExit, the #1054 signal — never a liveness inference, so
639
+ // the #1051 decouple holds) once a full grace has passed with no heartbeat since the relay
640
+ // received the report. The two guards make a late exit report harmless to a live replacement:
641
+ // - a replacement registration replaces meta wholesale (no lastExitReceivedAt) → excluded;
642
+ // - a replacement that got the late report stamped ONTO its row keeps heartbeating, pushing
643
+ // last_seen past the receipt (heartbeat also flips it off 'offline') → excluded.
644
+ // `lagGraceMs` mirrors the stale reaper's #1048/#1055 discipline: while the relay's own event
645
+ // loop was stalled, a live claimant's heartbeats may not have been processed yet, so the window
646
+ // widens by the observed lag rather than misreading the backlog as post-mortem silence.
647
+ export function revokeOrphanedRuntimeTokens(graceMs: number = STALE_TTL_MS, lagGraceMs = 0, now = Date.now()): Array<{ agentId: string; jtis: string[] }> {
648
+ const cutoff = now - graceMs - Math.max(0, lagGraceMs);
649
+ const rows = getDb()
650
+ .query("SELECT id, meta, last_seen FROM agents WHERE status = 'offline' AND meta LIKE '%lastExitReceivedAt%' AND id NOT IN ('user', 'system')")
651
+ .all() as Array<{ id: string; meta: string | null; last_seen: number }>;
652
+ const revoked: Array<{ agentId: string; jtis: string[] }> = [];
653
+ for (const row of rows) {
654
+ const meta = parseJson<Record<string, unknown>>(row.meta ?? "{}", {});
655
+ const receivedAt = typeof meta.lastExitReceivedAt === "number" ? meta.lastExitReceivedAt : undefined;
656
+ if (receivedAt === undefined || receivedAt > cutoff) continue;
657
+ if (row.last_seen > receivedAt) continue;
658
+ const jtis = revokeProviderRuntimeTokensByJti(runtimeTokenJtisFromMeta(meta), "stale", now);
659
+ if (jtis.length > 0) revoked.push({ agentId: row.id, jtis });
660
+ }
661
+ return revoked;
662
+ }
663
+
585
664
  // #484: `reason` is stamped on each revocation so a stale-on-restart revocation
586
665
  // (recoverable via orchestrator re-mint for a still-live process) is distinguishable
587
666
  // from a hard kill. Default "agent-removed" (hard) — callers that revoke a possibly-
@@ -589,6 +668,33 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
589
668
  export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now(), reason: TokenRevokeReason = "agent-removed"): string[] {
590
669
  const row = getDb().query("SELECT meta FROM agents WHERE id = ?").get(agentId) as { meta?: string } | undefined;
591
670
  const jtis = runtimeTokenJtisFromMeta(parseJson(row?.meta ?? "{}", {}));
671
+ return revokeProviderRuntimeTokensByJti(jtis, reason, now);
672
+ }
673
+
674
+ // #1051 — the #484 restart-strand invariant, re-homed from heartbeat-lapse to instance
675
+ // takeover. Staleness (a missed heartbeat) no longer revokes anything; instead, when a
676
+ // FRESH runner process re-registers under an existing agent id — a new instanceId, which
677
+ // bumps the agent epoch — the PRIOR instance's runtime tokens are revoked "stale" here,
678
+ // atomically with the bump (fired from inside upsertAgent). "stale" (not a hard kill)
679
+ // keeps them re-mintable via the orchestrator (#484), so a live process that merely
680
+ // rotated instances still self-heals, while an orphaned prior-instance token can no
681
+ // longer authenticate a request directly. A same-instance reconnect (relay-restart / lag
682
+ // recovery) keeps its instanceId, does not bump the epoch, and so never reaches this
683
+ // path — its token stays valid untouched.
684
+ // #1057(3) — `keepJtis` must be SERVER-verified (the jti of the token that authenticated the
685
+ // registration, resolved to this very agent — see registerAgent). It is never derived from the
686
+ // incoming client meta: a takeover caller could otherwise list the prior jti in meta.auth.jtis
687
+ // and spare it from revocation. The incoming instance's own token needs no keep entry — only
688
+ // PRIOR jtis are ever revoked, so a fresh token is structurally never collateral.
689
+ export function revokeSupersededRuntimeTokens(prior: AgentCard, keepJtis: string[] = [], now = Date.now()): string[] {
690
+ const priorJtis = runtimeTokenJtisFromMeta((prior.meta ?? {}) as Record<string, unknown>);
691
+ if (priorJtis.length === 0) return [];
692
+ const keep = new Set(keepJtis);
693
+ const superseded = priorJtis.filter((jti) => !keep.has(jti));
694
+ return revokeProviderRuntimeTokensByJti(superseded, "stale", now);
695
+ }
696
+
697
+ function revokeProviderRuntimeTokensByJti(jtis: string[], reason: TokenRevokeReason, now: number): string[] {
592
698
  if (jtis.length === 0) return [];
593
699
  const revokedAt = Math.floor(now / 1000);
594
700
  const placeholders = jtis.map(() => "?").join(",");
@@ -523,6 +523,19 @@ export function listPendingReplyObligations(agentId: string, limit = 20): ReplyO
523
523
  *
524
524
  * Returns the inbound message id this outbound answers, or null when nothing should be auto-linked.
525
525
  */
526
+ /**
527
+ * #976 — the most recent message `fromId` addressed to `recipientId` (direct target OR resolved
528
+ * recipient). Used to anchor a suppressed standalone self→parent ack onto the conversation it
529
+ * belongs to, so a bare ack collapses onto the inbound it answers — mirroring the reply-path
530
+ * collapse (which returns the parent message being acked). Returns null when no such inbound exists.
531
+ */
532
+ export function latestInboundMessage(recipientId: string, fromId: string): Message | null {
533
+ const sql = `${MSG_SELECT} WHERE m.from_agent = ? AND (m.to_target = ? OR m.resolved_to_agent = ?) ORDER BY m.id DESC LIMIT 1`;
534
+ const rows = timedQuery("latestInboundMessage", () =>
535
+ getDb().query(sql).all(fromId, recipientId, recipientId) as any[]);
536
+ return rows.length ? rowToMessage(rows[0]) : null;
537
+ }
538
+
526
539
  export function channelObligationAnsweredBy(
527
540
  agentId: string,
528
541
  channelAgentId: string,
@@ -624,6 +637,43 @@ export function readSpawnReportVerbosity(spawnRequestId: string | undefined): Re
624
637
  }
625
638
  }
626
639
 
640
+ /**
641
+ * #1062 — the RAW caller/parent brief a spawner supplied, read from the persisted `agent.spawn`
642
+ * command params by spawnRequestId. Mirrors {@link readSpawnReportVerbosity}: the dashboard
643
+ * (non-policy) restart path recovers this and re-threads it as `callerSystemPromptAppend` so a
644
+ * restarted spawned child keeps its parent's task brief while the rest of the projection
645
+ * (project/profile/scoped memory) is re-composed fresh. Returns undefined when absent/unparseable
646
+ * (pre-#1062 spawns never persisted it → the restart simply re-projects without a caller brief).
647
+ *
648
+ * #1067 — recovers across ANY number of dashboard restarts, not just one. #1065 rotates the
649
+ * spawnRequestId on every restart and never writes an agent.spawn row for the successor, so keying
650
+ * only on the original agent.spawn row lost the brief from the 2nd restart onward (the predecessor
651
+ * id was then the fresh id, which has no spawn row). The dashboard restart path re-persists the raw
652
+ * brief under each fresh id on the restart command's `restartSpawn.callerSystemPromptAppend`
653
+ * (agent-sessions.ts), so this lookup also matches an `agent.restart` row whose rotated id equals
654
+ * the query id — every generation has its own recoverable copy and the brief follows the agent.
655
+ */
656
+ export function readSpawnCallerSystemPromptAppend(spawnRequestId: string | undefined): string | undefined {
657
+ if (!spawnRequestId) return undefined;
658
+ try {
659
+ const row = getDb().query(`
660
+ SELECT coalesce(
661
+ json_extract(params, '$.callerSystemPromptAppend'),
662
+ json_extract(params, '$.restartSpawn.callerSystemPromptAppend')
663
+ ) AS append
664
+ FROM commands
665
+ WHERE (type = 'agent.spawn' AND correlation_id = ?)
666
+ OR (type = 'agent.restart' AND json_extract(params, '$.restartSpawn.spawnRequestId') = ?)
667
+ ORDER BY created_at DESC
668
+ LIMIT 1
669
+ `).get(spawnRequestId, spawnRequestId) as { append: string | null } | undefined;
670
+ const append = row?.append;
671
+ return typeof append === "string" && append.trim() ? append : undefined;
672
+ } catch {
673
+ return undefined;
674
+ }
675
+ }
676
+
627
677
  export function markRead(messageId: number, agentId: string): boolean {
628
678
  const exists = getDb().query("SELECT 1 FROM messages WHERE id = ?").get(messageId);
629
679
  if (!exists) return false;