agent-relay-server 0.26.0 → 0.27.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.15"
36
+ "agent-relay-sdk": "0.2.16"
37
37
  },
38
38
  "scripts": {
39
39
  "prepack": "bun run build:dashboard:bundle >&2",
package/public/index.html CHANGED
@@ -11066,6 +11066,12 @@ var PAIR_STATUS_COLORS = {
11066
11066
  };
11067
11067
  //#endregion
11068
11068
  //#region ../sdk/src/types.ts
11069
+ /** Terminal workspace statuses shared across server + dashboard filters. */
11070
+ var TERMINAL_WORKSPACE_STATUS_VALUES = [
11071
+ "cleaned",
11072
+ "merged",
11073
+ "abandoned"
11074
+ ];
11069
11075
  /** True for a non-null, non-array object. The canonical type guard for the whole repo. */
11070
11076
  function isRecord(value) {
11071
11077
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -11864,6 +11870,14 @@ var PROVIDER_CATALOG = {
11864
11870
  label: "Claude Code",
11865
11871
  defaultModel: "sonnet-4.6",
11866
11872
  models: [
11873
+ {
11874
+ alias: "fable-5",
11875
+ label: "Fable 5",
11876
+ providerModel: "claude-fable-5",
11877
+ efforts: CLAUDE_LOW_TO_XHIGH_MAX,
11878
+ defaultEffort: "medium",
11879
+ ...codeModel({ contextWindowTokens: CONTEXT_1M })
11880
+ },
11867
11881
  {
11868
11882
  alias: "opus-4.8",
11869
11883
  label: "Opus 4.8",
@@ -129474,7 +129488,7 @@ var STATUS_ORDER = {
129474
129488
  abandoned: 7,
129475
129489
  cleaned: 8
129476
129490
  };
129477
- var TERMINAL_STATUSES = new Set(["cleaned", "merged"]);
129491
+ var TERMINAL_STATUSES = new Set(TERMINAL_WORKSPACE_STATUS_VALUES);
129478
129492
  function shortPath(path) {
129479
129493
  const parts = path.split("/").filter(Boolean);
129480
129494
  if (parts.length <= 3) return path || "-";
package/src/routes.ts CHANGED
@@ -167,7 +167,7 @@ import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFF
167
167
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
168
168
  import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
169
169
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
170
- import { requestWorkspaceMerge } from "./workspace-merge";
170
+ import { isOwnerAlive, withOwnerOnline } from "./workspace-merge";
171
171
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
172
172
  import {
173
173
  applyWorkspaceAction,
@@ -3813,7 +3813,7 @@ const getWorkspaces: Handler = (req) => {
3813
3813
  const repoRoot = cleanString(url.searchParams.get("repoRoot") ?? undefined, "repoRoot", { max: 1000 });
3814
3814
  const ownerAgentId = cleanString(url.searchParams.get("agentId") ?? undefined, "agentId", { max: 240 });
3815
3815
  const status = optionalEnum(url.searchParams.get("status") ?? undefined, "status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
3816
- return json(listWorkspaces({ repoRoot, ownerAgentId, status }));
3816
+ return json(listWorkspaces({ repoRoot, ownerAgentId, status }).map(withOwnerOnline));
3817
3817
  } catch (e) {
3818
3818
  if (e instanceof ValidationError) return error(e.message, 400);
3819
3819
  throw e;
@@ -3823,7 +3823,7 @@ const getWorkspaces: Handler = (req) => {
3823
3823
  const getWorkspaceById: Handler = (_req, params) => {
3824
3824
  const workspace = getWorkspace(params.id!);
3825
3825
  if (!workspace) return error("workspace not found", 404);
3826
- return json(workspace);
3826
+ return json(withOwnerOnline(workspace));
3827
3827
  };
3828
3828
 
3829
3829
  // Per-repo coordination state: persistent steward records (survive offline gaps)
@@ -4006,7 +4006,7 @@ const getWorkspaceDiagnostics: Handler = async (_req, params) => {
4006
4006
  const workspace = getWorkspace(params.id!);
4007
4007
  if (!workspace) return error("workspace not found", 404);
4008
4008
  const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
4009
- const ownerOnline = Boolean(owner) && owner!.status !== "offline";
4009
+ const ownerOnline = isOwnerAlive(workspace.ownerAgentId);
4010
4010
  const orch = listOrchestrators().find((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
4011
4011
  const orchOnline = Boolean(orch) && orch!.status === "online";
4012
4012
  const fetched = await fetchWorkspaceGitState(workspace);
@@ -4055,7 +4055,7 @@ const postWorkspaceCleanupStale: Handler = async (req) => {
4055
4055
  const cleaned: string[] = [];
4056
4056
  for (const ws of candidates) {
4057
4057
  const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
4058
- const ownerOnline = Boolean(owner) && owner!.status !== "offline";
4058
+ const ownerOnline = isOwnerAlive(ws.ownerAgentId);
4059
4059
  if (ownerOnline) continue; // never clean a live owner's worktree
4060
4060
  if (offlineOwnerOnly && !ws.ownerAgentId) { /* no owner recorded — still eligible */ }
4061
4061
  if (workspaceActiveClaim(ws)) continue; // respect steward claims
@@ -4117,7 +4117,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
4117
4117
  // plus the directive projection + land receipt so the CLI `--wait` and any
4118
4118
  // HTTP caller get the same legible answer.
4119
4119
  return json({
4120
- workspace: waited.workspace,
4120
+ workspace: withOwnerOnline(waited.workspace),
4121
4121
  guidance: describeWorkspacePhase(waited.workspace),
4122
4122
  ...(landed ? { landed } : {}),
4123
4123
  fromStatus: waited.fromStatus,
@@ -4125,7 +4125,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
4125
4125
  timedOut: waited.timedOut,
4126
4126
  });
4127
4127
  }
4128
- return json(workspace);
4128
+ return json(withOwnerOnline(workspace));
4129
4129
  }
4130
4130
 
4131
4131
  // Everything else delegates to the shared core (one home, shared with the
@@ -4138,6 +4138,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
4138
4138
  metadata: cleanMeta(parsed.body.metadata) ?? {},
4139
4139
  strategy: optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy,
4140
4140
  deleteBranch: typeof parsed.body.deleteBranch === "boolean" ? parsed.body.deleteBranch : undefined,
4141
+ force: parsed.body.force === true,
4141
4142
  prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
4142
4143
  prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
4143
4144
  purpose: cleanString(parsed.body.purpose, "purpose", { max: 120 }),
@@ -4146,7 +4147,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
4146
4147
  });
4147
4148
  if (!result.ok) return error(result.error, result.httpStatus);
4148
4149
  if (result.command) emitCommand(result.command);
4149
- const payload: Record<string, unknown> = { workspace: result.workspace };
4150
+ const payload: Record<string, unknown> = { workspace: withOwnerOnline(result.workspace) };
4150
4151
  if (result.command) payload.command = result.command;
4151
4152
  if (result.claim !== undefined) payload.claim = result.claim;
4152
4153
  return json(payload, result.httpStatus);
@@ -19,8 +19,9 @@ import {
19
19
  updateWorkspaceStatus,
20
20
  } from "./db";
21
21
  import { emitActivityEvent } from "./sse";
22
- import { requestWorkspaceMerge } from "./workspace-merge";
22
+ import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
23
23
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
24
+ import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
24
25
  import type { Command, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
25
26
 
26
27
  // Single source of truth for the action verb set. The route's `optionalEnum` and
@@ -50,6 +51,8 @@ export interface ApplyWorkspaceActionInput {
50
51
  deleteBranch?: boolean;
51
52
  prTitle?: string;
52
53
  prBody?: string;
54
+ // cleanup
55
+ force?: boolean;
53
56
  // claim / release-claim
54
57
  purpose?: string;
55
58
  // deps-refresh
@@ -203,6 +206,20 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
203
206
  const nextStatus = STATUS_BY_ACTION[action];
204
207
  if (!nextStatus) return { ok: false, httpStatus: 400, error: `unsupported action: ${action}` };
205
208
 
209
+ if (
210
+ action === "cleanup" &&
211
+ input.force !== true &&
212
+ workspace.mode === "isolated" &&
213
+ !TERMINAL_WORKSPACE_STATUSES.has(workspace.status) &&
214
+ isOwnerAlive(workspace.ownerAgentId)
215
+ ) {
216
+ return {
217
+ ok: false,
218
+ httpStatus: 409,
219
+ error: `workspace ${workspace.id} owner is still online; pass force:true to clean it up intentionally`,
220
+ };
221
+ }
222
+
206
223
  const updated = updateWorkspaceStatus(workspace.id, nextStatus, {
207
224
  ...metadata,
208
225
  ...(detail ? { detail } : {}),
@@ -215,7 +232,7 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
215
232
  let command: Command | undefined;
216
233
  if (requiresCommand) {
217
234
  // Only `cleanup` reaches here — `merge` returned early via the shared helper.
218
- const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
235
+ const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard", { force: input.force === true });
219
236
  if (!built.ok) return { ok: false, httpStatus: built.status, error: built.error };
220
237
  command = built.command;
221
238
  }
@@ -237,7 +254,20 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
237
254
  export function buildWorkspaceCleanupCommand(
238
255
  workspace: WorkspaceRecord,
239
256
  requestedBy: string,
257
+ opts: { force?: boolean } = {},
240
258
  ): { ok: true; command: Command } | { ok: false; status: number; error: string } {
259
+ if (
260
+ opts.force !== true &&
261
+ workspace.mode === "isolated" &&
262
+ !TERMINAL_WORKSPACE_STATUSES.has(workspace.status) &&
263
+ isOwnerAlive(workspace.ownerAgentId)
264
+ ) {
265
+ return {
266
+ ok: false,
267
+ status: 409,
268
+ error: `workspace ${workspace.id} owner is still online; pass force:true to clean it up intentionally`,
269
+ };
270
+ }
241
271
  const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
242
272
  const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
243
273
  if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
@@ -32,12 +32,16 @@ export type RequestWorkspaceMergeResult =
32
32
 
33
33
  // The owner is "alive" while its relay agent exists and isn't offline (online or
34
34
  // a borderline-stale disconnect both count — don't nuke a worktree on a blip).
35
- function isOwnerAlive(ownerAgentId: string | undefined): boolean {
35
+ export function isOwnerAlive(ownerAgentId: string | undefined): boolean {
36
36
  if (!ownerAgentId) return false;
37
37
  const agent = getAgent(ownerAgentId);
38
38
  return Boolean(agent) && agent!.status !== "offline";
39
39
  }
40
40
 
41
+ export function withOwnerOnline<T extends { ownerAgentId?: string }>(workspace: T): T & { ownerOnline: boolean } {
42
+ return { ...workspace, ownerOnline: isOwnerAlive(workspace.ownerAgentId) };
43
+ }
44
+
41
45
  /**
42
46
  * Dispatch a base merge for an isolated workspace, serialized by the per-repo
43
47
  * merge lease (issue #157). Single source of truth shared by the manual
@@ -12,13 +12,14 @@
12
12
  // prune` (a no-op while the directory exists) never was.
13
13
 
14
14
  import { resolve } from "node:path";
15
- import { RELAY_TOKEN_HEADER, errMessage } from "agent-relay-sdk";
16
- import type { WorkspaceMergePreview, WorkspaceOrphan, WorkspaceProbe, WorkspaceRecord } from "./types";
17
- import { createActivityEvent, listOrchestrators, listWorkspaces } from "./db";
15
+ import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
16
+ import type { WorkspaceMergePreview, WorkspaceOrphan, WorkspaceProbe, WorkspaceRecord, WorkspaceStatus } from "./types";
17
+ import { createActivityEvent, getWorkspace, listOrchestrators, listWorkspaces, updateWorkspaceStatus } from "./db";
18
18
  import { createCommand } from "./commands-db";
19
19
  import { emitRelayEvent } from "./events";
20
20
  import { isPathWithinBase } from "./utils";
21
21
  import { TERMINAL_WORKSPACE_STATUSES, worktreeReapable, type WorktreeReapState } from "./workspace-phase";
22
+ import { isOwnerAlive } from "./workspace-merge";
22
23
 
23
24
  // Don't re-flag the same un-landed orphan every sweep — surface it once, then
24
25
  // stay quiet for this window. In-memory (keyed by worktree path) like the
@@ -28,6 +29,7 @@ const UNLANDED_FLAG_COOLDOWN_MS = Number(process.env.AGENT_RELAY_ORPHAN_FLAG_COO
28
29
  // remove them (parity with the session reaper's detect-only switch).
29
30
  const orphanWorktreeReapEnabled = () => process.env.AGENT_RELAY_ORPHAN_WORKTREE_REAP !== "0";
30
31
  const flaggedAt = new Map<string, number>();
32
+ const IN_FLIGHT_MISSING_WORKTREE_STATUSES = new Set<WorkspaceStatus>(["merge_planned", "cleanup_requested"]);
31
33
 
32
34
  export function resetOrphanWorktreeStateForTests(): void {
33
35
  flaggedAt.clear();
@@ -77,6 +79,39 @@ async function fetchWorktreeReapState(apiUrl: string, worktreePath: string, base
77
79
  }
78
80
  }
79
81
 
82
+ type MissingBranchProbe = { kind: "gone" } | { kind: "preview"; preview: WorkspaceMergePreview } | { kind: "unavailable" };
83
+
84
+ async function fetchBranchReapState(
85
+ apiUrl: string,
86
+ repoRoot: string,
87
+ branch: string | undefined,
88
+ baseRef?: string,
89
+ baseSha?: string,
90
+ ): Promise<MissingBranchProbe> {
91
+ if (!branch) return { kind: "unavailable" };
92
+ const query = new URLSearchParams({ repoRoot, branch, checkPr: "1" });
93
+ if (baseRef) query.set("baseRef", baseRef);
94
+ if (baseSha) query.set("baseSha", baseSha);
95
+ try {
96
+ const res = await fetch(`${apiUrl}/api/workspace/branch-merge-preview?${query.toString()}`, {
97
+ headers: relayHeaders(),
98
+ signal: AbortSignal.timeout(8_000),
99
+ });
100
+ if (res.status === 404) return { kind: "gone" };
101
+ if (!res.ok) return { kind: "unavailable" };
102
+ return { kind: "preview", preview: await res.json() as WorkspaceMergePreview };
103
+ } catch {
104
+ return { kind: "unavailable" };
105
+ }
106
+ }
107
+
108
+ function previewReapable(preview: WorkspaceMergePreview): boolean | undefined {
109
+ if (preview.error) return undefined;
110
+ const hasSignal = preview.landed === true || typeof preview.ahead === "number" || typeof preview.unmergedAhead === "number";
111
+ if (!hasSignal) return undefined;
112
+ return worktreeReapable({ landed: preview.landed, ahead: preview.ahead, unmergedAhead: preview.unmergedAhead, dirtyCount: 0 });
113
+ }
114
+
80
115
  function onlineOrchestrators(): OnlineOrchestrator[] {
81
116
  return listOrchestrators()
82
117
  .filter((orch) => orch.status === "online" && orch.apiUrl && orch.agentId)
@@ -93,7 +128,16 @@ function knownRepoRoots(workspaces: WorkspaceRecord[]): string[] {
93
128
  export interface CollectOrphansResult {
94
129
  orphans: WorkspaceOrphan[];
95
130
  /** Live isolated rows whose worktree is missing on disk (DB→disk drift). */
96
- missingWorktrees: Array<{ workspaceId: string; worktreePath: string; repoRoot: string; status: string }>;
131
+ missingWorktrees: Array<{
132
+ workspaceId: string;
133
+ worktreePath: string;
134
+ repoRoot: string;
135
+ status: WorkspaceStatus;
136
+ branch?: string;
137
+ baseRef?: string;
138
+ baseSha?: string;
139
+ ownerAgentId?: string;
140
+ }>;
97
141
  reason?: string;
98
142
  }
99
143
 
@@ -128,7 +172,16 @@ export async function collectWorkspaceOrphans(): Promise<CollectOrphansResult> {
128
172
  // DB→disk drift: a live isolated row whose worktree is no longer on disk.
129
173
  for (const [path, ws] of liveRowsByPath) {
130
174
  if (ws.mode === "isolated" && !onDisk.has(path)) {
131
- missingWorktrees.push({ workspaceId: ws.id, worktreePath: ws.worktreePath, repoRoot, status: ws.status });
175
+ missingWorktrees.push({
176
+ workspaceId: ws.id,
177
+ worktreePath: ws.worktreePath,
178
+ repoRoot,
179
+ status: ws.status,
180
+ branch: ws.branch,
181
+ baseRef: ws.baseRef,
182
+ baseSha: ws.baseSha,
183
+ ownerAgentId: ws.ownerAgentId,
184
+ });
132
185
  }
133
186
  }
134
187
 
@@ -208,6 +261,8 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
208
261
  const reapEnabled = orphanWorktreeReapEnabled();
209
262
  const reaped: string[] = [];
210
263
  const flagged: string[] = [];
264
+ const autoAbandoned: string[] = [];
265
+ const flaggedMissingWorktrees: string[] = [];
211
266
  const now = Date.now();
212
267
 
213
268
  for (const orphan of orphans) {
@@ -255,22 +310,103 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
255
310
  });
256
311
  }
257
312
 
258
- // DB→disk drift is observability-only: a live row whose worktree vanished is
259
- // surfaced, not auto-deleted (the row may still be mid-land or recoverable).
260
313
  for (const missing of missingWorktrees) {
261
- const key = `missing:${missing.worktreePath}`;
314
+ const workspace = getWorkspace(missing.workspaceId);
315
+ if (!workspace || TERMINAL_WORKSPACE_STATUSES.has(workspace.status) || workspace.mode !== "isolated" || !workspace.worktreePath) continue;
316
+ const key = `missing:${workspace.worktreePath}`;
317
+ const ownerAlive = isOwnerAlive(workspace.ownerAgentId);
318
+ const inFlight = IN_FLIGHT_MISSING_WORKTREE_STATUSES.has(workspace.status);
262
319
  const last = flaggedAt.get(key) ?? 0;
320
+ if (ownerAlive || inFlight) {
321
+ if (now - last < UNLANDED_FLAG_COOLDOWN_MS) continue;
322
+ flaggedAt.set(key, now);
323
+ createActivityEvent({
324
+ clientId: `workspace-row-no-worktree-${workspace.id}-${now}`,
325
+ kind: "state",
326
+ title: "Workspace row has no worktree on disk",
327
+ body: `Workspace ${workspace.id} (${workspace.status}) points at ${workspace.worktreePath}, which no longer exists on disk — disk/DB drift.`,
328
+ meta: workspace.id,
329
+ icon: "ti-unlink",
330
+ view: "orchestrators",
331
+ metadata: {
332
+ source: "server",
333
+ maintenanceJobId: "workspace-orphan-reaper",
334
+ workspaceId: workspace.id,
335
+ worktreePath: workspace.worktreePath,
336
+ status: workspace.status,
337
+ ownerAlive,
338
+ inFlight,
339
+ },
340
+ });
341
+ continue;
342
+ }
343
+
344
+ const orch = orchestrators.find((candidate) => candidate.apiUrl && isPathWithinBase(workspace.repoRoot, candidate.baseDir));
345
+ const probe = orch?.apiUrl
346
+ ? await fetchBranchReapState(orch.apiUrl, workspace.repoRoot, workspace.branch, workspace.baseRef, workspace.baseSha)
347
+ : { kind: "unavailable" } as const;
348
+ const safeToAbandon = probe.kind === "gone"
349
+ ? true
350
+ : probe.kind === "preview"
351
+ ? previewReapable(probe.preview)
352
+ : undefined;
353
+
354
+ if (safeToAbandon === true) {
355
+ const reasonText = probe.kind === "gone" ? "missing worktree; branch ref gone" : "missing worktree; branch already landed";
356
+ const updated = updateWorkspaceStatus(workspace.id, "abandoned", {
357
+ autoAbandoned: true,
358
+ abandonedReason: reasonText,
359
+ abandonedAt: now,
360
+ });
361
+ if (!updated) continue;
362
+ autoAbandoned.push(workspace.id);
363
+ flaggedAt.delete(key);
364
+ createActivityEvent({
365
+ clientId: `workspace-row-auto-abandoned-${workspace.id}-${now}`,
366
+ kind: "state",
367
+ title: "Workspace auto-abandoned",
368
+ body: `${workspace.branch ?? workspace.id} in ${workspace.repoRoot} — worktree missing and ${probe.kind === "gone" ? "branch ref is gone" : "branch has fully landed"}`,
369
+ meta: workspace.branch ?? workspace.id,
370
+ icon: "ti-clock-x",
371
+ view: "orchestrators",
372
+ metadata: {
373
+ source: "server",
374
+ maintenanceJobId: "workspace-orphan-reaper",
375
+ workspaceId: workspace.id,
376
+ worktreePath: workspace.worktreePath,
377
+ branch: workspace.branch,
378
+ reason: reasonText,
379
+ },
380
+ });
381
+ continue;
382
+ }
383
+
263
384
  if (now - last < UNLANDED_FLAG_COOLDOWN_MS) continue;
264
385
  flaggedAt.set(key, now);
386
+ flaggedMissingWorktrees.push(workspace.id);
387
+ const detail = probe.kind === "preview"
388
+ ? `${probe.preview.unmergedAhead ?? probe.preview.ahead ?? "?"} un-landed commit(s) still recoverable on branch ${workspace.branch ?? workspace.id}`
389
+ : workspace.branch
390
+ ? `host could not confirm whether branch ${workspace.branch} has landed`
391
+ : "host could not confirm whether recoverable branch work remains";
265
392
  createActivityEvent({
266
- clientId: `workspace-row-no-worktree-${missing.workspaceId}-${now}`,
393
+ clientId: `workspace-row-needs-attention-${workspace.id}-${now}`,
267
394
  kind: "state",
268
- title: "Workspace row has no worktree on disk",
269
- body: `Workspace ${missing.workspaceId} (${missing.status}) points at ${missing.worktreePath}, which no longer exists on disk disk/DB drift.`,
270
- meta: missing.workspaceId,
271
- icon: "ti-unlink",
395
+ title: "Missing-worktree workspace needs attention",
396
+ body: `${workspace.id} in ${workspace.repoRoot} has no worktree on disk and cannot be auto-abandoned safely — ${detail}. Recover via the branch if needed, then abandon or clean it up explicitly.`,
397
+ meta: workspace.id,
398
+ icon: "ti-alert-triangle",
272
399
  view: "orchestrators",
273
- metadata: { source: "server", maintenanceJobId: "workspace-orphan-reaper", workspaceId: missing.workspaceId, worktreePath: missing.worktreePath, status: missing.status },
400
+ metadata: {
401
+ source: "server",
402
+ maintenanceJobId: "workspace-orphan-reaper",
403
+ workspaceId: workspace.id,
404
+ worktreePath: workspace.worktreePath,
405
+ branch: workspace.branch,
406
+ status: workspace.status,
407
+ probe: probe.kind,
408
+ ...(probe.kind === "preview" ? { ahead: probe.preview.ahead, unmergedAhead: probe.preview.unmergedAhead, landed: probe.preview.landed } : {}),
409
+ },
274
410
  });
275
411
  }
276
412
 
@@ -283,6 +419,8 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
283
419
  scanned: orphans.length,
284
420
  reaped,
285
421
  flagged,
422
+ autoAbandoned,
423
+ flaggedMissingWorktrees,
286
424
  missingWorktrees: missingWorktrees.map((m) => m.workspaceId),
287
425
  reapEnabled,
288
426
  };
@@ -14,12 +14,13 @@
14
14
  // "handed off, healthy, wait" guidance, and `actionNeeded:false` is the explicit
15
15
  // anti-panic signal.
16
16
 
17
+ import { TERMINAL_WORKSPACE_STATUS_VALUES } from "agent-relay-sdk";
17
18
  import type { WorkspaceRecord, WorkspaceStatus } from "./types";
18
19
 
19
20
  // Statuses where the worktree's lifecycle is over — landed or torn down. Single
20
21
  // home; imported by maintenance (stale reap), routes (orphan scan), and the MCP
21
22
  // initialize primer (don't brief an agent on a dead workspace). Was duplicated.
22
- export const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
23
+ export const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(TERMINAL_WORKSPACE_STATUS_VALUES);
23
24
 
24
25
  // The "handed off, waiting to land" statuses — an agent has finished and the
25
26
  // auto-merge-back is responsible for getting the branch onto base. SINGLE HOME: