agent-relay-server 0.121.0 → 0.121.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 (126) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -4
  3. package/public/assets/{MessageBubble-BaPGFJxq.js → MessageBubble-CLqxCG9b.js} +2 -2
  4. package/public/assets/{MessageBubble-BaPGFJxq.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BXCVfmln.js → PlanGraphPanel-BkkToFEb.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BXCVfmln.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
  7. package/public/assets/{activity-D_shwAZt.js → activity-D-ocGQGs.js} +2 -2
  8. package/public/assets/{activity-D_shwAZt.js.map → activity-D-ocGQGs.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BrfVz5IV.js → agent-profiles-CRPgYTyb.js} +2 -2
  10. package/public/assets/{agent-profiles-BrfVz5IV.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
  11. package/public/assets/{agents-B5w-Z9Ic.js → agents-Chw5E_2q.js} +2 -2
  12. package/public/assets/{agents-B5w-Z9Ic.js.map → agents-Chw5E_2q.js.map} +1 -1
  13. package/public/assets/{analytics-Bm3D12UN.js → analytics-BmNDG0hR.js} +2 -2
  14. package/public/assets/{analytics-Bm3D12UN.js.map → analytics-BmNDG0hR.js.map} +1 -1
  15. package/public/assets/{automation-DOTL8BFs.js → automation-D7g90kTv.js} +2 -2
  16. package/public/assets/{automation-DOTL8BFs.js.map → automation-D7g90kTv.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-BlqeJZ5-.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
  18. package/public/assets/{branch-state-badge-BlqeJZ5-.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
  19. package/public/assets/{channels-BoiKfK6Q.js → channels-CLwPeEPi.js} +2 -2
  20. package/public/assets/{channels-BoiKfK6Q.js.map → channels-CLwPeEPi.js.map} +1 -1
  21. package/public/assets/{chat-PeU3iOaa.js → chat-Cgj7VKzc.js} +2 -2
  22. package/public/assets/{chat-PeU3iOaa.js.map → chat-Cgj7VKzc.js.map} +1 -1
  23. package/public/assets/{connectors-BvV8Hee2.js → connectors-Br6fiTny.js} +2 -2
  24. package/public/assets/{connectors-BvV8Hee2.js.map → connectors-Br6fiTny.js.map} +1 -1
  25. package/public/assets/{formatted-body-C1FgzlT1.js → formatted-body-CmkuD23M.js} +3 -3
  26. package/public/assets/{formatted-body-C1FgzlT1.js.map → formatted-body-CmkuD23M.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-BTpU1xH4.js → formatted-body-impl-DbdFmbwW.js} +2 -2
  28. package/public/assets/{formatted-body-impl-BTpU1xH4.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
  29. package/public/assets/{index-DZEahmyc.js → index-D9OogaB5.js} +6 -6
  30. package/public/assets/{index-DZEahmyc.js.map → index-D9OogaB5.js.map} +1 -1
  31. package/public/assets/{insights-BqmgSpQm.js → insights-qvaPqWCV.js} +2 -2
  32. package/public/assets/{insights-BqmgSpQm.js.map → insights-qvaPqWCV.js.map} +1 -1
  33. package/public/assets/{integrations-CuT92zQi.js → integrations-DYEGSqq_.js} +2 -2
  34. package/public/assets/{integrations-CuT92zQi.js.map → integrations-DYEGSqq_.js.map} +1 -1
  35. package/public/assets/{maintenance-lM7FeXVC.js → maintenance-C0HIEB-J.js} +2 -2
  36. package/public/assets/{maintenance-lM7FeXVC.js.map → maintenance-C0HIEB-J.js.map} +1 -1
  37. package/public/assets/{managed-agents-DaPNv3yY.js → managed-agents-DdS7aI38.js} +2 -2
  38. package/public/assets/{managed-agents-DaPNv3yY.js.map → managed-agents-DdS7aI38.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-Wia3Ho1s.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-Wia3Ho1s.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
  41. package/public/assets/{memory-B1v_zD-M.js → memory-CSwx7bz8.js} +2 -2
  42. package/public/assets/{memory-B1v_zD-M.js.map → memory-CSwx7bz8.js.map} +1 -1
  43. package/public/assets/{messages-BNOx3yK6.js → messages-Be9CmvDv.js} +2 -2
  44. package/public/assets/{messages-BNOx3yK6.js.map → messages-Be9CmvDv.js.map} +1 -1
  45. package/public/assets/{orchestrators-DWFy4asl.js → orchestrators-BgtvvHvo.js} +2 -2
  46. package/public/assets/{orchestrators-DWFy4asl.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
  47. package/public/assets/{overview-C_12cFPM.js → overview-BvhbPWtF.js} +2 -2
  48. package/public/assets/{overview-C_12cFPM.js.map → overview-BvhbPWtF.js.map} +1 -1
  49. package/public/assets/{pairs-DIc7gX83.js → pairs-CpXSOMx0.js} +2 -2
  50. package/public/assets/{pairs-DIc7gX83.js.map → pairs-CpXSOMx0.js.map} +1 -1
  51. package/public/assets/{projects-DPjvJUzT.js → projects-DdPNPFJI.js} +2 -2
  52. package/public/assets/{projects-DPjvJUzT.js.map → projects-DdPNPFJI.js.map} +1 -1
  53. package/public/assets/{prompt-settings-CtumNgG8.js → prompt-settings-BpZse7-X.js} +2 -2
  54. package/public/assets/{prompt-settings-CtumNgG8.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
  55. package/public/assets/{registry-DzUhgMq9.js → registry-pb1gTZEg.js} +2 -2
  56. package/public/assets/{registry-DzUhgMq9.js.map → registry-pb1gTZEg.js.map} +1 -1
  57. package/public/assets/{security-BJ3_u0uY.js → security-BgcX1n9D.js} +2 -2
  58. package/public/assets/{security-BJ3_u0uY.js.map → security-BgcX1n9D.js.map} +1 -1
  59. package/public/assets/{select-AWvlctyQ.js → select-DZPVpKPv.js} +2 -2
  60. package/public/assets/{select-AWvlctyQ.js.map → select-DZPVpKPv.js.map} +1 -1
  61. package/public/assets/{settings-5ofqi-bN.js → settings-hd5kzdRB.js} +2 -2
  62. package/public/assets/{settings-5ofqi-bN.js.map → settings-hd5kzdRB.js.map} +1 -1
  63. package/public/assets/store-DKTLubBT.js +9 -0
  64. package/public/assets/store-DKTLubBT.js.map +1 -0
  65. package/public/assets/{tasks-BEE9bBf7.js → tasks-C5d2LU5E.js} +2 -2
  66. package/public/assets/{tasks-BEE9bBf7.js.map → tasks-C5d2LU5E.js.map} +1 -1
  67. package/public/assets/{teams-C27H_Gwo.js → teams-B8f2pGJe.js} +2 -2
  68. package/public/assets/{teams-C27H_Gwo.js.map → teams-B8f2pGJe.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-WD2TboA7.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-WD2TboA7.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-CtmPK2hK.js → work-queue-9imc7aNK.js} +2 -2
  73. package/public/assets/{work-queue-CtmPK2hK.js.map → work-queue-9imc7aNK.js.map} +1 -1
  74. package/public/assets/{workspaces-B26EKv_H.js → workspaces-CLYc3yF3.js} +2 -2
  75. package/public/assets/{workspaces-B26EKv_H.js.map → workspaces-CLYc3yF3.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
  79. package/src/agent-issue-repo.ts +10 -3
  80. package/src/agent-lifecycle-events.ts +23 -1
  81. package/src/agent-ref.ts +18 -3
  82. package/src/bus-outbox.ts +44 -1
  83. package/src/bus.ts +28 -5
  84. package/src/channel-target.ts +4 -0
  85. package/src/cli/steward.ts +15 -14
  86. package/src/commands-db.ts +63 -9
  87. package/src/config.ts +50 -0
  88. package/src/db/claim-sql.ts +16 -0
  89. package/src/db/continuations.ts +44 -3
  90. package/src/db/message-reads.ts +34 -4
  91. package/src/db/messages.ts +128 -4
  92. package/src/db/pairs.ts +1 -0
  93. package/src/db/schema.ts +6 -0
  94. package/src/gate-resolver.ts +124 -0
  95. package/src/lifecycle-manager.ts +41 -10
  96. package/src/lifecycle-signal-registry.ts +87 -0
  97. package/src/maintenance/constants.ts +5 -1
  98. package/src/maintenance/jobs.ts +28 -2
  99. package/src/maintenance/workspaces.ts +75 -44
  100. package/src/managed-policy-escalation.ts +68 -3
  101. package/src/mcp/tools-spawn.ts +16 -6
  102. package/src/mcp-plan-tools.ts +2 -3
  103. package/src/notification-settings.ts +11 -0
  104. package/src/notification-types.ts +4 -0
  105. package/src/operator-alarm.ts +37 -0
  106. package/src/routes/agents.ts +4 -0
  107. package/src/routes/orchestrator-proxy.ts +13 -1
  108. package/src/routes/orchestrator.ts +4 -0
  109. package/src/security.ts +20 -0
  110. package/src/self-resume.ts +185 -21
  111. package/src/server.ts +2 -0
  112. package/src/services/plan-graph.ts +42 -12
  113. package/src/services/register-agent.ts +5 -1
  114. package/src/services/send-message.ts +36 -0
  115. package/src/services/spawn-agent.ts +21 -2
  116. package/src/services/task-semantic-events.ts +17 -1
  117. package/src/spawn-command.ts +3 -0
  118. package/src/spawn-targets.ts +26 -1
  119. package/src/sse.ts +13 -5
  120. package/src/workspace-auto-merge.ts +90 -34
  121. package/src/workspace-escalation.ts +52 -3
  122. package/src/workspace-human-escalation.ts +68 -0
  123. package/src/workspace-orphans.ts +11 -6
  124. package/src/workspace-phase.ts +22 -1
  125. package/public/assets/store-D5et8685.js +0 -9
  126. package/public/assets/store-D5et8685.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { emitRelayEvent } from "./events";
2
2
  import { routeNotification } from "./notification-router";
3
- import { getAgent, markAgentReadyNotified, recordCrashReport, recordTerminalEvent } from "./db";
3
+ import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSignalKey } from "./lifecycle-signal-registry";
4
+ import { deliverableTaskIdsBySpawnRequestId, getAgent, markAgentReadyNotified, recordCrashReport, recordTerminalEvent } from "./db";
4
5
  import { crashReportFor } from "./agent-death-diagnosis";
5
6
  import { renderTemplate } from "./prompt-resolver";
6
7
  import type { AgentCard, AgentDeathDiagnosis } from "./types";
@@ -82,6 +83,26 @@ function spawnRequestIdOf(child: AgentCard): string | undefined {
82
83
  return typeof s === "string" ? s : undefined;
83
84
  }
84
85
 
86
+ // #1032b — record that a first-class lifecycle event (ready/exit) told the parent about this child,
87
+ // so a redundant "Task status pulse" for the same child/task is suppressed instead of double-waking.
88
+ // #1038 — agent.ready means the child is coming up to work its task, so it is state-keyed to
89
+ // `working` (same as a report-up): it supersedes a redundant `working` pulse only, never a later
90
+ // distinct-state transition for the same task within the TTL.
91
+ function stampChildLifecycleSignal(parent: string, child: AgentCard): void {
92
+ stampLifecycleSignal(parent, childSignalKey(child.id));
93
+ const direct = child.meta?.taskId;
94
+ const directNum = typeof direct === "string" ? Number(direct) : direct;
95
+ if (typeof directNum === "number" && Number.isSafeInteger(directNum) && directNum > 0) {
96
+ stampLifecycleSignal(parent, taskSignalKey(directNum, LIFECYCLE_STATE_WORKING));
97
+ }
98
+ const spawnRequestId = spawnRequestIdOf(child);
99
+ if (spawnRequestId) {
100
+ for (const taskId of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) {
101
+ stampLifecycleSignal(parent, taskSignalKey(taskId, LIFECYCLE_STATE_WORKING));
102
+ }
103
+ }
104
+ }
105
+
85
106
  /**
86
107
  * A spawned child became genuinely ready (registered AND past onboarding — ready+idle, not merely
87
108
  * process-started). Emits the durable `agent.ready` event and, unless suppressed, pushes the
@@ -152,6 +173,7 @@ export function notifyAgentReady(childId: string): void {
152
173
  replyExpected: false,
153
174
  },
154
175
  });
176
+ stampChildLifecycleSignal(parent, child);
155
177
  }
156
178
 
157
179
  /**
package/src/agent-ref.ts CHANGED
@@ -99,9 +99,19 @@ function idMatchesSegment(id: string, ref: string): boolean {
99
99
  export function matchAgents(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): AgentCard[] {
100
100
  const trimmed = ref.trim();
101
101
  if (!trimmed) return [];
102
- const pool = opts.excludeId ? agents.filter((a) => a.id !== opts.excludeId) : agents;
103
102
 
104
103
  const selector = parseSelector(trimmed);
104
+ // An exact id reference — a bare full id, or an `id:` selector — names ONE agent unambiguously,
105
+ // so it is honored even when it targets the excluded (self) agent: addressing your own full id
106
+ // is a deliberate self/peer send, NOT the #290 bare-ref self-loop that `excludeId` guards
107
+ // against. Without this exemption an exact self-id is refused by the matcher yet still offered by
108
+ // the "did you mean" hint — the resolver suggesting the very id it just rejected (#1035).
109
+ const exactId = selector ? (selector.field === "id" ? selector.value : null) : trimmed;
110
+ const selfExact = opts.excludeId && exactId
111
+ ? agents.find((a) => a.id === opts.excludeId && a.id === exactId)
112
+ : undefined;
113
+ const pool = opts.excludeId && !selfExact ? agents.filter((a) => a.id !== opts.excludeId) : agents;
114
+
105
115
  if (selector) return selector.value ? pool.filter((a) => matchesSelector(a, selector)) : [];
106
116
 
107
117
  const exact = pool.filter((a) => matchesExact(a, trimmed));
@@ -207,9 +217,14 @@ function ambiguousMessage(ref: string, candidates: AgentCard[]): string {
207
217
  return `agent reference "${ref}" is ambiguous — matches ${candidates.length}: ${list}${more}. Use a full id, label, or a fan-out selector (tag:/cap:/label:/team:).`;
208
218
  }
209
219
 
210
- function notFoundMessage(ref: string, agents: AgentCard[]): string {
220
+ function notFoundMessage(ref: string, agents: AgentCard[], opts: ResolveOptions = {}): string {
211
221
  const needle = ref.toLowerCase();
212
222
  const near = agents
223
+ // Canonicalize the suggestion pool to the SAME identity the matcher used: never offer the
224
+ // excluded (self) agent, and never suggest an id/label that EQUALS the ref — if it were an
225
+ // exact match it would have resolved, so surfacing it means offering the id we just rejected
226
+ // (#1035, the "Did you mean: <the id you typed>?" self-suggestion).
227
+ .filter((a) => a.id !== opts.excludeId && a.id !== ref && a.label !== ref)
213
228
  .filter((a) => isPseudoAgent(a) && (a.id.toLowerCase().includes(needle) || a.label?.toLowerCase().includes(needle)))
214
229
  .slice(0, 3)
215
230
  .map(describeAgent);
@@ -261,7 +276,7 @@ export function planSend(to: string, agents: AgentCard[], opts: ResolveOptions =
261
276
  if (offline.length > 1) {
262
277
  return { kind: "ambiguous", message: ambiguousMessage(target, offline), candidates: offline };
263
278
  }
264
- return { kind: "not_found", message: notFoundMessage(target, agents) };
279
+ return { kind: "not_found", message: notFoundMessage(target, agents, opts) };
265
280
  }
266
281
 
267
282
  /**
package/src/bus-outbox.ts CHANGED
@@ -45,6 +45,24 @@ export function appendEvent(type: string, source: string, data: unknown, subject
45
45
  }
46
46
 
47
47
  export function replayEvents(since: number, limit = 500): BusEvent[] {
48
+ return replayEventsSlice(since, limit).events;
49
+ }
50
+
51
+ export interface ReplaySlice {
52
+ events: BusEvent[];
53
+ /** The highest real outbox row seq in this slice (or `since` when empty). Resume from here to
54
+ * continue past the slice — driven by the raw row seq, not the (possibly batch-expanded) payload
55
+ * seq, so a legacy `message.batch` row can't be half-skipped. */
56
+ lastSeq: number;
57
+ /** More retained events exist beyond `lastSeq` — the caller MUST fetch the next slice, or the
58
+ * backlog tail between this slice and the outbox head is silently dropped (#1022 F1). */
59
+ truncated: boolean;
60
+ }
61
+
62
+ /** One page of the resume backlog plus whether more remains. `replayEvents`' 500-row cap used to
63
+ * be a silent ceiling: a resume returning exactly `limit` rows dropped everything between the last
64
+ * replayed event and the outbox head. Callers loop on `truncated`, resuming from `lastSeq`. */
65
+ export function replayEventsSlice(since: number, limit = 500): ReplaySlice {
48
66
  const rows = getDb().query(`
49
67
  SELECT seq, event_type, source, subject, data, timestamp
50
68
  FROM bus_outbox
@@ -52,7 +70,10 @@ export function replayEvents(since: number, limit = 500): BusEvent[] {
52
70
  ORDER BY seq ASC
53
71
  LIMIT ?
54
72
  `).all(since, limit) as OutboxRow[];
55
- return rows.map(rowToEvent);
73
+ const events = rows.map(rowToEvent);
74
+ const lastSeq = rows.length > 0 ? rows[rows.length - 1]!.seq : since;
75
+ const truncated = rows.length === limit && getOutboxCursor() > lastSeq;
76
+ return { events, lastSeq, truncated };
56
77
  }
57
78
 
58
79
  export function pruneOutbox(retentionMs = 60 * 60 * 1000): number {
@@ -71,6 +92,28 @@ export function getOldestOutboxCursor(): number {
71
92
  return row.cursor ?? 0;
72
93
  }
73
94
 
95
+ /** The highest seq ever assigned to the outbox — read from `sqlite_sequence`, so it survives the
96
+ * retention prune that empties the table. Needed for gap detection: once the outbox is fully
97
+ * pruned `getOldestOutboxCursor()` returns 0, which read as "no gap" and let a reconnecting client
98
+ * believe it was caught up while its post-cursor events had aged out (#1022 F2). */
99
+ export function getOutboxHighWater(): number {
100
+ const row = getDb().query("SELECT seq FROM sqlite_sequence WHERE name = 'bus_outbox'").get() as { seq: number } | undefined;
101
+ return row?.seq ?? 0;
102
+ }
103
+
104
+ /** Whether a resume from `since` has lost events to the retention prune. Two cases:
105
+ * - outbox non-empty: the oldest retained event is newer than `since + 1` → the events between
106
+ * were pruned.
107
+ * - outbox fully pruned (oldest = 0): a gap exists iff events after `since` were ever assigned
108
+ * (`since` is behind the high-water mark) and have since aged out.
109
+ * A client that hits a gap must reconcile from scratch rather than resume from a stale cursor. */
110
+ export function hasReplayGap(since: number): boolean {
111
+ if (since <= 0) return false;
112
+ const oldest = getOldestOutboxCursor();
113
+ if (oldest > 0) return oldest > since + 1;
114
+ return since < getOutboxHighWater();
115
+ }
116
+
74
117
  function rowToEvent(row: OutboxRow): BusEvent {
75
118
  return {
76
119
  seq: row.seq,
package/src/bus.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
2
  import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus, validateAgentSession } from "./db";
3
- import { getOldestOutboxCursor, getOutboxCursor, replayEvents, type BusEvent } from "./bus-outbox";
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";
6
6
  import { getCommand, updateCommand } from "./commands-db";
@@ -15,6 +15,7 @@ import { agentProviderTimelineEvent, auditRunnerTimelineEvent } from "./provider
15
15
  import { ServiceAuthError } from "./services/errors";
16
16
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
17
17
  import { flushQueuedDirectMessages } from "./services/managed-running";
18
+ import { flushIdleLineageReport } from "./services/send-message";
18
19
  import { isAgentUnavailable } from "./db/agent-predicates";
19
20
  import { applyCommandToRecipe } from "./recipe-runner";
20
21
  import { enqueueContinuationInjectionAfterSelfResume } from "./self-resume";
@@ -105,6 +106,10 @@ export function busHandleClose(ws: BusWebSocket): void {
105
106
  if (!conn.transportReconnect) {
106
107
  setStatus(conn.agentId, "stale", { instanceId: conn.instanceId, epoch: conn.epoch });
107
108
  emitAgentStatusEvent(conn.agentId);
109
+ // #1040 — a WS close is a busy→exit edge (the runner crashed/dropped before its stop-hook
110
+ // status frame). Flush any held turn-final report-up so it still reaches the spawner instead
111
+ // of dying with the socket. No-op when nothing was deferred.
112
+ flushIdleLineageReport(conn.agentId);
108
113
  }
109
114
  }
110
115
  }
@@ -123,6 +128,9 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs()): { agentIds: s
123
128
  revokeRuntimeTokensForAgent(row.id, Date.now(), "stale");
124
129
  setStatus(row.id, "offline");
125
130
  emitAgentStatusEvent(row.id);
131
+ // #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
132
+ // clean idle transition. Flush any held turn-final report-up before the agent ages out.
133
+ flushIdleLineageReport(row.id);
126
134
  const tasks = orphanTasksForAgent(row.id);
127
135
  for (const task of tasks) {
128
136
  orphanedTasks.push(task);
@@ -156,7 +164,14 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
156
164
  const wasUnavailable = current ? isAgentUnavailable(current) : false;
157
165
  const runtime = runtimeFromMeta(frame.payload.meta);
158
166
  heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
159
- if (frame.payload.status) setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
167
+ if (frame.payload.status) {
168
+ setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
169
+ // #1040 — a heartbeat frame can carry the busy→idle transition instead of a `status`
170
+ // frame. Flush the held report-up on THIS edge too, else `before.status` is already
171
+ // non-busy at the next status frame → the flush never fires → the child deadlocks idle
172
+ // with its verdict held and the coordinator waits forever.
173
+ if (current?.status === "busy" && frame.payload.status !== "busy") flushIdleLineageReport(conn.agentId);
174
+ }
160
175
  emitAgentStatusEvent(conn.agentId);
161
176
  // #416 — flush queued messages if the agent transitioned from unavailable to available.
162
177
  if (wasUnavailable) {
@@ -200,6 +215,9 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
200
215
  emitAgentStatusEvent(conn.agentId);
201
216
  // #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
202
217
  probeWorkspaceOnTurnEnd(before, after);
218
+ // #1037 — the same busy→idle turn-end edge flushes a quiet child's deferred turn-final
219
+ // report-up to its spawner (held back while the child was mid-work).
220
+ if (before?.status === "busy" && after && after.status !== "busy") flushIdleLineageReport(conn.agentId);
203
221
  // #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
204
222
  // marks the offline edge with `terminalProviderExit`; the owning service turns that into a
205
223
  // terminal event + #636 diagnosis (and clears the hold on a genuine resume).
@@ -466,18 +484,23 @@ function hasRunnerLifecycleCapabilities(capabilities: string[], meta: Record<str
466
484
  }
467
485
 
468
486
  function handleResume(ws: BusWebSocket, frameId: string, since: number): void {
469
- const oldest = getOldestOutboxCursor();
470
- if (since > 0 && oldest > 0 && oldest > since + 1) {
487
+ if (hasReplayGap(since)) {
471
488
  sendError(ws, frameId, "REPLAY_GAP", "requested cursor is older than retained bus outbox events");
472
489
  return;
473
490
  }
474
- const events = replayEvents(since).flatMap(outboxToPayloads);
491
+ // One page only. The replay cap silently dropped the backlog tail between item 500 and the head
492
+ // (#1022 F1); we now report `truncated` + the raw `nextSince` row seq so the client loops resume
493
+ // until it has drained the whole backlog rather than lurching to the live head on the next event.
494
+ const slice = replayEventsSlice(since);
495
+ const events = slice.events.flatMap(outboxToPayloads);
475
496
  send(ws, {
476
497
  type: "resumed",
477
498
  payload: {
478
499
  events,
479
500
  fromSeq: events[0]?.seq ?? since,
480
501
  toSeq: events.at(-1)?.seq ?? since,
502
+ nextSince: slice.lastSeq,
503
+ truncated: slice.truncated,
481
504
  },
482
505
  });
483
506
  }
@@ -19,6 +19,10 @@ export function parseChannelRouteTarget(target: string): ChannelRouteTarget {
19
19
  if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
20
20
  if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
21
21
  if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
22
+ // `capability:` is an accepted alias of `cap:` at send time (planSend/FANOUT_PREFIXES + the SDK
23
+ // matcher both honor it). Parse it here too so channel routing, broadcast scoping and the
24
+ // claimable gate don't silently treat a `capability:` message as a bare agent id (#1022 F3).
25
+ if (target.startsWith("capability:")) return { type: "capability", id: target.slice("capability:".length) };
22
26
  if (target === "broadcast") return { type: "broadcast" };
23
27
  if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
24
28
  return { type: "agent", id: target };
@@ -1,6 +1,7 @@
1
1
  // Steward commands — auto-split from cli.ts (#294). Briefing rail for the steward
2
2
  // (#208): the attention queue, per-workspace diagnostics, and check suggestions.
3
3
  import { apiRequest } from "./_shared";
4
+ import { resolveStewardChecks } from "../gate-resolver";
4
5
 
5
6
  // Steward briefing commands (#208): queue of workspaces needing attention, a
6
7
  // per-workspace diagnostics inspection, and a check-command suggestion.
@@ -39,21 +40,21 @@ export async function handleStewardCommand(args: string[]): Promise<void> {
39
40
  return;
40
41
  }
41
42
 
42
- // checks: suggest validation commands from the workspace's changed files.
43
+ // checks: suggest validation commands from the workspace's changed files. Derive
44
+ // the gate from the repo (#1036) — hardcoding `bun test` produced bogus failures in
45
+ // non-bun repos. Resolve the worktree to read its package.json / land-gates pin:
46
+ // an explicit `--repo` wins, else the workspace's own worktreePath/repoRoot.
43
47
  const diff = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diff?patch=0`) as any;
44
48
  const files: string[] = Array.isArray(diff?.files) ? diff.files.map((f: any) => f.path) : [];
45
- const checks = suggestStewardChecks(files);
49
+ let repoRoot = repo;
50
+ if (!repoRoot) {
51
+ try {
52
+ const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`) as any;
53
+ repoRoot = ws?.worktreePath ?? ws?.repoRoot;
54
+ } catch {
55
+ // Best-effort: without a resolvable path the resolver falls back to its default.
56
+ }
57
+ }
58
+ const checks = resolveStewardChecks(files, repoRoot);
46
59
  console.log(JSON.stringify({ workspaceId: id, changedFiles: files.length, checks }, null, 2));
47
60
  }
48
-
49
- // Heuristic check suggestions from changed file paths. Repo-agnostic defaults a
50
- // steward can refine; cheaper than re-deriving from project docs every run.
51
- function suggestStewardChecks(files: string[]): Array<{ command: string; reason: string }> {
52
- const checks: Array<{ command: string; reason: string }> = [];
53
- const has = (re: RegExp) => files.some((f) => re.test(f));
54
- if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
55
- if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
56
- else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
57
- if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
58
- return checks;
59
- }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { STALE_TTL_MS } from "./config";
2
3
  import { getAgent, getDb, getTerminalEvent, recordTerminalEvent } from "./db";
3
4
  import { stringValue } from "agent-relay-sdk";
4
5
  import type { Command, CommandStatus, CreateCommandInput, UpdateCommandInput } from "./types";
@@ -63,6 +64,14 @@ function isTerminalStatus(status: CommandStatus): boolean {
63
64
  function isLegitimateTerminalCorrection(type: string, from: CommandStatus, to: CommandStatus): boolean {
64
65
  if (type === "agent.spawn" && from === "succeeded" && to === "failed") return true;
65
66
  if (type === "orchestrator.upgrade" && from === "failed" && to === "succeeded") return true;
67
+ // #1025 A7 — a lost merge RESULT: the orchestrator lands the branch (base pushed,
68
+ // worktree recycled onto `<branch>-N`) but dies before POSTing, so the command ages to
69
+ // `timed_out`. When it finally reports the real `succeeded` result, that late correction
70
+ // must be applied — otherwise setWorkspaceBranch never runs, the row keeps the old
71
+ // (deleted) branch, the worktree sits on `-N`, and every later preview reports `missing`
72
+ // → the lane degrades into a silent previewUnavailable strand (A5). The merge already
73
+ // happened, so there is no double-land hazard: applying the result only reconciles state.
74
+ if (type === "workspace.merge" && from === "timed_out" && to === "succeeded") return true;
66
75
  return false;
67
76
  }
68
77
 
@@ -162,16 +171,34 @@ export function findLatestCommandByCorrelation(type: string, correlationId: stri
162
171
  return row ? rowToCommand(row) : null;
163
172
  }
164
173
 
165
- export function countInFlightSpawnCommands(parentId: string): number {
174
+ // #1034 count a parent's still-in-flight spawn commands, EXCLUDING any whose child has
175
+ // already registered as a live agent. Spawn commands settle to a terminal status
176
+ // asynchronously (the orchestrator reports back AFTER the child registers), so between
177
+ // registration and settlement a child is otherwise double-counted: once by
178
+ // `countLiveSpawnedAgents` (ready=1) and once here (command still running). Combined into
179
+ // `occupied = live + inFlight`, that transient over-count could push a parent at its quota
180
+ // boundary over the limit and spuriously reject a concurrent spawn — which then "succeeded
181
+ // seconds later" once the command settled. The `NOT EXISTS` dedup mirrors the live-child
182
+ // liveness predicate in `countLiveSpawnedAgents` (and the display-side dedup already in
183
+ // relay_my_agents) so each child counts exactly once.
184
+ export function countInFlightSpawnCommands(parentId: string, now: number = Date.now()): number {
166
185
  const row = getDb()
167
186
  .query(
168
187
  `SELECT count(*) AS n
169
- FROM commands
170
- WHERE type = 'agent.spawn'
171
- AND status IN (${SPAWN_IN_FLIGHT_STATUSES.map(() => "?").join(",")})
172
- AND json_extract(params, '$.spawnedBy') = ?`,
188
+ FROM commands c
189
+ WHERE c.type = 'agent.spawn'
190
+ AND c.status IN (${SPAWN_IN_FLIGHT_STATUSES.map(() => "?").join(",")})
191
+ AND json_extract(c.params, '$.spawnedBy') = ?
192
+ AND NOT EXISTS (
193
+ SELECT 1 FROM agents a
194
+ WHERE a.spawned_by = ?
195
+ AND a.status NOT IN ('offline', 'stale')
196
+ AND a.ready = 1
197
+ AND a.last_seen > ?
198
+ AND json_extract(a.meta, '$.spawnRequestId') = coalesce(json_extract(c.params, '$.spawnRequestId'), c.correlation_id)
199
+ )`,
173
200
  )
174
- .get(...SPAWN_IN_FLIGHT_STATUSES, parentId) as { n: number };
201
+ .get(...SPAWN_IN_FLIGHT_STATUSES, parentId, parentId, now - STALE_TTL_MS) as { n: number };
175
202
  return row.n;
176
203
  }
177
204
 
@@ -244,11 +271,38 @@ export function deleteCommand(id: string): boolean {
244
271
  return true;
245
272
  }
246
273
 
247
- export function expireCommands(now: number = Date.now()): Command[] {
248
- const rows = getDb()
274
+ // #1025 A6 sweep both claimed/active leases past their deadline AND pending commands
275
+ // whose target orchestrator is permanently gone. Pending commands are normally left for a
276
+ // busy host to poll (#930/#953), but a `workspace.merge` (or `agent.spawn`) whose target
277
+ // host was decommissioned / re-registered under a new agent id will NEVER poll again — the
278
+ // command lingers forever, `recoverAgedInFlightWorkspaces` files the workspace under
279
+ // `stillActive` indefinitely, and the parked worker waits on a `landed-success` that can't
280
+ // fire. The `isTargetGone` predicate (supplied by the caller, which knows agent liveness)
281
+ // gates the pending sweep so a merely-busy host is untouched; a gone target's expired
282
+ // pending command is timed out, then settled → the workspace returns to review_requested
283
+ // and re-schedules, and the spawn parent is told. Opt-in: callers without the predicate
284
+ // keep the historical claimed/active-only behavior.
285
+ export function expireCommands(
286
+ now: number = Date.now(),
287
+ opts: { isTargetGone?: (command: Command) => boolean } = {},
288
+ ): Command[] {
289
+ const activeRows = getDb()
249
290
  .query(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status IN (${CLAIMED_ACTIVE_STATUSES.map(() => "?").join(",")})`)
250
291
  .all(now, ...CLAIMED_ACTIVE_STATUSES) as CommandRow[];
251
- for (const row of rows) updateCommand(row.id, { status: "timed_out", error: "command timed out" });
292
+ let pendingRows: CommandRow[] = [];
293
+ if (opts.isTargetGone) {
294
+ const candidates = getDb()
295
+ .query(`SELECT * FROM commands WHERE expires_at IS NOT NULL AND expires_at <= ? AND status = 'pending'`)
296
+ .all(now) as CommandRow[];
297
+ pendingRows = candidates.filter((row) => opts.isTargetGone!(rowToCommand(row)));
298
+ }
299
+ const rows = [...activeRows, ...pendingRows];
300
+ for (const row of rows) {
301
+ updateCommand(row.id, {
302
+ status: "timed_out",
303
+ error: row.status === "pending" ? "command target gone; expired before dispatch" : "command timed out",
304
+ });
305
+ }
252
306
  return rows.map((row) => getCommand(row.id)).filter((command): command is Command => Boolean(command));
253
307
  }
254
308
 
package/src/config.ts CHANGED
@@ -116,6 +116,56 @@ export function relayAuthHeaders(base: Record<string, string> = {}): Record<stri
116
116
  return token ? { ...base, [RELAY_TOKEN_HEADER]: token } : base;
117
117
  }
118
118
 
119
+ // #1024 — origins the orchestrator proxy may forward the relay MASTER token (relayToken()) to.
120
+ // A dynamically-registered orchestrator can carry an attacker-controlled apiUrl; forwarding the
121
+ // master token there leaks full relay admin. This is the defense-in-depth backstop behind the
122
+ // registration lock: even if some path seeds an orchestrator row, the proxy refuses to talk to an
123
+ // untrusted apiUrl at all (never sending the token OR the caller's query string). Explicit
124
+ // operator overrides — comma-separated origins, e.g. "https://orch.example.com".
125
+ export const TRUSTED_ORCHESTRATOR_ORIGINS = (process.env.AGENT_RELAY_TRUSTED_ORCHESTRATOR_ORIGINS || "")
126
+ .split(",")
127
+ .map((origin) => origin.trim())
128
+ .filter(Boolean);
129
+
130
+ // A private/LAN/tailnet IP literal — never a public, attacker-reachable address.
131
+ function isPrivateIpLiteral(host: string): boolean {
132
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
133
+ if (v4) {
134
+ const a = Number(v4[1]), b = Number(v4[2]);
135
+ if (a === 10 || a === 127) return true; // 10.0.0.0/8, loopback
136
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
137
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
138
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
139
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT (tailnet)
140
+ return false;
141
+ }
142
+ if (/^f[cd][0-9a-f]*:/.test(host)) return true; // IPv6 ULA fc00::/7
143
+ if (/^fe[89ab][0-9a-f]*:/.test(host)) return true; // IPv6 link-local fe80::/10
144
+ return false;
145
+ }
146
+
147
+ // Whether the relay master token may be forwarded to this orchestrator apiUrl (#1024). Trusts
148
+ // loopback, bare single-label hostnames (LAN/MagicDNS — never a public FQDN), private DNS
149
+ // suffixes, private/CGNAT/ULA IP literals, and any explicit AGENT_RELAY_TRUSTED_ORCHESTRATOR_ORIGINS
150
+ // entry. A public IP or public FQDN (e.g. http://attacker.example) is refused unless allowlisted.
151
+ export function isTrustedOrchestratorApiUrl(apiUrl: string | undefined): boolean {
152
+ if (!apiUrl) return false;
153
+ let url: URL;
154
+ try {
155
+ url = new URL(apiUrl);
156
+ } catch {
157
+ return false;
158
+ }
159
+ if (TRUSTED_ORCHESTRATOR_ORIGINS.includes(url.origin)) return true;
160
+ let host = url.hostname.toLowerCase();
161
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // unwrap IPv6 literal
162
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1") return true;
163
+ // A bare single-label host (no dot, not an IPv6 literal) is a LAN/MagicDNS name, never public.
164
+ if (!host.includes(".") && !host.includes(":")) return true;
165
+ if (/\.(local|internal|lan|home|ts\.net|tailnet)$/.test(host)) return true;
166
+ return isPrivateIpLiteral(host);
167
+ }
168
+
119
169
  export function allowUnauthenticated(): boolean {
120
170
  return process.env.AGENT_RELAY_ALLOW_UNAUTH === "1";
121
171
  }
@@ -26,3 +26,19 @@ type MessageIdRef = "m.id" | "messages.id";
26
26
  export function messageTaskClosed(messageRef: MessageIdRef): string {
27
27
  return `EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = ${messageRef} AND t.status IN (${CLOSED_TASK_STATUS_SQL}))`;
28
28
  }
29
+
30
+ /**
31
+ * SQL predicate: a claimable message that has NO linked task has already been handled by its claim
32
+ * holder. A claimable with a linked task settles via `messageTaskClosed`; one WITHOUT a task had no
33
+ * completion state at all — after the 30-min lease expired `releaseExpiredClaims` nulled the claim
34
+ * and the poll re-offered the finished work to every matching agent, forever (#1022 F6). The runner
35
+ * only marks a claimable read AFTER winning the claim (losers `continue` without reading), so a
36
+ * read receipt from any non-sender uniquely means "the holder took and processed it" — the missing
37
+ * settle signal. Gate the expired/unclaimed poll branch with `AND NOT ${claimableHandledNoTask(...)}`.
38
+ * `messageRef` is a fixed internal column reference, not user input.
39
+ */
40
+ export function claimableHandledNoTask(messageRef: MessageIdRef): string {
41
+ const fromAgentRef = messageRef === "m.id" ? "m.from_agent" : "messages.from_agent";
42
+ return `(NOT EXISTS (SELECT 1 FROM tasks t WHERE t.message_id = ${messageRef}) ` +
43
+ `AND EXISTS (SELECT 1 FROM message_reads mr WHERE mr.message_id = ${messageRef} AND mr.agent_id <> ${fromAgentRef}))`;
44
+ }
@@ -55,6 +55,16 @@ export function seedContinuationObjectiveFromSpawnPrompt(agentId: string, spawnR
55
55
  return ensureContinuationEnvelope({ agentId, objective: params.prompt, config, now });
56
56
  }
57
57
 
58
+ // #1023 — the ruledOut ledger was append-only with no cap: cumulative resends
59
+ // duplicated entries verbatim, and nothing bounded total size, so one long-running
60
+ // agent could render a multi-megabyte continuation straight into its own fresh
61
+ // context. MAX_LEDGER_TOTAL_BYTES/MAX_LEDGER_ENTRIES bound what stays in the
62
+ // envelope; dedupe drops literal resends. Entries pushed out by the bound are
63
+ // returned as droppedLedgerEntries so the caller can archive them (still
64
+ // recallable via relay_recall) instead of silently destroying them.
65
+ const MAX_LEDGER_TOTAL_BYTES = 256 * 1024;
66
+ const MAX_LEDGER_ENTRIES = 500;
67
+
58
68
  export function updateContinuationAfterCompact(input: {
59
69
  agentId: string;
60
70
  generation: number;
@@ -63,7 +73,7 @@ export function updateContinuationAfterCompact(input: {
63
73
  archiveRefs?: string[];
64
74
  spentTokens: number;
65
75
  now?: number;
66
- }): ContinuationEnvelope {
76
+ }): { envelope: ContinuationEnvelope; droppedLedgerEntries: ContinuationLedgerEntry[] } {
67
77
  const existing = getContinuationEnvelope(input.agentId);
68
78
  if (!existing) throw new ValidationError("continuation envelope not found");
69
79
  const now = input.now ?? Date.now();
@@ -76,20 +86,51 @@ export function updateContinuationAfterCompact(input: {
76
86
  tokens: existing.budget.spent.tokens + Math.max(0, input.spentTokens),
77
87
  },
78
88
  };
89
+ const { ledger, dropped } = mergeLedger(existing.ledger, input.ledgerEntries);
79
90
  getDb().query(`
80
91
  UPDATE agent_continuations
81
92
  SET generation = ?, ledger = ?, working_state = ?, archive_refs = ?, budget = ?, updated_at = ?
82
93
  WHERE agent_id = ?
83
94
  `).run(
84
95
  input.generation,
85
- JSON.stringify([...existing.ledger, ...input.ledgerEntries]),
96
+ JSON.stringify(ledger),
86
97
  input.workingState,
87
98
  JSON.stringify([...existing.archiveRefs, ...(input.archiveRefs ?? [])]),
88
99
  JSON.stringify(nextBudget),
89
100
  now,
90
101
  input.agentId,
91
102
  );
92
- return getContinuationEnvelope(input.agentId)!;
103
+ return { envelope: getContinuationEnvelope(input.agentId)!, droppedLedgerEntries: dropped };
104
+ }
105
+
106
+ function ledgerEntryKey(entry: ContinuationLedgerEntry): string {
107
+ return `${entry.ruledOut.trim().toLowerCase()} ${entry.why.trim().toLowerCase()}`;
108
+ }
109
+
110
+ function ledgerEntryBytes(entry: ContinuationLedgerEntry): number {
111
+ return new TextEncoder().encode(entry.ruledOut).byteLength + new TextEncoder().encode(entry.why).byteLength + 48;
112
+ }
113
+
114
+ function mergeLedger(
115
+ existing: ContinuationLedgerEntry[],
116
+ incoming: ContinuationLedgerEntry[],
117
+ ): { ledger: ContinuationLedgerEntry[]; dropped: ContinuationLedgerEntry[] } {
118
+ const seen = new Set(existing.map(ledgerEntryKey));
119
+ const merged = [...existing];
120
+ for (const entry of incoming) {
121
+ const key = ledgerEntryKey(entry);
122
+ if (seen.has(key)) continue;
123
+ seen.add(key);
124
+ merged.push(entry);
125
+ }
126
+ let start = merged.length > MAX_LEDGER_ENTRIES ? merged.length - MAX_LEDGER_ENTRIES : 0;
127
+ let totalBytes = 0;
128
+ for (let i = merged.length - 1; i >= start; i -= 1) totalBytes += ledgerEntryBytes(merged[i]!);
129
+ while (totalBytes > MAX_LEDGER_TOTAL_BYTES && start < merged.length - 1) {
130
+ totalBytes -= ledgerEntryBytes(merged[start]!);
131
+ start += 1;
132
+ }
133
+ return { ledger: merged.slice(start), dropped: merged.slice(0, start) };
93
134
  }
94
135
 
95
136
  export function appendContinuationArchiveRefs(agentId: string, archiveRefs: string[]): ContinuationEnvelope | null {
@@ -1,6 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
- import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
3
+ import { isRecord, stringValue, isMechanicalMessageKind, isReportVerbosity, type ReportVerbosity } from "agent-relay-sdk";
4
4
  import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
5
5
  import { parseJson } from "../utils";
6
6
  import { isLiveIsolatedWorkspace } from "../workspace-phase";
@@ -19,7 +19,7 @@ import { sendMessageWithResult } from "./messages.ts";
19
19
  import { deleteArtifactLinksForEntity } from "./artifacts.ts";
20
20
  import { getDb, getDbPath, runDbMaintenanceWorker, timedQuery } from "./connection.ts";
21
21
  import { legacyChannelTargets, isChannelAgentId } from "./delivery.ts";
22
- import { messageTaskClosed } from "./claim-sql.ts";
22
+ import { claimableHandledNoTask, messageTaskClosed } from "./claim-sql.ts";
23
23
  import { MSG_SELECT, rowToMessage } from "./mappers.ts";
24
24
  import type {
25
25
  AgentCard,
@@ -306,7 +306,11 @@ export function pollMessages(query: PollQuery): Message[] {
306
306
  targetClauses.push(freshFanoutTargetClause(`tag:${tag}`, agent, params));
307
307
  }
308
308
  for (const cap of agentCaps) {
309
+ // A capability fan-out is accepted at send time under both `cap:` and its `capability:` alias
310
+ // (planSend/FANOUT_PREFIXES + the SDK matcher). Poll for BOTH stored forms, or a message sent
311
+ // to `capability:X` gets a delivered receipt yet is invisible to every poll (#1022 F3).
309
312
  targetClauses.push(freshFanoutTargetClause(`cap:${cap}`, agent, params));
313
+ targetClauses.push(freshFanoutTargetClause(`capability:${cap}`, agent, params));
310
314
  }
311
315
  if (agentLabel) {
312
316
  targetClauses.push(freshFanoutTargetClause(`label:${agentLabel}`, agent, params));
@@ -338,7 +342,9 @@ export function pollMessages(query: PollQuery): Message[] {
338
342
  // pruned/deleted its message claims are released to NULL (see pruneOfflineAgents/deleteAgent),
339
343
  // so without it a finished task's message resurrects as fresh work on the next restart (which
340
344
  // mints a new agent id, so message_reads no longer dedups it) — the automation re-delivery bug.
341
- conditions.push(`(claimable = 0 OR claimed_by = ? OR ((claimed_by IS NULL OR claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT ${messageTaskClosed("m.id")}))`);
345
+ // `claimableHandledNoTask` closes the same gap for claimables with NO linked task: once the
346
+ // holder handled one, its lease expired and it got re-offered to everyone forever (#1022 F6).
347
+ conditions.push(`(claimable = 0 OR claimed_by = ? OR ((claimed_by IS NULL OR claim_expires_at IS NULL OR claim_expires_at <= ?) AND NOT ${messageTaskClosed("m.id")} AND NOT ${claimableHandledNoTask("m.id")}))`);
342
348
  params.push(query.for, Date.now());
343
349
 
344
350
  if (query.sinceId !== undefined) {
@@ -594,6 +600,30 @@ export function seedSpawnPromptMirror(
594
600
  }
595
601
  }
596
602
 
603
+ /**
604
+ * #1037 — the per-spawn report-up verbosity a spawner chose, read from the persisted `agent.spawn`
605
+ * command params by spawnRequestId. Mirrors seedSpawnPromptMirror: at first registration the relay
606
+ * has both the resolved child id and the spawn command, so registration stamps this onto child meta
607
+ * where the lineage report-up delivery gate reads it. Returns undefined when absent/unparseable
608
+ * (the gate then defaults to `quiet`).
609
+ */
610
+ export function readSpawnReportVerbosity(spawnRequestId: string | undefined): ReportVerbosity | undefined {
611
+ if (!spawnRequestId) return undefined;
612
+ try {
613
+ const row = getDb().query(`
614
+ SELECT params FROM commands
615
+ WHERE type = 'agent.spawn' AND correlation_id = ?
616
+ ORDER BY created_at DESC
617
+ LIMIT 1
618
+ `).get(spawnRequestId) as { params: string } | undefined;
619
+ const params = row ? parseJson<unknown>(row.params, {}) : {};
620
+ if (!isRecord(params)) return undefined;
621
+ return isReportVerbosity(params.reportVerbosity) ? params.reportVerbosity : undefined;
622
+ } catch {
623
+ return undefined;
624
+ }
625
+ }
626
+
597
627
  export function markRead(messageId: number, agentId: string): boolean {
598
628
  const exists = getDb().query("SELECT 1 FROM messages WHERE id = ?").get(messageId);
599
629
  if (!exists) return false;
@@ -607,7 +637,7 @@ export function markRead(messageId: number, agentId: string): boolean {
607
637
  delivery_next_retry_at = NULL,
608
638
  delivery_poison_reason = NULL,
609
639
  delivery_updated_at = ?
610
- WHERE id = ? AND (to_target = ? OR resolved_to_agent = ? OR to_target = 'broadcast' OR to_target LIKE 'tag:%' OR to_target LIKE 'cap:%' OR to_target LIKE 'label:%' OR to_target LIKE 'team:%')
640
+ WHERE id = ? AND (to_target = ? OR resolved_to_agent = ? OR to_target = 'broadcast' OR to_target LIKE 'tag:%' OR to_target LIKE 'cap:%' OR to_target LIKE 'capability:%' OR to_target LIKE 'label:%' OR to_target LIKE 'team:%')
611
641
  `).run(Date.now(), messageId, agentId, agentId);
612
642
  return true;
613
643
  }