@akira-tl/forgerelay 0.9.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.1] - 2026-09-04
8
+
9
+ ### Added
10
+
11
+ - Added the low-frequency `workspace.recovery` Capability for ForgeRelay-managed worktrees. `status` reports the existing recovery model; `repair` can recreate missing active execution backing only from the surviving ForgeRelay managed branch; and Workspace-scoped `cleanup` removes only proven stale worktree registration or an already-integrated closed-Workspace managed branch. Workspace identity and Task state are preserved, active/unique/ambiguous Git state is never force-deleted, Relay executes recovery on the owning Execution ForgeRelay, and Composite use remains explicit-member scoped.
12
+
7
13
  ## [0.9.0] - 2026-09-04
8
14
 
9
15
  ### Added
@@ -45,4 +45,26 @@ Managed worktree finalize:
45
45
 
46
46
  source checkout 可能被用户或其他 Agent 修改。关闭前不要假设 target branch 仍停留在创建 worktree 时的状态;让 ForgeRelay 的 close 检查决定是否可以安全集成。遇到 diverged target、dirty source、忙碌 workspace 或 cleanup warning 时,保留现有 worktree 状态并向用户说明真实失败点,不要自行强制 reset、merge 或删除 worktree。
47
47
 
48
- 如果需要继续工作,复用原 `workspaceId` 或已知 worktree path;不要因为一次关闭失败就创建新的隔离副本。
48
+ 如果 active managed-worktree Workspace 的物理 backing 丢失,先用 `open_workspace action="inspect"` `workspace.recovery` 的 `status` 查看恢复分类。只有 Capability 返回 `recoverable` 时,才考虑 `workspace.recovery { operation: "repair" }`。
49
+
50
+ `workspace.recovery` 的 repair 规则:
51
+
52
+ - 只恢复当前 persistent `workspaceId`,不会创建新的 Workspace identity;
53
+ - 必须从持久化记录中的原 `forgerelay/*` managed branch 恢复,绝不从 target branch 重新创建替代工作;
54
+ - managed branch、target branch、source repository identity、worktree ownership 任一无法证明时返回 `manual-intervention`,不猜测;
55
+ - 若同一 managed branch 已出现另一个 worktree candidate,拒绝自动恢复;
56
+ - recovery backing 创建后必须重新验证 branch、HEAD、Git registration 和工作树健康状态,验证失败时仅回滚 ForgeRelay 本次创建的临时 backing;
57
+ - repair 不 merge、不 rebase、不 reset、不删除 managed branch,也不移动 target branch。
58
+
59
+ `workspace.recovery { operation: "cleanup" }` 只清理当前 persistent Workspace 能证明归属的 Git administrative residue:
60
+
61
+ - backing 已不存在且 stale registration 仍精确对应持久化 managed branch 时,可只移除该 registration;不执行 repository-wide `git worktree prune`;
62
+ - active Workspace 的 managed branch 始终保留,即使 stale registration 已被清掉,避免 cleanup 把仍需恢复的 Workspace 变成不可恢复状态;
63
+ - closed Workspace 的残留 `forgerelay/*` branch 只有在没有任何 worktree registration 或其他 persistent Workspace identity 持有它、source checkout 仍位于持久化 target branch、且 Git 证明该 branch 已完整并入 target 时才允许删除;
64
+ - unique/unmerged commits、branch ownership mismatch、active backing、缺失 target、source/target identity 不可证明等情况返回 `manual-intervention`,不强制删除;
65
+ - cleanup 返回 `cleaned`、`nothing-to-clean` 或 `manual-intervention` 分类;对 active Workspace 成功清理 registration 后同时返回更新后的 recovery projection;
66
+ - cleanup 不 reopen、close、delete Workspace,也不改变 persistent Workspace lifecycle state。
67
+
68
+ Relay Workspace 的 recovery 在 Execution ForgeRelay 上执行;Composite Workspace 必须显式指定实际拥有该 worktree 的 member。不要在 Gateway 或 Composite identity 上自行解释/修改成员 Git 状态。
69
+
70
+ 如果需要继续工作,复用原 `workspaceId`;不要因为 backing 丢失、一次关闭失败或一次恢复失败就创建新的隔离副本来绕过原状态。
@@ -95,6 +95,7 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
95
95
  "capability-guides.read",
96
96
  "code.intelligence",
97
97
  "workspace.tasks",
98
+ "workspace.recovery",
98
99
  ];
99
100
  if (config.toolMode !== "codex") {
100
101
  capabilities.push("batch.execute");
@@ -190,6 +190,11 @@ export function createCapabilityRegistry(dependencies) {
190
190
  taskId: z.string().min(1),
191
191
  }).strict(),
192
192
  ]);
193
+ const workspaceRecoveryInput = z.discriminatedUnion("operation", [
194
+ z.object({ operation: z.literal("status") }).strict(),
195
+ z.object({ operation: z.literal("repair") }).strict(),
196
+ z.object({ operation: z.literal("cleanup") }).strict(),
197
+ ]);
193
198
  const subagentSessionInput = z.discriminatedUnion("operation", [
194
199
  z.object({
195
200
  operation: z.literal("start"),
@@ -287,6 +292,18 @@ export function createCapabilityRegistry(dependencies) {
287
292
  run: async (input, context, options) => dependencies.codeIntelligence.run(input, context, options),
288
293
  }]
289
294
  : []),
295
+ ...(dependencies.workspaceRecovery
296
+ ? [{
297
+ name: "workspace.recovery",
298
+ description: "Inspect or safely repair the current managed-worktree Workspace when ForgeRelay can prove the surviving managed branch and execution backing ownership.",
299
+ guideName: "managed-worktrees",
300
+ readGuideBeforeFirstUse: true,
301
+ batchPolicy: "unsupported",
302
+ inputSchema: workspaceRecoveryInput,
303
+ availability: (context) => managedWorktreeAvailability(context, dependencies.workspaceRecovery?.available ?? false, dependencies.workspaceRecovery?.unavailableReason),
304
+ run: async (input, context, options) => dependencies.workspaceRecovery.run(input, context, options),
305
+ }]
306
+ : []),
290
307
  ...(dependencies.workspaceTasks
291
308
  ? [{
292
309
  name: "workspace.tasks",
@@ -360,6 +377,17 @@ function filesystemWorkspaceAvailability(context, available = true, reason) {
360
377
  }
361
378
  return { available, ...(reason ? { reason } : {}) };
362
379
  }
380
+ function managedWorktreeAvailability(context, available = true, reason) {
381
+ if (context.workspaceKind !== "workspace" ||
382
+ context.workspaceMode !== "worktree" ||
383
+ context.workspaceManaged !== true) {
384
+ return {
385
+ available: false,
386
+ reason: "This capability is available only for a ForgeRelay-managed worktree Workspace.",
387
+ };
388
+ }
389
+ return { available, ...(reason ? { reason } : {}) };
390
+ }
363
391
  function requireWorkspaceRoot(context) {
364
392
  if (context.workspaceKind !== "workspace" || !context.workspaceRoot) {
365
393
  throw new CapabilityError("capability_unavailable", "This capability requires a filesystem-backed Workspace.");
@@ -1,3 +1,4 @@
1
+ import { loadCapabilityGuides } from "./capabilities.js";
1
2
  import { CapabilityError } from "./capability-registry.js";
2
3
  import { formatPathForPrompt } from "../../../workspaces/resources/skills.js";
3
4
  export function workspaceHookInvocation(workspace) {
@@ -13,6 +14,8 @@ export function capabilityContextFor(workspace) {
13
14
  workspaceId: workspace.id,
14
15
  workspaceKind: "workspace",
15
16
  workspaceRoot: workspace.root,
17
+ workspaceMode: workspace.mode,
18
+ workspaceManaged: workspace.worktree?.managed ?? false,
16
19
  guides: workspace.capabilityGuides.map((guide) => ({
17
20
  name: guide.name,
18
21
  description: guide.description,
@@ -21,6 +24,20 @@ export function capabilityContextFor(workspace) {
21
24
  })),
22
25
  };
23
26
  }
27
+ export function managedWorktreeRecoveryCapabilityContext(session, config) {
28
+ return {
29
+ workspaceId: session.id,
30
+ workspaceKind: "workspace",
31
+ workspaceMode: session.mode,
32
+ workspaceManaged: session.managed,
33
+ guides: loadCapabilityGuides(config).map((guide) => ({
34
+ name: guide.name,
35
+ description: guide.description,
36
+ whenToRead: guide.whenToRead,
37
+ path: formatPathForPrompt(guide.filePath),
38
+ })),
39
+ };
40
+ }
24
41
  export function compositeCapabilityContext(workspaceId, guides) {
25
42
  return {
26
43
  workspaceId,
@@ -10,7 +10,7 @@ import { formatAgentsPath } from "../../../../workspaces.js";
10
10
  import { markReturnedOutput, processActivityOutcome, processToolResponse, } from "../../../process/runtime.js";
11
11
  import { capabilityActivityAuditRequest, capabilityActivityAuditResult } from "../../../../subagents/sessions/mcp/audit.js";
12
12
  import { activityRelationFor, activityRequestFor, runActivityTool, runActivityToolWithHooks, standardActivityOutcome, } from "../../core/activity-support.js";
13
- import { capabilityContextFor, workspaceHookInvocation } from "../../core/capability-support.js";
13
+ import { capabilityContextFor, managedWorktreeRecoveryCapabilityContext, workspaceHookInvocation, } from "../../core/capability-support.js";
14
14
  import { assertWorkspaceInstructionsLoadedBeforeSideEffect, contentLineCount, contentText, countDiffStats, formatDiscoveredWorkspaceInstructions, logFailedToolResponse, logToolCall, newFilePatch, textBlock, textSummary, toolResultContent, toolResultIsError, toolResultText, workspaceLogContext, } from "../../core/tool-support.js";
15
15
  import { appendAutomaticMutationDiagnostics } from "./mutation-diagnostics.js";
16
16
  export function createOperationRuntime(options) {
@@ -429,6 +429,58 @@ export function createOperationRuntime(options) {
429
429
  },
430
430
  capabilityRun: async (input, context) => {
431
431
  const { workspaceId, name, arguments: capabilityArguments, file } = input;
432
+ if (name === "workspace.recovery") {
433
+ const session = workspaces.getWorkspaceSession(workspaceId);
434
+ if (session.status === "closed") {
435
+ const startedAt = performance.now();
436
+ try {
437
+ const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, managedWorktreeRecoveryCapabilityContext(session, config), {
438
+ nativeFile: file,
439
+ signal: context.signal,
440
+ requestMeta: context.requestMeta,
441
+ sessionId: context.sessionId,
442
+ batch: context.batch,
443
+ });
444
+ const result = {
445
+ content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
446
+ structuredContent: { name, action: "run", result: execution.value },
447
+ };
448
+ logToolCall(config, {
449
+ tool: toolNames.capability,
450
+ workspaceId: session.id,
451
+ capability: name,
452
+ action: "run",
453
+ success: true,
454
+ durationMs: Math.round(performance.now() - startedAt),
455
+ });
456
+ return result;
457
+ }
458
+ catch (error) {
459
+ const capabilityError = error instanceof CapabilityError
460
+ ? error
461
+ : new CapabilityError("execution_failed", error instanceof Error ? error.message : String(error));
462
+ const result = {
463
+ content: [textBlock(`${capabilityError.code}: ${capabilityError.message}`)],
464
+ structuredContent: {
465
+ name,
466
+ action: "run",
467
+ error: { code: capabilityError.code, message: capabilityError.message },
468
+ },
469
+ isError: true,
470
+ };
471
+ logToolCall(config, {
472
+ tool: toolNames.capability,
473
+ workspaceId: session.id,
474
+ capability: name,
475
+ action: "run",
476
+ success: false,
477
+ durationMs: Math.round(performance.now() - startedAt),
478
+ error: capabilityError.message,
479
+ });
480
+ return result;
481
+ }
482
+ }
483
+ }
432
484
  const workspace = workspaces.getWorkspace(workspaceId);
433
485
  let changedPaths = [];
434
486
  return runActivityTool(activityLifecycle, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), toolNames.capability, activityRequestFor(capabilityActivityAuditRequest(input), context), (activityContext) => runToolWithHooks(hooks, {
@@ -438,6 +490,7 @@ export function createOperationRuntime(options) {
438
490
  payload: { name, action: "run" },
439
491
  isFailure: toolResultIsError,
440
492
  changedPaths: () => changedPaths,
493
+ afterCwd: () => workspaces.getWorkspaceSession(workspaceId).root,
441
494
  operation: async () => {
442
495
  const startedAt = performance.now();
443
496
  try {
package/dist/server.js CHANGED
@@ -131,6 +131,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
131
131
  const capabilityRegistry = createCapabilityRegistry({
132
132
  inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
133
133
  ...subagentMcp.registryDependencies,
134
+ workspaceRecovery: {
135
+ available: true,
136
+ run: async (input, context) => ({
137
+ value: await workspaces.runManagedWorktreeRecovery(context.workspaceId, input.operation),
138
+ }),
139
+ },
134
140
  workspaceTasks: {
135
141
  available: true,
136
142
  run: async (input, context) => {
@@ -228,7 +228,7 @@ async function isAncestor(ancestor, descendant, cwd) {
228
228
  return false;
229
229
  }
230
230
  }
231
- function managedWorktreePath(input) {
231
+ export function managedWorktreePath(input) {
232
232
  const repoName = sanitizePathSegment(basename(input.repoRoot)) || "repo";
233
233
  return join(input.worktreeRoot, `${repoName}-${input.worktreeId}`);
234
234
  }
@@ -1,7 +1,9 @@
1
- import { realpath, stat } from "node:fs/promises";
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, realpath, rm, stat } from "node:fs/promises";
2
3
  import { platform } from "node:os";
3
4
  import { basename, dirname, join, resolve } from "node:path";
4
5
  import { assertAllowedPath } from "../../mcp/filesystem/roots.js";
6
+ import { managedWorktreePath } from "./git-worktrees.js";
5
7
  import { git } from "./git.js";
6
8
  export async function inspectManagedWorktreeRecovery(session, config) {
7
9
  if (session.status !== "active" ||
@@ -72,6 +74,233 @@ export async function inspectManagedWorktreeRecovery(session, config) {
72
74
  backingBranch,
73
75
  };
74
76
  }
77
+ export async function prepareManagedWorktreeRepair(session, config) {
78
+ const recovery = await inspectManagedWorktreeRecovery(session, config);
79
+ if (!recovery) {
80
+ throw new Error(`Workspace ${session.id} is not an active managed-worktree Workspace.`);
81
+ }
82
+ if (recovery.classification !== "recoverable") {
83
+ return {
84
+ prepared: false,
85
+ recovery,
86
+ reason: "The Workspace is not in a provably recoverable managed-worktree state.",
87
+ };
88
+ }
89
+ if (!session.sourceRoot ||
90
+ !session.branch ||
91
+ !session.targetBranch ||
92
+ !session.baseRef ||
93
+ !session.baseSha ||
94
+ !session.branch.startsWith("forgerelay/")) {
95
+ return {
96
+ prepared: false,
97
+ recovery: manualRecovery(recovery),
98
+ reason: "Persisted managed-worktree ownership metadata is incomplete or cannot prove ForgeRelay branch ownership.",
99
+ };
100
+ }
101
+ const sourceRoot = assertAllowedPath(session.sourceRoot, config.allowedRoots);
102
+ const head = (await git(sourceRoot, ["rev-parse", `refs/heads/${session.branch}`])).stdout.trim();
103
+ const beforeRegistrations = await worktreeRegistrations(sourceRoot);
104
+ const persistedRegistrations = await matchingPathRegistrations(beforeRegistrations, session.root);
105
+ if (recovery.gitRegistration === "stale" &&
106
+ (persistedRegistrations.length !== 1 ||
107
+ persistedRegistrations[0]?.prunable !== true ||
108
+ persistedRegistrations[0]?.branch !== `refs/heads/${session.branch}` ||
109
+ (persistedRegistrations[0]?.head !== undefined && persistedRegistrations[0].head !== head))) {
110
+ return {
111
+ prepared: false,
112
+ recovery: manualRecovery(recovery),
113
+ reason: "The stale Git worktree registration does not prove ownership of the persisted ForgeRelay managed branch.",
114
+ };
115
+ }
116
+ const beforeConflicts = await conflictingBranchRegistrations(beforeRegistrations, session.branch, session.root);
117
+ if (beforeConflicts.length > 0) {
118
+ return {
119
+ prepared: false,
120
+ recovery: manualRecovery(recovery),
121
+ reason: `Managed branch ${session.branch} is already associated with another worktree candidate.`,
122
+ };
123
+ }
124
+ const root = await allocateManagedWorktreeRecoveryPath(sourceRoot, config);
125
+ try {
126
+ await git(sourceRoot, ["worktree", "add", "--force", root, session.branch]);
127
+ }
128
+ catch (error) {
129
+ await rm(root, { recursive: true, force: true });
130
+ const afterFailureConflicts = await conflictingBranchRegistrations(await worktreeRegistrations(sourceRoot), session.branch, session.root);
131
+ if (afterFailureConflicts.length > 0) {
132
+ return {
133
+ prepared: false,
134
+ recovery: manualRecovery(recovery),
135
+ reason: `Managed branch ${session.branch} acquired another worktree candidate while repair was starting.`,
136
+ };
137
+ }
138
+ throw new Error(`Git could not recreate managed-worktree backing from ${session.branch}: ${error instanceof Error ? error.message : String(error)}`);
139
+ }
140
+ const preparation = {
141
+ prepared: true,
142
+ previousRoot: session.root,
143
+ root,
144
+ sourceRoot,
145
+ branch: session.branch,
146
+ targetBranch: session.targetBranch,
147
+ baseRef: session.baseRef,
148
+ baseSha: session.baseSha,
149
+ head,
150
+ recovery,
151
+ };
152
+ try {
153
+ const afterRegistrations = await worktreeRegistrations(sourceRoot);
154
+ const afterConflicts = await conflictingBranchRegistrations(afterRegistrations, session.branch, session.root, root);
155
+ if (afterConflicts.length > 0) {
156
+ await rollbackManagedWorktreeRepair(preparation, config);
157
+ return {
158
+ prepared: false,
159
+ recovery: manualRecovery(recovery),
160
+ reason: `Managed branch ${session.branch} has ambiguous worktree ownership after repair preflight.`,
161
+ };
162
+ }
163
+ const rootKey = await registrationPathKey(root);
164
+ const repairedRegistration = await firstMatchingRegistration(afterRegistrations, `refs/heads/${session.branch}`, rootKey);
165
+ if (!repairedRegistration || repairedRegistration.prunable) {
166
+ throw new Error("Git did not register the newly created recovery backing as an active worktree.");
167
+ }
168
+ const actualBranch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim();
169
+ if (actualBranch !== session.branch) {
170
+ throw new Error(`Recovery backing opened branch ${actualBranch} instead of ${session.branch}.`);
171
+ }
172
+ const repairedHead = (await git(root, ["rev-parse", "HEAD"])).stdout.trim();
173
+ const managedHead = (await git(sourceRoot, ["rev-parse", `refs/heads/${session.branch}`])).stdout.trim();
174
+ if (repairedHead !== managedHead) {
175
+ throw new Error("Recovery backing HEAD does not match the surviving managed branch.");
176
+ }
177
+ if ((await git(root, ["status", "--porcelain=v1"])).stdout.trim().length > 0) {
178
+ throw new Error("Recovery backing is not clean immediately after reconstruction.");
179
+ }
180
+ const candidateRecovery = await inspectManagedWorktreeRecovery({ ...session, root }, config);
181
+ if (!candidateRecovery || candidateRecovery.classification !== "healthy") {
182
+ await rollbackManagedWorktreeRepair({ ...preparation, head: repairedHead }, config);
183
+ return {
184
+ prepared: false,
185
+ recovery: candidateRecovery ? manualRecovery(candidateRecovery) : manualRecovery(recovery),
186
+ reason: "The reconstructed backing did not pass the managed-worktree recovery health check.",
187
+ };
188
+ }
189
+ return { ...preparation, head: repairedHead, recovery: candidateRecovery };
190
+ }
191
+ catch (error) {
192
+ try {
193
+ await rollbackManagedWorktreeRepair(preparation, config);
194
+ }
195
+ catch (rollbackError) {
196
+ throw new Error(`${error instanceof Error ? error.message : String(error)} Recovery rollback also failed; the temporary backing was preserved for inspection: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
197
+ }
198
+ throw error;
199
+ }
200
+ }
201
+ export async function rollbackManagedWorktreeRepair(preparation, config) {
202
+ const sourceRoot = assertAllowedPath(preparation.sourceRoot, config.allowedRoots);
203
+ const root = assertAllowedPath(preparation.root, [config.worktreeRoot]);
204
+ const actualBranch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim();
205
+ if (actualBranch !== preparation.branch) {
206
+ throw new Error(`Cannot roll back recovery backing because it is on ${actualBranch} instead of ${preparation.branch}.`);
207
+ }
208
+ if ((await git(root, ["status", "--porcelain=v1"])).stdout.trim().length > 0) {
209
+ throw new Error("Cannot roll back recovery backing because it acquired working-tree changes.");
210
+ }
211
+ const head = (await git(root, ["rev-parse", "HEAD"])).stdout.trim();
212
+ if (head !== preparation.head) {
213
+ throw new Error("Cannot roll back recovery backing because its managed branch advanced after reconstruction.");
214
+ }
215
+ await git(sourceRoot, ["worktree", "remove", root]);
216
+ }
217
+ export async function cleanupManagedWorktreeState(session, config, options = {}) {
218
+ if (session.mode !== "worktree" || !session.managed || (session.status !== "active" && session.status !== "closed")) {
219
+ throw new Error(`Workspace ${session.id} is not an active or closed ForgeRelay-managed worktree Workspace.`);
220
+ }
221
+ let recoveryBefore;
222
+ try {
223
+ recoveryBefore = await inspectManagedWorktreeRecovery(session, config);
224
+ }
225
+ catch {
226
+ recoveryBefore = undefined;
227
+ }
228
+ if (!session.sourceRoot || !session.branch || !session.targetBranch || !session.branch.startsWith("forgerelay/")) {
229
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "Persisted managed-worktree ownership metadata is incomplete or cannot prove ForgeRelay branch ownership.");
230
+ }
231
+ let backing;
232
+ try {
233
+ backing = await directoryState(session.root, [config.worktreeRoot]);
234
+ }
235
+ catch {
236
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The persisted managed-worktree backing path is outside the trusted ForgeRelay worktree root or otherwise unavailable.");
237
+ }
238
+ if (backing === "present") {
239
+ return cleanupResult(session, recoveryBefore, false, false, "nothing-to-clean", "The managed worktree backing is still present; cleanup will not mutate an active or residual physical backing.");
240
+ }
241
+ const source = await sourceState(session.sourceRoot, config.allowedRoots);
242
+ if (source !== "available") {
243
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The persisted source repository is unavailable, so cleanup cannot prove Git ownership safely.");
244
+ }
245
+ const sourceRoot = assertAllowedPath(session.sourceRoot, config.allowedRoots);
246
+ const branchRef = `refs/heads/${session.branch}`;
247
+ let registrations = await worktreeRegistrations(sourceRoot);
248
+ const persistedRegistrations = await matchingPathRegistrations(registrations, session.root);
249
+ if (persistedRegistrations.length > 1) {
250
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "Multiple Git worktree registrations match the persisted backing; cleanup requires manual intervention.");
251
+ }
252
+ let registrationRemoved = false;
253
+ const persistedRegistration = persistedRegistrations[0];
254
+ if (persistedRegistration) {
255
+ if (!persistedRegistration.prunable) {
256
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The persisted backing still has an active Git worktree registration; cleanup will not remove it.");
257
+ }
258
+ if (persistedRegistration.branch !== branchRef) {
259
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "The stale Git worktree registration no longer proves ownership of the persisted ForgeRelay managed branch.");
260
+ }
261
+ try {
262
+ await git(sourceRoot, ["worktree", "remove", persistedRegistration.path]);
263
+ registrationRemoved = true;
264
+ }
265
+ catch {
266
+ return cleanupResult(session, recoveryBefore, false, false, "manual-intervention", "Git did not confirm safe removal of the stale worktree registration; no cleanup mutation was forced.");
267
+ }
268
+ registrations = await worktreeRegistrations(sourceRoot);
269
+ }
270
+ if (session.status === "active") {
271
+ const recovery = await inspectManagedWorktreeRecovery(session, config);
272
+ return cleanupResult(session, recovery, registrationRemoved, false, registrationRemoved ? "cleaned" : "nothing-to-clean", registrationRemoved
273
+ ? "Stale Git worktree registration removed; the managed branch is preserved because the Workspace remains active."
274
+ : "No stale registration is safe to remove; the managed branch is preserved because the Workspace remains active.");
275
+ }
276
+ const managedBranch = await branchState(sourceRoot, session.branch);
277
+ if (managedBranch === "missing") {
278
+ return cleanupResult(session, undefined, registrationRemoved, false, registrationRemoved ? "cleaned" : "nothing-to-clean", registrationRemoved ? undefined : "No stale registration or managed branch remains to clean.");
279
+ }
280
+ if (registrations.some((registration) => registration.branch === branchRef)) {
281
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The managed branch is still associated with a Git worktree registration and was preserved.");
282
+ }
283
+ if (options.managedBranchOwnedByOtherWorkspace) {
284
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "Another persistent Workspace still references the managed branch, so it was preserved.");
285
+ }
286
+ if (await branchState(sourceRoot, session.targetBranch) !== "present") {
287
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The intended target branch is missing, so the managed branch was preserved.");
288
+ }
289
+ const sourceBranch = await currentBranchName(sourceRoot);
290
+ if (sourceBranch !== session.targetBranch) {
291
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The source checkout is not on the persisted target branch, so the managed branch was preserved.");
292
+ }
293
+ if (!await branchIsAncestorOfTarget(sourceRoot, session.branch, session.targetBranch)) {
294
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "The managed branch contains commits not integrated into the intended target branch and was preserved.");
295
+ }
296
+ try {
297
+ await git(sourceRoot, ["branch", "-d", session.branch]);
298
+ }
299
+ catch {
300
+ return cleanupResult(session, undefined, registrationRemoved, false, "manual-intervention", "Git did not confirm safe managed-branch deletion at cleanup time; the branch was preserved.");
301
+ }
302
+ return cleanupResult(session, undefined, registrationRemoved, true, "cleaned");
303
+ }
75
304
  async function directoryState(path, allowedRoots) {
76
305
  const allowedPath = assertAllowedPath(path, allowedRoots);
77
306
  try {
@@ -156,13 +385,113 @@ function parseWorktreeRegistrations(output) {
156
385
  const path = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
157
386
  if (!path)
158
387
  return undefined;
388
+ const branch = lines.find((line) => line.startsWith("branch "))?.slice("branch ".length);
389
+ const head = lines.find((line) => line.startsWith("HEAD "))?.slice("HEAD ".length);
159
390
  return {
160
391
  path,
161
392
  prunable: lines.some((line) => line.startsWith("prunable ")),
393
+ ...(branch ? { branch } : {}),
394
+ ...(head ? { head } : {}),
162
395
  };
163
396
  })
164
397
  .filter((entry) => entry !== undefined);
165
398
  }
399
+ async function worktreeRegistrations(sourceRoot) {
400
+ return parseWorktreeRegistrations((await git(sourceRoot, ["worktree", "list", "--porcelain"])).stdout);
401
+ }
402
+ async function conflictingBranchRegistrations(registrations, branch, previousRoot, repairedRoot) {
403
+ const branchRef = `refs/heads/${branch}`;
404
+ const previousKey = await registrationPathKey(previousRoot);
405
+ const repairedKey = repairedRoot ? await registrationPathKey(repairedRoot) : undefined;
406
+ const conflicts = [];
407
+ for (const registration of registrations) {
408
+ if (registration.branch !== branchRef)
409
+ continue;
410
+ const key = await registrationPathKey(registration.path);
411
+ if (key === previousKey && registration.prunable)
412
+ continue;
413
+ if (repairedKey !== undefined && key === repairedKey && !registration.prunable)
414
+ continue;
415
+ conflicts.push(registration);
416
+ }
417
+ return conflicts;
418
+ }
419
+ async function matchingPathRegistrations(registrations, path) {
420
+ const expectedKey = await registrationPathKey(path);
421
+ const matches = [];
422
+ for (const registration of registrations) {
423
+ if (await registrationPathKey(registration.path) === expectedKey)
424
+ matches.push(registration);
425
+ }
426
+ return matches;
427
+ }
428
+ async function firstMatchingRegistration(registrations, branchRef, pathKeyValue) {
429
+ for (const registration of registrations) {
430
+ if (registration.branch !== branchRef)
431
+ continue;
432
+ if (await registrationPathKey(registration.path) === pathKeyValue)
433
+ return registration;
434
+ }
435
+ return undefined;
436
+ }
437
+ async function allocateManagedWorktreeRecoveryPath(sourceRoot, config) {
438
+ await mkdir(config.worktreeRoot, { recursive: true });
439
+ for (let attempt = 0; attempt < 16; attempt += 1) {
440
+ const path = managedWorktreePath({
441
+ worktreeRoot: config.worktreeRoot,
442
+ repoRoot: sourceRoot,
443
+ worktreeId: randomBytes(4).toString("hex"),
444
+ });
445
+ assertAllowedPath(path, [config.worktreeRoot]);
446
+ try {
447
+ await stat(path);
448
+ }
449
+ catch (error) {
450
+ if (isMissingPath(error))
451
+ return path;
452
+ throw error;
453
+ }
454
+ }
455
+ throw new Error("Could not allocate a unique managed-worktree recovery path.");
456
+ }
457
+ function manualRecovery(recovery) {
458
+ return recovery.classification === "manual-intervention"
459
+ ? recovery
460
+ : { ...recovery, classification: "manual-intervention" };
461
+ }
462
+ function cleanupResult(session, recovery, registrationRemoved, managedBranchRemoved, classification, reason) {
463
+ return {
464
+ classification,
465
+ cleaned: registrationRemoved || managedBranchRemoved,
466
+ registrationRemoved,
467
+ managedBranchRemoved,
468
+ status: session.status,
469
+ ...(recovery ? { recovery } : {}),
470
+ ...(reason ? { reason } : {}),
471
+ };
472
+ }
473
+ async function currentBranchName(cwd) {
474
+ try {
475
+ return (await git(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim() || undefined;
476
+ }
477
+ catch {
478
+ return undefined;
479
+ }
480
+ }
481
+ async function branchIsAncestorOfTarget(sourceRoot, branch, targetBranch) {
482
+ try {
483
+ await git(sourceRoot, [
484
+ "merge-base",
485
+ "--is-ancestor",
486
+ `refs/heads/${branch}`,
487
+ `refs/heads/${targetBranch}`,
488
+ ]);
489
+ return true;
490
+ }
491
+ catch {
492
+ return false;
493
+ }
494
+ }
166
495
  async function registrationPathKey(path) {
167
496
  const resolved = resolve(path);
168
497
  try {
@@ -2,6 +2,7 @@ import { stat } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { loadCapabilityGuides } from "../mcp/server/core/capabilities.js";
4
4
  import { createManagedWorktree, discardFreshManagedWorktree, } from "./git/git-worktrees.js";
5
+ import { cleanupManagedWorktreeState, inspectManagedWorktreeRecovery, prepareManagedWorktreeRepair, rollbackManagedWorktreeRepair, } from "./git/worktree-recovery.js";
5
6
  import { AccessDeniedError, assertAllowedPath } from "../mcp/filesystem/roots.js";
6
7
  import { loadSubagentProfiles } from "../subagents/profiles.js";
7
8
  import { BOOTSTRAP_CONTEXT_COMPONENTS, bootstrapContextFingerprints, resolveBootstrapContextComponents, } from "./bootstrap.js";
@@ -371,6 +372,98 @@ export class WorkspaceSessionService {
371
372
  throw error;
372
373
  }
373
374
  }
375
+ async runManagedWorktreeRecovery(workspaceId, operation) {
376
+ const store = this.store;
377
+ if (!store) {
378
+ throw new Error(`Workspace ${workspaceId} cannot use managed-worktree recovery without persistent Workspace state.`);
379
+ }
380
+ const session = store.getSession(workspaceId);
381
+ if (!session)
382
+ throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
383
+ if (operation === "cleanup") {
384
+ let managedBranchOwnedByOtherWorkspace = false;
385
+ if (session.sourceRoot && session.branch) {
386
+ const sourceKey = canonicalPersistedWorkspacePath(session.sourceRoot);
387
+ for (const candidate of store.listSessions({ mode: "worktree" })) {
388
+ if (candidate.id === session.id ||
389
+ !candidate.managed ||
390
+ candidate.branch !== session.branch ||
391
+ !candidate.sourceRoot)
392
+ continue;
393
+ if (canonicalPersistedWorkspacePath(candidate.sourceRoot) === sourceKey) {
394
+ managedBranchOwnedByOtherWorkspace = true;
395
+ break;
396
+ }
397
+ }
398
+ }
399
+ return {
400
+ workspaceId: session.id,
401
+ ...await cleanupManagedWorktreeState(session, this.config, { managedBranchOwnedByOtherWorkspace }),
402
+ };
403
+ }
404
+ const recovery = await inspectManagedWorktreeRecovery(session, this.config);
405
+ if (!recovery) {
406
+ throw new Error(`Workspace ${session.id} is not an active managed-worktree Workspace.`);
407
+ }
408
+ if (operation === "status") {
409
+ return {
410
+ workspaceId: session.id,
411
+ repaired: false,
412
+ recovery,
413
+ };
414
+ }
415
+ const prepared = await prepareManagedWorktreeRepair(session, this.config);
416
+ if (!prepared.prepared) {
417
+ return {
418
+ workspaceId: session.id,
419
+ repaired: false,
420
+ recovery: prepared.recovery,
421
+ reason: prepared.reason,
422
+ };
423
+ }
424
+ const cachedWorkspace = this.workspaces.get(session.id);
425
+ try {
426
+ this.context.forgetWorkspaceResources(session.id);
427
+ this.workspaces.delete(session.id);
428
+ const candidateSession = {
429
+ ...session,
430
+ root: prepared.root,
431
+ };
432
+ const candidateWorkspace = this.workspaceFromSession(candidateSession, false);
433
+ await this.reusedWorkspaceContext(candidateWorkspace);
434
+ store.replaceWorktreeBacking({
435
+ id: session.id,
436
+ root: prepared.root,
437
+ sourceRoot: prepared.sourceRoot,
438
+ baseRef: prepared.baseRef,
439
+ baseSha: prepared.baseSha,
440
+ branch: prepared.branch,
441
+ targetBranch: prepared.targetBranch,
442
+ });
443
+ return {
444
+ workspaceId: session.id,
445
+ repaired: true,
446
+ previousRoot: prepared.previousRoot,
447
+ root: prepared.root,
448
+ branch: prepared.branch,
449
+ targetBranch: prepared.targetBranch,
450
+ recovery: prepared.recovery,
451
+ };
452
+ }
453
+ catch (error) {
454
+ this.context.forgetWorkspaceResources(session.id);
455
+ this.workspaces.delete(session.id);
456
+ if (cachedWorkspace)
457
+ this.workspaces.set(session.id, cachedWorkspace);
458
+ try {
459
+ await rollbackManagedWorktreeRepair(prepared, this.config);
460
+ }
461
+ catch (rollbackError) {
462
+ throw new Error(`${error instanceof Error ? error.message : String(error)} Recovery rollback also failed; the temporary backing was preserved for inspection: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
463
+ }
464
+ throw error;
465
+ }
466
+ }
374
467
  getWorkspaceSession(workspaceId) {
375
468
  const session = this.store?.getSession(workspaceId);
376
469
  if (session)
@@ -316,6 +316,9 @@ export class WorkspaceRegistry {
316
316
  getWorkspace(workspaceId) {
317
317
  return this.sessions.getWorkspace(workspaceId);
318
318
  }
319
+ runManagedWorktreeRecovery(workspaceId, operation) {
320
+ return this.sessions.runManagedWorktreeRecovery(workspaceId, operation);
321
+ }
319
322
  fileToolRoots(workspace) {
320
323
  return this.context.fileToolRoots(workspace);
321
324
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -52,7 +52,7 @@
52
52
  "release:push-ready": "node scripts/release/push-ready.mjs",
53
53
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
54
54
  "start": "node dist/cli.js serve",
55
- "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/runtime/config/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/cli.test.ts",
55
+ "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/runtime/config/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/cli.test.ts",
56
56
  "typecheck": "tsc -p tsconfig.json --noEmit",
57
57
  "release:check": "node scripts/release-version.mjs check",
58
58
  "release:tag-check": "node scripts/release-version.mjs tag",