agent-relay-server 0.119.6 → 0.119.9

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 (85) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-Azda2GIf.js → MessageBubble-U5sX3SNZ.js} +2 -2
  4. package/public/assets/{MessageBubble-Azda2GIf.js.map → MessageBubble-U5sX3SNZ.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-Bi2gvXeE.js +2 -0
  6. package/public/assets/PlanGraphPanel-Bi2gvXeE.js.map +1 -0
  7. package/public/assets/{activity-B0hz4XPy.js → activity-Cwkz51pG.js} +2 -2
  8. package/public/assets/{activity-B0hz4XPy.js.map → activity-Cwkz51pG.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BF0oSOTq.js → agent-profiles-BgddVqRp.js} +2 -2
  10. package/public/assets/{agent-profiles-BF0oSOTq.js.map → agent-profiles-BgddVqRp.js.map} +1 -1
  11. package/public/assets/{agents-CW4--j3Q.js → agents-CiGYBw0h.js} +2 -2
  12. package/public/assets/{agents-CW4--j3Q.js.map → agents-CiGYBw0h.js.map} +1 -1
  13. package/public/assets/{automation-S6_bQ2t-.js → automation-BBJB4RaE.js} +2 -2
  14. package/public/assets/{automation-S6_bQ2t-.js.map → automation-BBJB4RaE.js.map} +1 -1
  15. package/public/assets/{chat-hoFhhvgy.js → chat-DS578gZy.js} +2 -2
  16. package/public/assets/{chat-hoFhhvgy.js.map → chat-DS578gZy.js.map} +1 -1
  17. package/public/assets/{formatted-body-BHm145gP.js → formatted-body-DYUVqWym.js} +3 -3
  18. package/public/assets/{formatted-body-BHm145gP.js.map → formatted-body-DYUVqWym.js.map} +1 -1
  19. package/public/assets/{formatted-body-impl-CvE31j9F.js → formatted-body-impl-DdcyxVO0.js} +2 -2
  20. package/public/assets/{formatted-body-impl-CvE31j9F.js.map → formatted-body-impl-DdcyxVO0.js.map} +1 -1
  21. package/public/assets/{index-TamtghSr.js → index-CqcAqQKW.js} +6 -6
  22. package/public/assets/{index-TamtghSr.js.map → index-CqcAqQKW.js.map} +1 -1
  23. package/public/assets/{insights-Ci9ftVfl.js → insights-BunHbsTA.js} +2 -2
  24. package/public/assets/{insights-Ci9ftVfl.js.map → insights-BunHbsTA.js.map} +1 -1
  25. package/public/assets/{maintenance-bVhNbr33.js → maintenance-cICBVcpJ.js} +2 -2
  26. package/public/assets/{maintenance-bVhNbr33.js.map → maintenance-cICBVcpJ.js.map} +1 -1
  27. package/public/assets/{managed-agents-D5HO4dhI.js → managed-agents-BK9_Gi-M.js} +2 -2
  28. package/public/assets/{managed-agents-D5HO4dhI.js.map → managed-agents-BK9_Gi-M.js.map} +1 -1
  29. package/public/assets/{markdown-preview-impl-DO-p83dz.js → markdown-preview-impl-EZfcTHG9.js} +2 -2
  30. package/public/assets/{markdown-preview-impl-DO-p83dz.js.map → markdown-preview-impl-EZfcTHG9.js.map} +1 -1
  31. package/public/assets/{memory-D72o8umQ.js → memory-Vi-8HvK3.js} +2 -2
  32. package/public/assets/{memory-D72o8umQ.js.map → memory-Vi-8HvK3.js.map} +1 -1
  33. package/public/assets/{messages-5L_Bc_9w.js → messages-DScIiBMI.js} +2 -2
  34. package/public/assets/{messages-5L_Bc_9w.js.map → messages-DScIiBMI.js.map} +1 -1
  35. package/public/assets/{orchestrators-CnfgkQpl.js → orchestrators-DLogldXx.js} +2 -2
  36. package/public/assets/{orchestrators-CnfgkQpl.js.map → orchestrators-DLogldXx.js.map} +1 -1
  37. package/public/assets/{overview-Bu39NIyR.js → overview-B_RQAQR8.js} +2 -2
  38. package/public/assets/{overview-Bu39NIyR.js.map → overview-B_RQAQR8.js.map} +1 -1
  39. package/public/assets/{pairs-WuamP5W5.js → pairs-CSiPQ7E2.js} +2 -2
  40. package/public/assets/{pairs-WuamP5W5.js.map → pairs-CSiPQ7E2.js.map} +1 -1
  41. package/public/assets/{prompt-settings-BCrQreU6.js → prompt-settings-DsDi_Xpd.js} +2 -2
  42. package/public/assets/{prompt-settings-BCrQreU6.js.map → prompt-settings-DsDi_Xpd.js.map} +1 -1
  43. package/public/assets/{registry-kHKn0xwg.js → registry-DHFUWIdb.js} +2 -2
  44. package/public/assets/{registry-kHKn0xwg.js.map → registry-DHFUWIdb.js.map} +1 -1
  45. package/public/assets/{security-Dk5t3r_K.js → security-D2nHkJH6.js} +2 -2
  46. package/public/assets/{security-Dk5t3r_K.js.map → security-D2nHkJH6.js.map} +1 -1
  47. package/public/assets/{select-BdsZQfU2.js → select-BjNcommA.js} +2 -2
  48. package/public/assets/{select-BdsZQfU2.js.map → select-BjNcommA.js.map} +1 -1
  49. package/public/assets/{settings-EpjzQHnZ.js → settings-DiUEq54n.js} +2 -2
  50. package/public/assets/{settings-EpjzQHnZ.js.map → settings-DiUEq54n.js.map} +1 -1
  51. package/public/assets/{tasks-CedMQHbg.js → tasks-Dx8GVQiR.js} +2 -2
  52. package/public/assets/{tasks-CedMQHbg.js.map → tasks-Dx8GVQiR.js.map} +1 -1
  53. package/public/assets/{teams-BlHBo5Qt.js → teams-DQtf7bha.js} +2 -2
  54. package/public/assets/{teams-BlHBo5Qt.js.map → teams-DQtf7bha.js.map} +1 -1
  55. package/public/assets/{terminal-viewer-impl-gGqYpsjq.js → terminal-viewer-impl-D5rhXV4u.js} +2 -2
  56. package/public/assets/{terminal-viewer-impl-gGqYpsjq.js.map → terminal-viewer-impl-D5rhXV4u.js.map} +1 -1
  57. package/public/assets/types-uVOXgvMT.js.map +1 -1
  58. package/public/assets/{work-queue-D7opMqtU.js → work-queue-BfWNDp7U.js} +2 -2
  59. package/public/assets/{work-queue-D7opMqtU.js.map → work-queue-BfWNDp7U.js.map} +1 -1
  60. package/public/index.html +2 -2
  61. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  62. package/src/commands-db.ts +72 -20
  63. package/src/db/index.ts +1 -0
  64. package/src/db/migrations.ts +7 -0
  65. package/src/db/orchestrators.ts +1 -1
  66. package/src/db/provider-quotas.ts +2 -34
  67. package/src/db/schema.ts +2 -0
  68. package/src/db/work-evidence.ts +38 -0
  69. package/src/db/workspaces.ts +48 -3
  70. package/src/lifecycle-manager.ts +2 -4
  71. package/src/maintenance/jobs.ts +10 -1
  72. package/src/maintenance/workspaces.ts +71 -13
  73. package/src/routes/commands.ts +20 -4
  74. package/src/routes/workspaces.ts +6 -11
  75. package/src/services/shutdown-agent.ts +6 -1
  76. package/src/services/spawn-agent.ts +50 -7
  77. package/src/services/transient-agent-reaper.ts +82 -23
  78. package/src/workspace-actions.ts +26 -16
  79. package/src/workspace-auto-merge.ts +95 -13
  80. package/src/workspace-host.ts +55 -0
  81. package/src/workspace-merge.ts +3 -4
  82. package/src/workspace-orphans.ts +78 -55
  83. package/src/workspace-probe.ts +2 -3
  84. package/public/assets/PlanGraphPanel-B9TN_KQr.js +0 -2
  85. package/public/assets/PlanGraphPanel-B9TN_KQr.js.map +0 -1
@@ -13,13 +13,14 @@ import {
13
13
  updateWorkspaceStatus,
14
14
  setWorkspaceBranch,
15
15
  } from "../db";
16
- import type { WorkspaceMergePreview, WorkspaceStatus } from "../types";
16
+ import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "../types";
17
17
  import { notifyStewardHandoff, reconcileLandedWorkspace } from "../branch-landed";
18
18
  import { resolveStewardIdentity } from "../steward-identity";
19
19
  import { getStewardConfig } from "../config-store";
20
20
  import { preparePrCompletionScan, reconcileLandedPr } from "../workspace-pr-completion";
21
21
  import { applyBranchFreshness, fetchHostMergePreview, workspacePathWithinBase } from "../workspace-probe";
22
22
  import { wakeRepoSteward } from "../workspace-auto-merge";
23
+ import { workspaceOwnerOrchestrator } from "../workspace-host";
23
24
  import {
24
25
  CONFLICT_SCAN_STATUSES,
25
26
  DAY_MS,
@@ -44,7 +45,7 @@ export async function scanWorkspaceConflicts(): Promise<Record<string, unknown>>
44
45
  const reviewerArmed: string[] = [], reviewerChangesRequested: string[] = [], relayPrMerged: string[] = [], relayPrWaiting: string[] = [];
45
46
 
46
47
  for (const ws of candidates) {
47
- const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
48
+ const orch = workspaceOwnerOrchestrator(ws, orchestrators, { requireApiUrl: true });
48
49
  if (!orch?.apiUrl) continue;
49
50
  const preview = await fetchHostMergePreview(orch.apiUrl, ws, { checkPr: LANDED_RECONCILE_STATUSES.has(ws.status) });
50
51
  if (!preview || (preview as { available?: false }).available === false) continue;
@@ -129,6 +130,31 @@ export async function scanWorkspaceConflicts(): Promise<Record<string, unknown>>
129
130
  return { scanned: candidates.length, flagged, cleared, merged, prRecycled, notifiedStewards, reviewerArmed, reviewerChangesRequested, relayPrMerged, relayPrWaiting };
130
131
  }
131
132
 
133
+ type AutoAbandonDecision =
134
+ | { kind: "abandon" }
135
+ | { kind: "defer"; reason: string }
136
+ | { kind: "landed"; preview: WorkspaceMergePreview };
137
+
138
+ function previewProvesLanded(preview: WorkspaceMergePreview): boolean {
139
+ const cleanWorktree = (preview.dirtyCount ?? 0) === 0;
140
+ return preview.landed === true
141
+ || preview.prMerged === true
142
+ || preview.noop === true
143
+ || (cleanWorktree && (preview.unmergedAhead === 0 || preview.ahead === 0));
144
+ }
145
+
146
+ async function reviewAutoAbandonDecision(ws: WorkspaceRecord, orchestrators: ReturnType<typeof listOrchestrators>): Promise<AutoAbandonDecision> {
147
+ const owner = workspaceOwnerOrchestrator(ws, orchestrators, { requireApiUrl: true });
148
+ if (!owner?.apiUrl) return ws.orchestratorId ? { kind: "defer", reason: "owner-unavailable" } : { kind: "abandon" };
149
+ const preview = await fetchHostMergePreview(owner.apiUrl, ws, { checkPr: true });
150
+ if (!preview || (preview as { available?: false }).available === false) return { kind: "defer", reason: "owner-unavailable" };
151
+ const p = preview as WorkspaceMergePreview;
152
+ if (p.error) return { kind: "defer", reason: "preview-error" };
153
+ if (previewProvesLanded(p)) return { kind: "landed", preview: p };
154
+ if ((p.dirtyCount ?? 0) > 0) return { kind: "defer", reason: "dirty-worktree" };
155
+ return { kind: "abandon" };
156
+ }
157
+
132
158
  export async function workspaceGC(): Promise<Record<string, unknown>> {
133
159
  const now = Date.now();
134
160
  const cutoff = now - WORKSPACE_RETENTION_MS;
@@ -147,6 +173,7 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
147
173
  const escalatedIds: string[] = [];
148
174
  const reassignedIds: string[] = [];
149
175
  const escalationTargets: string[] = [];
176
+ const reconciledLandedIds: string[] = [];
150
177
  const escalationMs = stewardEscalationMs();
151
178
  const fallbackTarget = stewardFallbackTarget();
152
179
  for (const ws of all) {
@@ -159,6 +186,20 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
159
186
  const stewardOnline = Boolean(steward && getAgent(steward) && getAgent(steward)!.status !== "offline");
160
187
  const strandedAt = typeof meta.strandedAt === "number" ? meta.strandedAt : undefined;
161
188
 
189
+ if (!stewardOnline) {
190
+ const orch = workspaceOwnerOrchestrator(fresh, listOrchestrators().filter((o) => o.status === "online" && o.apiUrl), { requireApiUrl: true });
191
+ const preview = orch?.apiUrl ? await fetchHostMergePreview(orch.apiUrl, fresh, { checkPr: true }) : null;
192
+ if (preview && !("available" in preview) && !preview.error && !preview.missing) {
193
+ const landed = previewProvesLanded(preview);
194
+ if (landed) {
195
+ reconcileLandedWorkspace(fresh, preview);
196
+ patchWorkspaceMetadata(fresh.id, { strandedAt: undefined, escalatedAt: undefined, strandedNotifiedSteward: undefined });
197
+ reconciledLandedIds.push(fresh.id);
198
+ continue;
199
+ }
200
+ }
201
+ }
202
+
162
203
  if (stewardOnline) {
163
204
  if (strandedAt !== undefined && meta.strandedNotifiedSteward !== steward) {
164
205
  const sent = notifyTarget(
@@ -206,30 +247,45 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
206
247
  }
207
248
 
208
249
  const abandonedIds: string[] = [];
250
+ const autoAbandonDeferred: string[] = [];
209
251
  const notifiedStewards: string[] = [];
252
+ const reviewOrchestrators = listOrchestrators();
210
253
  for (const ws of all) {
211
254
  if (ws.status === "review_requested" && ws.updatedAt < reviewCutoff) {
212
- updateWorkspaceStatus(ws.id, "abandoned", { autoAbandoned: true, abandonedReason: "review_requested TTL exceeded", abandonedAt: now });
213
- abandonedIds.push(ws.id);
214
- const target = ws.stewardAgentId ?? fallbackTarget;
255
+ const fresh = getWorkspace(ws.id);
256
+ if (!fresh || fresh.status !== "review_requested") continue;
257
+ const decision = await reviewAutoAbandonDecision(fresh, reviewOrchestrators);
258
+ if (decision.kind === "defer") {
259
+ autoAbandonDeferred.push(`${fresh.id}:${decision.reason}`);
260
+ continue;
261
+ }
262
+ if (decision.kind === "landed") {
263
+ reconcileLandedWorkspace(fresh, decision.preview);
264
+ reconciledLandedIds.push(fresh.id);
265
+ continue;
266
+ }
267
+
268
+ updateWorkspaceStatus(fresh.id, "abandoned", { autoAbandoned: true, abandonedReason: "review_requested TTL exceeded", abandonedAt: now });
269
+ abandonedIds.push(fresh.id);
270
+ const target = fresh.stewardAgentId ?? fallbackTarget;
215
271
  const sent = notifyTarget(
216
272
  "workspace.auto_abandoned",
217
- { workspaceId: ws.id, repoRoot: ws.repoRoot },
273
+ { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
218
274
  target,
219
275
  "Workspace auto-abandoned",
220
- `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
221
- { kind: "workspace.auto-abandoned", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch },
276
+ `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
277
+ { kind: "workspace.auto-abandoned", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch },
222
278
  );
223
279
  if (sent) notifiedStewards.push(sent);
224
280
  createActivityEvent({
225
- clientId: `workspace-gc-abandon-${ws.id}-${now}`,
281
+ clientId: `workspace-gc-abandon-${fresh.id}-${now}`,
226
282
  kind: "state",
227
283
  title: "Workspace auto-abandoned",
228
- body: `${ws.branch ?? ws.id} in ${ws.repoRoot} - review_requested for ${Math.round((now - ws.updatedAt) / DAY_MS)}d`,
229
- meta: ws.branch ?? ws.id,
284
+ body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - review_requested for ${Math.round((now - fresh.updatedAt) / DAY_MS)}d`,
285
+ meta: fresh.branch ?? fresh.id,
230
286
  icon: "ti-clock-x",
231
287
  view: "orchestrators",
232
- metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: ws.id },
288
+ metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id },
233
289
  });
234
290
  }
235
291
  }
@@ -255,12 +311,14 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
255
311
  return {
256
312
  prunedTerminal: terminalIds,
257
313
  autoAbandoned: abandonedIds,
314
+ autoAbandonDeferred,
258
315
  notifiedStewards,
259
316
  pruneCommands,
260
317
  releasedLeaseRepos,
261
318
  escalated: escalatedIds,
262
319
  reassigned: reassignedIds,
263
320
  escalationTargets,
321
+ reconciledLanded: reconciledLandedIds,
264
322
  };
265
323
  }
266
324
 
@@ -288,7 +346,7 @@ export async function refreshIdleWorkspaces(): Promise<Record<string, unknown>>
288
346
  const now = Date.now();
289
347
 
290
348
  for (const ws of candidates) {
291
- const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
349
+ const orch = workspaceOwnerOrchestrator(ws, orchestrators);
292
350
  if (!orch?.agentId) { skipped.push(`${ws.id}:no-orchestrator`); continue; }
293
351
 
294
352
  // Gate: owner must exist and not be busy.
@@ -22,6 +22,7 @@ import { handleReviewerMergeResult } from "../reviewer-pipeline";
22
22
  import { CLEAR_MERGE_NO_PROGRESS, noteMergeNoProgress, scheduleRepoMerge } from "../workspace-auto-merge";
23
23
  import { enqueueContinuationInjectionAfterSelfResume } from "../self-resume";
24
24
  import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
25
+ import { commandTargetsWorkspaceOwner } from "../workspace-host";
25
26
 
26
27
  const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
27
28
 
@@ -227,10 +228,13 @@ export const patchCommand: Handler = async (req, params) => {
227
228
  if (command.type === "workspace.cleanup" && command.status === "succeeded" && isRecord(command.result)) {
228
229
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
229
230
  if (workspaceId) {
230
- updateWorkspaceStatus(workspaceId, "cleaned", {
231
- cleanupResult: command.result,
232
- cleanupCommandId: command.id,
233
- });
231
+ const workspace = getWorkspace(workspaceId);
232
+ if (workspace && commandTargetsWorkspaceOwner(command, workspace)) {
233
+ updateWorkspaceStatus(workspaceId, "cleaned", {
234
+ cleanupResult: command.result,
235
+ cleanupCommandId: command.id,
236
+ });
237
+ }
234
238
  }
235
239
  }
236
240
  if (command.type === "workspace.idle-refresh" && command.status === "succeeded" && isRecord(command.result)) {
@@ -261,6 +265,10 @@ export const patchCommand: Handler = async (req, params) => {
261
265
  // Snapshot the row BEFORE the recycle repoints `branch` (#206) — the landed
262
266
  // branch name + author (#239 branch.landed push) come from this pre-mutation state.
263
267
  const landedWorkspace = getWorkspace(workspaceId);
268
+ if (landedWorkspace && !commandTargetsWorkspaceOwner(command, landedWorkspace)) {
269
+ auditCommandOutcome(command);
270
+ return json(command);
271
+ }
264
272
  const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
265
273
  const normalizedStatus = command.result.merged === true
266
274
  && !newBranch
@@ -361,6 +369,10 @@ export const patchCommand: Handler = async (req, params) => {
361
369
  // auto-merge job backs off after N instead of busy-looping (#638).
362
370
  const current = getWorkspace(command.correlationId);
363
371
  if (current && current.status === "merge_planned") {
372
+ if (!commandTargetsWorkspaceOwner(command, current)) {
373
+ auditCommandOutcome(command);
374
+ return json(command);
375
+ }
364
376
  const failedResultStatus = isRecord(command.result)
365
377
  ? optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined
366
378
  : undefined;
@@ -420,6 +432,10 @@ export const patchCommand: Handler = async (req, params) => {
420
432
  // a status a human/agent has since moved on (merge_planned, abandoned, …).
421
433
  const current = getWorkspace(workspaceId);
422
434
  if (current && (current.status === "active" || current.status === "ready")) {
435
+ if (!commandTargetsWorkspaceOwner(command, current)) {
436
+ auditCommandOutcome(command);
437
+ return json(command);
438
+ }
423
439
  if (removed) {
424
440
  // The owner exited and the worktree had no work — the orchestrator
425
441
  // already deleted it on disk. Drop the DB row immediately instead of
@@ -15,6 +15,7 @@ import { AUTO_MERGE_POLICIES } from "../workspace-merge";
15
15
  import { workspaceActiveClaim } from "../workspace-claim";
16
16
  import { resolveCoordinatorAgentId } from "../steward-identity";
17
17
  import { relayToken } from "../config";
18
+ import { workspaceOwnerOrchestrator } from "../workspace-host";
18
19
 
19
20
  export const getWorkspaces: Handler = (req) => {
20
21
  try {
@@ -44,9 +45,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
44
45
  if (workspace.mode !== "isolated" || !workspace.worktreePath) {
45
46
  return json({ available: false, reason: "no isolated worktree" });
46
47
  }
47
- const orch = listOrchestrators().find(
48
- (candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
49
- );
48
+ const orch = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { requireApiUrl: true });
50
49
  if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
51
50
  const query = new URLSearchParams({ path: workspace.worktreePath });
52
51
  if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
@@ -287,9 +286,7 @@ export const postWorkspaceOrphanReclaim: Handler = async (req) => {
287
286
 
288
287
  async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ state: WorkspaceGitState } | { unavailable: string }> {
289
288
  if (workspace.mode !== "isolated" || !workspace.worktreePath) return { unavailable: "no isolated worktree" };
290
- const orch = listOrchestrators().find(
291
- (candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
292
- );
289
+ const orch = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { requireApiUrl: true });
293
290
  if (!orch?.apiUrl) return { unavailable: "owning orchestrator offline" };
294
291
  const query = new URLSearchParams({ path: workspace.worktreePath });
295
292
  if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
@@ -342,7 +339,7 @@ export const getWorkspaceDiagnostics: Handler = async (_req, params) => {
342
339
  // #640 — surface the branch agent's coordinator (owner's `spawnedBy` lineage) so a steward
343
340
  // inspecting the workspace knows who to escalate to, not just `repoRoot`.
344
341
  const coordinatorAgentId = resolveCoordinatorAgentId(workspace.ownerAgentId);
345
- const orch = listOrchestrators().find((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
342
+ const orch = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { onlineOnly: false });
346
343
  const orchOnline = Boolean(orch) && orch!.status === "online";
347
344
  const fetched = await fetchWorkspaceGitState(workspace);
348
345
  const gitState = "state" in fetched ? fetched.state : undefined;
@@ -380,8 +377,7 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
380
377
  // a destructive-action footgun. Now an explicit id narrows the candidate set.
381
378
  const workspaceId = cleanString(body.workspaceId, "workspaceId", { max: 160 });
382
379
  const dryRun = body.dryRun !== false; // safe by default
383
- const landedOnly = body.landedOnly !== false;
384
- const offlineOwnerOnly = body.offlineOwnerOnly !== false;
380
+ const landedOnly = true;
385
381
 
386
382
  const candidates = listWorkspaces().filter((ws) =>
387
383
  ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status)
@@ -395,7 +391,6 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
395
391
  const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
396
392
  const ownerOnline = isOwnerAlive(ws.ownerAgentId);
397
393
  if (ownerOnline) continue; // never clean a live owner's worktree
398
- if (offlineOwnerOnly && !ws.ownerAgentId) { /* no owner recorded — still eligible */ }
399
394
  if (workspaceActiveClaim(ws)) continue; // respect steward claims
400
395
  const fetched = await fetchWorkspaceGitState(ws);
401
396
  const gitState = "state" in fetched ? fetched.state : undefined;
@@ -420,7 +415,7 @@ export const postWorkspaceCleanupStale: Handler = async (req) => {
420
415
  }
421
416
  rows.push(row);
422
417
  }
423
- return json({ dryRun, landedOnly, offlineOwnerOnly, repoRoot, ...(workspaceId ? { workspaceId } : {}), scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
418
+ return json({ dryRun, landedOnly, repoRoot, ...(workspaceId ? { workspaceId } : {}), scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
424
419
  };
425
420
 
426
421
  export const postWorkspaceAction: Handler = async (req, params) => {
@@ -79,6 +79,11 @@ function str(v: unknown): string | undefined {
79
79
  return typeof v === "string" && v.length > 0 ? v : undefined;
80
80
  }
81
81
 
82
+ export function callerOwnsSpawnedAgent(ctx: AuthContext, target: AgentCard | null | undefined): boolean {
83
+ const callerId = ctx.callerAgentId;
84
+ return Boolean(callerId && target?.spawnedBy === callerId);
85
+ }
86
+
82
87
  /** Build a ShutdownAgentInput from a WS-bus `agent.shutdown` command frame's target +
83
88
  * params. Lives here (not in bus.ts) so the bus transport stays a thin dispatcher and the
84
89
  * shutdown-input shape has one home. */
@@ -123,7 +128,7 @@ export function authorizeShutdown(ctx: AuthContext, input: ShutdownAgentInput):
123
128
  const target = input.agentId
124
129
  ? getAgent(input.agentId)
125
130
  : listAgents().find((a) => a.meta?.spawnRequestId === input.spawnRequestId);
126
- if (!target || target.spawnedBy !== callerId) {
131
+ if (!target || !callerOwnsSpawnedAgent(ctx, target)) {
127
132
  throw new ShutdownAuthError(
128
133
  input.agentId
129
134
  ? `agent ${input.agentId} is not one of your spawned children`
@@ -28,7 +28,7 @@ import { DEFAULT_USER_ID, errMessage, isRecord, stringValue } from "agent-relay-
28
28
  import { statSync } from "node:fs";
29
29
  import { dirname, resolve } from "node:path";
30
30
  import { countLiveSpawnedAgents, createActivityEvent, ensureTeam, getAgent, getTaskDetail, listAgents, listProjects, listWorkspaces, recordTaskSpawnRequested, resolveAgentOwnership, resolveProjectForCwd, resolveScopedAnchor, rootsForProject, setAgentTeam, ValidationError, withResolvedProvisioning } from "../db";
31
- import { createCommand } from "../commands-db";
31
+ import { countInFlightSpawnCommands, createCommand, createSpawnCommandWithinQuota, findSpawnCommandByRequestId } from "../commands-db";
32
32
  import { emitCommandEvent } from "../command-events";
33
33
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
34
34
  import { runnerRuntimeTokenEnv } from "../runtime-tokens";
@@ -48,12 +48,14 @@ import { hasComponentScope, isComponentAuthorizedFor } from "../security";
48
48
  import { isPathWithinBase } from "../utils";
49
49
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
50
50
  import type { AuthContext } from "./auth-context";
51
- import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMode } from "../types";
51
+ import { callerOwnsSpawnedAgent } from "./shutdown-agent";
52
+ import type { AgentCard, AgentLifecycle, Command, CommandStatus, CreateCommandInput, SpawnApprovalMode, WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMode } from "../types";
52
53
  import { isOwnerAlive, liveAgentCwds, worktreeInUseByLiveAgent } from "../workspace-merge";
53
54
  import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
54
55
 
55
56
  /** The spawn scope every transport must hold (admin `*` / `admin:*` pass; `system` bypasses). */
56
57
  const SPAWN_SCOPE = "command:spawn";
58
+ const ACTIVE_SPAWN_COMMAND_STATUSES: CommandStatus[] = ["pending", "accepted", "running"];
57
59
 
58
60
  function hasGitMarker(dir: string): boolean {
59
61
  try {
@@ -362,11 +364,14 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
362
364
  throw new McpAuthError("spawned agents cannot spawn further agents (no grandchildren)");
363
365
  }
364
366
  const quota = ctx.constraints?.maxSpawnedAgents ?? 0;
365
- const live = countLiveSpawnedAgents(callerId);
366
- if (live >= quota) {
367
- throw new ValidationError(`spawn quota reached (${live}/${quota} live children) — shut one down or wait for one to exit`);
367
+ const occupied = countLiveSpawnedAgents(callerId) + countInFlightSpawnCommands(callerId);
368
+ if (occupied >= quota) {
369
+ throw new ValidationError(`spawn quota reached (${occupied}/${quota} live or in-flight children) — shut one down or wait for one to exit`);
368
370
  }
369
371
  }
372
+ const spawnQuota = callerId
373
+ ? { parentId: callerId, maxSpawnedAgents: ctx.constraints?.maxSpawnedAgents ?? 0 }
374
+ : undefined;
370
375
  // Gate the child spawn on the token's legit bounds: the target orchestrator AND the spawn cwd
371
376
  // (`cwdPrefixes`/`cwd` constraint). Excludes the #323 footguns (spawnRequestIds/policies) — those
372
377
  // are self-scoping per-spawn ids the CHILD gets fresh, so gating on them makes the quota
@@ -379,6 +384,31 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
379
384
  // the raw token actor. Recorded in the command payload, the token mint, and the audit row.
380
385
  const requestedBy = callerId ?? taskCreatorAgentId ?? input.requestedBy ?? ctx.actor.id;
381
386
 
387
+ const requestedSpawnRequestId = input.spawnRequestId?.trim();
388
+ if (requestedSpawnRequestId) {
389
+ const registeredAgent = listAgents().find((agent) => agent.meta?.spawnRequestId === requestedSpawnRequestId);
390
+ const activeCommand = findSpawnCommandByRequestId(requestedSpawnRequestId, ACTIVE_SPAWN_COMMAND_STATUSES);
391
+ const existingCommand = activeCommand ?? (registeredAgent ? findSpawnCommandByRequestId(requestedSpawnRequestId) : null);
392
+ if (existingCommand || registeredAgent) {
393
+ const existingParent = existingCommand ? stringValue(existingCommand.params.spawnedBy) : undefined;
394
+ if (callerId && existingParent !== callerId && !callerOwnsSpawnedAgent(ctx, registeredAgent)) {
395
+ throw new ValidationError(`spawnRequestId ${requestedSpawnRequestId} is already in use`);
396
+ }
397
+ if (!existingCommand) {
398
+ throw new ValidationError(`spawnRequestId ${requestedSpawnRequestId} is already registered to agent ${registeredAgent!.id}`);
399
+ }
400
+ return {
401
+ ok: true,
402
+ spawnRequestId: requestedSpawnRequestId,
403
+ orchestratorId: stringValue(existingCommand.params.orchestratorId) ?? orchestrator.id,
404
+ provider: (stringValue(existingCommand.params.provider) as SpawnProvider | undefined) ?? input.provider,
405
+ agentId: registeredAgent?.id ?? null,
406
+ registered: Boolean(registeredAgent),
407
+ command: existingCommand,
408
+ };
409
+ }
410
+ }
411
+
382
412
  // #405 — durable project binding: the project whose registered root contains the spawn cwd.
383
413
  // Stamped on both the child token and an auto-scaffolded Task row so issueless spawned work
384
414
  // stays filterable by project.
@@ -579,7 +609,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
579
609
  // command payload.
580
610
  const expectReply = input.expectReply === true;
581
611
 
582
- const command = createCommand({
612
+ const commandInput: CreateCommandInput = {
583
613
  type: "agent.spawn",
584
614
  source: "system",
585
615
  target: orchestrator.agentId,
@@ -619,7 +649,20 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
619
649
  // so there's no agent row to resolve `spawnedBy` from) can be routed back to it.
620
650
  ...(lineageAgentId ? { extra: { spawnedBy: lineageAgentId } } : {}),
621
651
  }),
622
- });
652
+ };
653
+ let command: Command;
654
+ if (spawnQuota) {
655
+ const quotaResult = createSpawnCommandWithinQuota(commandInput, {
656
+ ...spawnQuota,
657
+ liveChildren: () => countLiveSpawnedAgents(spawnQuota.parentId),
658
+ });
659
+ if (!quotaResult.command) {
660
+ throw new ValidationError(`spawn quota reached (${quotaResult.occupiedSlots}/${spawnQuota.maxSpawnedAgents} live or in-flight children) — shut one down or wait for one to exit`);
661
+ }
662
+ command = quotaResult.command;
663
+ } else {
664
+ command = createCommand(commandInput);
665
+ }
623
666
  emitCommandEvent(command, "command.requested");
624
667
 
625
668
  // ONE spawn-request audit row, identical across transports (the MCP path previously emitted
@@ -1,11 +1,14 @@
1
+ import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
1
2
  import { createCommand } from "../commands-db";
2
3
  import { emitRelayEvent } from "../events";
3
- import { createActivityEvent, getAgent, listAgents, listWorkspaces, recordTerminalEvent } from "../db";
4
+ import { createActivityEvent, getAgent, hasArtifactCreatedBy, listAgents, listFinalResponseCaptureMessages, listOrchestrators, listWorkspaces, recordTerminalEvent } from "../db";
4
5
  import { routeNotification } from "../notification-router";
5
6
  import { isProviderBlocked } from "../provider-state";
7
+ import { relayToken } from "../config";
8
+ import { workspaceOwnerOrchestrator } from "../workspace-host";
6
9
  import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
7
10
  import { finalizeEphemeralTasksOnReap } from "./task-entity";
8
- import type { AgentCard, Command, WorkspaceRecord } from "../types";
11
+ import type { AgentCard, Command, WorkspaceGitState, WorkspaceRecord } from "../types";
9
12
 
10
13
  // The single home for "dispose a transient/one-shot agent" — the canonical `agent.shutdown`
11
14
  // command + its events/terminal-marker/activity. Extracted so the idle-grace scan below AND
@@ -65,19 +68,28 @@ export function resetTransientTrackerForTests(): void {
65
68
  }
66
69
 
67
70
  // How many commits the agent's branch has ahead of its base. Returns null when
68
- // the check can't run (missing fields, git failure) — callers treat null as
71
+ // the check can't run (missing fields, host/probe failure) — callers treat null as
69
72
  // "unknown" and preserve the safe #614 hold-pending bias: only 0 is fast-reaped.
70
- function defaultCommitsAhead(ws: WorkspaceRecord): number | null {
71
- const base = ws.baseSha ?? ws.baseRef;
72
- if (!base || !ws.branch || !ws.worktreePath) return null;
73
+ async function defaultCommitsAhead(ws: WorkspaceRecord): Promise<number | null> {
74
+ if (!ws.branch || !ws.worktreePath) return null;
75
+ const orch = workspaceOwnerOrchestrator(ws, listOrchestrators(), { requireApiUrl: true });
76
+ if (!orch?.apiUrl) return null;
77
+ const query = new URLSearchParams({ path: ws.worktreePath });
78
+ if (ws.baseRef) query.set("baseRef", ws.baseRef);
79
+ if (ws.baseSha) query.set("baseSha", ws.baseSha);
80
+ const headers: Record<string, string> = {};
81
+ const token = relayToken();
82
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
73
83
  try {
74
- const proc = Bun.spawnSync(
75
- ["git", "-C", ws.worktreePath, "rev-list", "--count", `${base}..${ws.branch}`],
76
- { stdin: "ignore", stdout: "pipe", stderr: "ignore" },
77
- );
78
- if (proc.exitCode !== 0) return null;
79
- const n = parseInt(Buffer.from(proc.stdout).toString("utf8").trim(), 10);
80
- return Number.isFinite(n) ? n : null;
84
+ const res = await fetch(`${orch.apiUrl}/api/workspace/state?${query.toString()}`, {
85
+ headers,
86
+ signal: AbortSignal.timeout(8_000),
87
+ });
88
+ if (!res.ok) return null;
89
+ const state = await res.json() as WorkspaceGitState;
90
+ if (state.missing || state.error || (state.dirtyCount ?? 0) > 0) return null;
91
+ const n = state.landed === true ? 0 : (state.unmergedAhead ?? state.ahead);
92
+ return typeof n === "number" && Number.isFinite(n) ? n : null;
81
93
  } catch {
82
94
  return null;
83
95
  }
@@ -96,9 +108,9 @@ function reapProtected(agent: ReturnType<typeof listAgents>[number]): boolean {
96
108
  return agent.status !== "idle" || !agent.ready || isProviderBlocked(agent);
97
109
  }
98
110
 
99
- let _commitsAheadFn: (ws: WorkspaceRecord) => number | null = defaultCommitsAhead;
111
+ let _commitsAheadFn: (ws: WorkspaceRecord) => number | null | Promise<number | null> = defaultCommitsAhead;
100
112
 
101
- export function overrideCommitsAheadForTests(fn: (ws: WorkspaceRecord) => number | null): void {
113
+ export function overrideCommitsAheadForTests(fn: (ws: WorkspaceRecord) => number | null | Promise<number | null>): void {
102
114
  _commitsAheadFn = fn;
103
115
  }
104
116
  export function resetCommitsAheadForTests(): void {
@@ -116,14 +128,61 @@ function workspaceProducedWork(ws: WorkspaceRecord | undefined): boolean {
116
128
  return typeof ws.metadata.mergedAt === "number";
117
129
  }
118
130
 
119
- function transientLandComplete(agent: AgentCard): { complete: boolean; producedWork: boolean; workspaceId?: string; status?: string; reason?: string } {
131
+ const ACK_ONLY_RESPONSES = new Set([
132
+ "ok",
133
+ "okay",
134
+ "done",
135
+ "finished",
136
+ "complete",
137
+ "completed",
138
+ "ack",
139
+ "acknowledged",
140
+ "thanks",
141
+ "thank you",
142
+ ]);
143
+
144
+ const REPORT_SIGNAL_RE = /\b(ship|hold|pass|fail|finding|findings|issue|issues|blocker|blockers|reviewed|verified|tested|tests?|risk|regression|artifact|summary|audited|inspected)\b/i;
145
+ const GENERIC_COMPLETION_RE = /\b(?:i(?:'m| am)|we(?:'re| are))?\s*(?:done|finished|complete|completed)\b|\b(?:task|work)\s+(?:is\s+)?(?:done|finished|complete|completed)\b|\b(?:all set|nothing else|nothing more|no further action|no more to do|do not need to do anything else|don't need to do anything else)\b/i;
146
+
147
+ function genericCompletionOnly(normalized: string): boolean {
148
+ return GENERIC_COMPLETION_RE.test(normalized) && !REPORT_SIGNAL_RE.test(normalized);
149
+ }
150
+
151
+ function substantiveFinalReport(body: string): boolean {
152
+ const normalized = body.trim().replace(/\s+/g, " ");
153
+ if (!normalized) return false;
154
+ const ackKey = normalized.toLowerCase().replace(/[.!?]+$/g, "");
155
+ if (ACK_ONLY_RESPONSES.has(ackKey)) return false;
156
+ if (genericCompletionOnly(normalized)) return false;
157
+ const words = normalized.match(/[A-Za-z0-9][A-Za-z0-9'_-]*/g) ?? [];
158
+ if (normalized.length >= 80 && words.length >= 6) return true;
159
+ if (normalized.length >= 40 && words.length >= 8) return true;
160
+ return normalized.length >= 30
161
+ && words.length >= 5
162
+ && REPORT_SIGNAL_RE.test(normalized);
163
+ }
164
+
165
+ function reportOnlyWorkEvidence(agent: AgentCard): { producedWork: boolean; reason?: string } {
166
+ const since = typeof agent.createdAt === "number" && Number.isFinite(agent.createdAt) ? agent.createdAt : 0;
167
+ if (hasArtifactCreatedBy(agent.id, since)) return { producedWork: true, reason: "artifact-upload" };
168
+ const reports = listFinalResponseCaptureMessages(agent.id, since);
169
+ const hasSubstantiveReport = reports.some((message) => {
170
+ if (agent.spawnedBy && message.to !== agent.spawnedBy) return false;
171
+ return substantiveFinalReport(message.body);
172
+ });
173
+ return hasSubstantiveReport ? { producedWork: true, reason: "final-response-report" } : { producedWork: false };
174
+ }
175
+
176
+ async function transientLandComplete(agent: AgentCard): Promise<{ complete: boolean; producedWork: boolean; workspaceId?: string; status?: string; reason?: string }> {
120
177
  const agentId = agent.id;
178
+ const reportEvidence = reportOnlyWorkEvidence(agent);
121
179
  const isolated = listWorkspaces({ ownerAgentId: agentId }).filter((ws) => ws.mode === "isolated");
122
- if (!isolated.length) return { complete: true, producedWork: completedProviderTurns(agent) > 0, reason: "no-isolated-workspace" };
180
+ if (!isolated.length) return { complete: true, producedWork: reportEvidence.producedWork, reason: reportEvidence.reason ?? "no-isolated-workspace" };
123
181
  const live = isolated.find((ws) => !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
124
182
  if (!live) {
125
183
  const latest = isolated[0];
126
- return { complete: true, producedWork: workspaceProducedWork(latest), workspaceId: latest?.id, status: latest?.status, reason: "terminal-workspace" };
184
+ const producedWork = workspaceProducedWork(latest);
185
+ return { complete: true, producedWork: producedWork || reportEvidence.producedWork, workspaceId: latest?.id, status: latest?.status, reason: producedWork ? "terminal-workspace" : reportEvidence.reason ?? "terminal-workspace" };
127
186
  }
128
187
  const mergedAt = typeof live.metadata.mergedAt === "number" ? live.metadata.mergedAt : undefined;
129
188
  const landReportedAt = typeof live.metadata.landReportedAt === "number" ? live.metadata.landReportedAt : undefined;
@@ -134,8 +193,8 @@ function transientLandComplete(agent: AgentCard): { complete: boolean; producedW
134
193
  // instead of holding it pending a land that will never happen. Only fires when the
135
194
  // branch is provably empty (count === 0); null (unknown/git error) falls through to
136
195
  // the safe #614 hold-pending bias below.
137
- if (live.status === "active" && _commitsAheadFn(live) === 0) {
138
- return { complete: true, producedWork: false, workspaceId: live.id, status: live.status, reason: "empty-branch" };
196
+ if (live.status === "active" && await _commitsAheadFn(live) === 0) {
197
+ return { complete: true, producedWork: reportEvidence.producedWork, workspaceId: live.id, status: live.status, reason: reportEvidence.reason ?? "empty-branch" };
139
198
  }
140
199
  return { complete: false, producedWork: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
141
200
  }
@@ -171,7 +230,7 @@ function notifyTransientHeld(agentId: string, workspaceId: string | undefined, s
171
230
  }
172
231
  }
173
232
 
174
- export function reapTransientAgents(): Record<string, unknown> {
233
+ export async function reapTransientAgents(): Promise<Record<string, unknown>> {
175
234
  const now = Date.now();
176
235
  const grace = transientGraceMs();
177
236
  const maxLandWait = transientMaxLandWaitMs();
@@ -193,7 +252,7 @@ export function reapTransientAgents(): Record<string, unknown> {
193
252
  transientTracker.set(agent.id, entry);
194
253
  const idleForMs = now - entry.firstIdleAt;
195
254
  if (idleForMs < grace) { waiting.push(agent.id); continue; }
196
- const land = transientLandComplete(agent);
255
+ const land = await transientLandComplete(agent);
197
256
  if (!land.complete) {
198
257
  if (idleForMs >= grace + maxLandWait) {
199
258
  held.push(agent.id);
@@ -212,7 +271,7 @@ export function reapTransientAgents(): Record<string, unknown> {
212
271
  workspaceId: land.workspaceId,
213
272
  workspaceStatus: land.status,
214
273
  activityTitle: "Transient agent complete",
215
- activityBody: "Agent idle grace elapsed and branch work is land-complete; shutdown requested",
274
+ activityBody: "Agent idle grace elapsed and transient work is complete; shutdown requested",
216
275
  landReason: land.reason,
217
276
  now,
218
277
  });