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 +2 -2
- package/public/index.html +15 -1
- package/src/routes.ts +9 -8
- package/src/workspace-actions.ts +32 -2
- package/src/workspace-merge.ts +5 -1
- package/src/workspace-orphans.ts +152 -14
- package/src/workspace-phase.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "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.
|
|
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(
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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);
|
package/src/workspace-actions.ts
CHANGED
|
@@ -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" };
|
package/src/workspace-merge.ts
CHANGED
|
@@ -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
|
package/src/workspace-orphans.ts
CHANGED
|
@@ -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
|
|
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<{
|
|
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({
|
|
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
|
|
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-
|
|
393
|
+
clientId: `workspace-row-needs-attention-${workspace.id}-${now}`,
|
|
267
394
|
kind: "state",
|
|
268
|
-
title: "
|
|
269
|
-
body:
|
|
270
|
-
meta:
|
|
271
|
-
icon: "ti-
|
|
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: {
|
|
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
|
};
|
package/src/workspace-phase.ts
CHANGED
|
@@ -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>(
|
|
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:
|