agents-pod 0.3.1 → 0.3.4

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/src/doctor.ts CHANGED
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { constants } from "node:fs";
3
3
  import { access, readdir, readFile, stat } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
+ import { adapterFacts, readAdapters, type AdapterRecord } from "./adapters.ts";
5
6
  import { readListeners, type ListenerRecord } from "./listeners.ts";
6
7
  import { readLock } from "./locks.ts";
7
8
  import { loadNotifyConfig } from "./notify/config.ts";
@@ -89,6 +90,20 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
89
90
  } else {
90
91
  const active = registry.pods.filter((pod) => pod.state === "active").length;
91
92
  storeLines.push({ mark: "ok", text: `${registry.pods.length} pods, ${active} active` });
93
+ // A Pod Record bound to no Listener has no other line to carry its path
94
+ // (ADR 0028): terminal Pods are exempt, they block no conversation.
95
+ const bound = new Set(
96
+ listeners.filter((listener) => listener.mode === "pod").map((listener) => listener.podId),
97
+ );
98
+ for (const pod of registry.pods) {
99
+ if (pod.state === "done" || pod.state === "cancelled" || bound.has(pod.id)) continue;
100
+ if (await exists(pod.project)) continue;
101
+ storeLines.push({
102
+ mark: "bad",
103
+ text: `pod ${pod.id}: its project ${pod.project} does not exist`,
104
+ detail: [`next: agents-pod project move ${pod.project} <new>`],
105
+ });
106
+ }
92
107
  }
93
108
  const lock = await readLock(reconcileLockPath());
94
109
  if (lock !== undefined) {
@@ -102,6 +117,29 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
102
117
 
103
118
  sections.push({ title: "pass", lines: await passLines(lastPass, now, sources, listeners, registry.pods) });
104
119
  sections.push({ title: "intake", lines: await intakeLines(sources, listeners, now) });
120
+ // A corrupt provenance file is a fact, not silence: swallowing it would
121
+ // restore exactly the blindness adapters.json exists to end (ADR 0029).
122
+ let adapters: AdapterRecord[] = [];
123
+ let adaptersError: string | undefined;
124
+ try {
125
+ adapters = await readAdapters();
126
+ } catch (error) {
127
+ adaptersError = (error as Error).message;
128
+ }
129
+ if (adaptersError !== undefined) {
130
+ sections.push({
131
+ title: "adapters",
132
+ lines: [
133
+ {
134
+ mark: "note",
135
+ text: `adapters.json is unreadable: ${adaptersError}`,
136
+ detail: ["next: agents-pod adapter sync <name> --apply rewrites it"],
137
+ },
138
+ ],
139
+ });
140
+ } else if (adapters.length > 0) {
141
+ sections.push({ title: "adapters", lines: await adapterLines(adapters) });
142
+ }
105
143
  sections.push({ title: "listeners", lines: await listenerLines(listeners, sources, registry.pods) });
106
144
  sections.push({ title: "outbound", lines: await outboundLines(deliverer, registry.pods) });
107
145
  sections.push({ title: "notify", lines: notifyLines(notify, warnings, env) });
@@ -249,6 +287,8 @@ async function listenerLines(
249
287
  const lines: DoctorLine[] = [];
250
288
  for (const listener of listeners) {
251
289
  const problems: string[] = [];
290
+ /** The path a `project move` would fix; `undefined` means "not a path problem". */
291
+ let deadPath: string | undefined;
252
292
  const origin =
253
293
  listener.source !== undefined
254
294
  ? `source ${listener.source}`
@@ -265,15 +305,32 @@ async function listenerLines(
265
305
  }
266
306
  if (listener.project !== undefined && !(await exists(listener.project))) {
267
307
  problems.push(`its project ${listener.project} does not exist`);
308
+ deadPath = listener.project;
268
309
  }
269
310
  } else if (!pods.some((pod) => pod.id === listener.podId)) {
270
311
  problems.push(`its Pod ${listener.podId ?? "(none)"} is not in the registry`);
312
+ } else {
313
+ // #24: a bound Pod's project is what a wake-up starts in, so a renamed
314
+ // directory makes the whole channel inert while this line used to read ✓.
315
+ const pod = pods.find((candidate) => candidate.id === listener.podId)!;
316
+ if (!(await exists(pod.project))) {
317
+ problems.push(`its Pod ${pod.id} project ${pod.project} does not exist`);
318
+ deadPath = pod.project;
319
+ } else if (!(await exists(join(pod.project, ".agents", "pods", pod.id)))) {
320
+ problems.push(`its Pod ${pod.id} workspace is missing under ${pod.project}`);
321
+ }
271
322
  }
272
323
  if (problems.length > 0) {
273
324
  lines.push({
274
325
  mark: "bad",
275
326
  text: `${listener.id}: ${problems.join("; ")}`,
276
- detail: [`next: agents-pod listener rm ${listener.id}`, " then add it again with a path that exists"],
327
+ detail:
328
+ deadPath === undefined
329
+ ? ["next: agents-pod listener rm " + listener.id, " then add it again with a path that exists"]
330
+ : [
331
+ `next: agents-pod project move ${deadPath} <new>`,
332
+ " a renamed repository carries <project>/.agents/pods/<id> with it (ADR 0028)",
333
+ ],
277
334
  });
278
335
  continue;
279
336
  }
@@ -292,6 +349,23 @@ async function listenerLines(
292
349
  return lines;
293
350
  }
294
351
 
352
+ /**
353
+ * Provenance, not behaviour (issue #26): an adapter's `--check` proves it
354
+ * answers, never that the package's fix reached the copy. Only registered
355
+ * copies appear — an unregistered directory is not something core watches.
356
+ */
357
+ async function adapterLines(adapters: AdapterRecord[]): Promise<DoctorLine[]> {
358
+ const lines: DoctorLine[] = [];
359
+ for (const fact of await adapterFacts(adapters)) {
360
+ lines.push({
361
+ mark: fact.mark,
362
+ text: fact.text,
363
+ ...(fact.next === undefined ? {} : { detail: [fact.next] }),
364
+ });
365
+ }
366
+ return lines;
367
+ }
368
+
295
369
  async function outboundLines(deliverer: DelivererRecord | undefined, pods: PodRecord[]): Promise<DoctorLine[]> {
296
370
  const lines: DoctorLine[] = [];
297
371
  const { pending, dead } = await outboxFacts(pods);
@@ -99,10 +99,11 @@ export function registerManagementTools(pi: ExtensionAPI): void {
99
99
  name: "spawn_pod",
100
100
  label: "Spawn Pod",
101
101
  description:
102
- "Spawn a pod: a standing unit that outlives this session and wakes fresh agents inside it, one context window per wake-up, for as long as its goal stays open. A pod does not finish on its own — cancel it, or retire the goal, to end it: a router, a watcher, an inbox server, a multi-day refactor are all one pod. A pod is fed from a chat or an inbox (a source, e.g. feishu, plus a listener bound to it), sleeps between batches, and routes work to other pods by writing inbox items — a pod never spawns pods. Returns the pod id.",
102
+ "Spawn a pod: a standing unit that outlives this session and wakes fresh agents inside it, one context window per wake-up, for as long as its goal stays open. A pod does not finish on its own — cancel it, or retire the goal, to end it: a router, a watcher, an inbox server, a multi-day refactor are all one pod. A pod is fed from a chat or an inbox (a source, e.g. feishu, plus a listener bound to it), sleeps between batches, and routes work to other pods by writing inbox items — a pod never spawns pods. A pod has NO CLOCK: it never runs at 9:00 or every 30 minutes, it wakes when it has work. Returns the pod id.",
103
103
  promptSnippet: "Spawn a standing pod for work that outlives this session",
104
104
  promptGuidelines: [
105
- "Call spawn_pod as your FIRST tool call when the user asks for anything standing or recurring — 常驻/一直跑/每天/监听/收消息/路由/服务某个 inbox/until I say stop. Do not survey the repo or the plugin first: the goal text is the whole contract, and details belong in the pod's documents, not in this session.",
105
+ "Decide by the signal. A TIME rule — 每天, 每小时, 每 30 分钟, 到点, 明天提醒我 — is a schedule: use schedule_task / agents-cron, which owns when work starts. A MESSAGE or a ROLE that must stay alive — 常驻, 一直跑, 监听, 收消息, 路由, 服务某个 inbox, 直到我说停, one job spanning days — is a pod: call spawn_pod as your FIRST tool call, without surveying the repo or the plugin first.",
106
+ "Never put a clock in a pod's goal. If the ask needs 09:00, the schedule owns the time and a pod owns the work it hands over; a pod is woken by intake, never by the clock.",
106
107
  "A pod is the right shape for a role, not only for a big job: a router that judges incoming messages, a watcher, an inbox server.",
107
108
  "A pod's agents share documents, not this conversation: put everything the successor must know into the goal text.",
108
109
  "spawn_pod returns a pod id; report that id back to the user so they can run /pod status on it. The pod owns the work from there — do not start doing its work in this session.",
package/src/paths.ts CHANGED
@@ -36,6 +36,11 @@ export function sourcesFile(): string {
36
36
  return join(storeRoot(), "sources.json");
37
37
  }
38
38
 
39
+ /** Registered adapter copies and their provenance (issue #26): beside, not inside. */
40
+ export function adaptersFile(): string {
41
+ return join(storeRoot(), "adapters.json");
42
+ }
43
+
39
44
  /** The single outbound delivery command, beside the Source records (ADR 0025). */
40
45
  export function delivererFile(): string {
41
46
  return join(storeRoot(), "deliverer.json");
@@ -0,0 +1,395 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { cp, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { readListeners } from "./listeners.ts";
5
+ import { listenersFile, podDir, podsFile, projectsFile } from "./paths.ts";
6
+ import { readProjects } from "./projects.ts";
7
+ import { readPods, withRegistryLock } from "./registry.ts";
8
+
9
+ export type MoveArtifact = "present" | "missing" | "moved";
10
+
11
+ export interface ProjectMovePod {
12
+ id: string;
13
+ from: string;
14
+ to: string;
15
+ artifact: MoveArtifact;
16
+ }
17
+
18
+ export interface ProjectMoveListener {
19
+ id: string;
20
+ from: string;
21
+ to: string;
22
+ }
23
+
24
+ export interface ProjectMoveCapacity {
25
+ from: string;
26
+ to: string;
27
+ maxActive: number;
28
+ }
29
+
30
+ export interface ProjectMoveRefusal {
31
+ reason: string;
32
+ }
33
+
34
+ export interface ProjectMovePlan {
35
+ oldPath: string;
36
+ newPath: string;
37
+ moveArtifacts: boolean;
38
+ pods: ProjectMovePod[];
39
+ listeners: ProjectMoveListener[];
40
+ projects: ProjectMoveCapacity[];
41
+ refusals: ProjectMoveRefusal[];
42
+ matched: number;
43
+ }
44
+
45
+ export interface ProjectMoveResult {
46
+ files: Array<{ name: "pods.json" | "listeners.json" | "projects.json"; changed: number }>;
47
+ }
48
+
49
+ async function exists(path: string): Promise<boolean> {
50
+ return stat(path).then(
51
+ () => true,
52
+ () => false,
53
+ );
54
+ }
55
+
56
+ /**
57
+ * A project path is a location, not Pod identity (ADR 0028). The plan is a
58
+ * read-only snapshot; apply re-reads every record under the registry lock.
59
+ */
60
+ export async function planProjectMove(
61
+ oldPath: string,
62
+ newPath: string,
63
+ { moveArtifacts = false }: { moveArtifacts?: boolean },
64
+ ): Promise<ProjectMovePlan> {
65
+ const oldRoot = resolve(oldPath);
66
+ const newRoot = resolve(newPath);
67
+ const [pods, listeners, projects] = await Promise.all([
68
+ readPods(),
69
+ readListeners(),
70
+ readProjects(),
71
+ ]);
72
+
73
+ const movingPods = pods.filter((pod) => resolve(pod.project) === oldRoot);
74
+ const movingListeners = listeners.filter(
75
+ (listener) =>
76
+ listener.mode === "template" &&
77
+ listener.project !== undefined &&
78
+ resolve(listener.project) === oldRoot,
79
+ );
80
+ const movingProjects = projects.filter((project) => resolve(project.project) === oldRoot);
81
+ const matched = movingPods.length + movingListeners.length + movingProjects.length;
82
+ const plan: ProjectMovePlan = {
83
+ oldPath: oldRoot,
84
+ newPath: newRoot,
85
+ moveArtifacts,
86
+ pods: [],
87
+ listeners: movingListeners.map((listener) => ({
88
+ id: listener.id,
89
+ from: oldRoot,
90
+ to: newRoot,
91
+ })),
92
+ projects: movingProjects.map((project) => ({
93
+ from: oldRoot,
94
+ to: newRoot,
95
+ maxActive: project.maxActive,
96
+ })),
97
+ refusals: [],
98
+ matched,
99
+ };
100
+
101
+ // An empty old binding is already the desired end state. In particular, a
102
+ // repeated apply must not mistake the records just moved to newPath for an
103
+ // ambiguous target.
104
+ if (matched === 0) return plan;
105
+
106
+ for (const pod of pods) {
107
+ if (resolve(pod.project) === newRoot) {
108
+ plan.refusals.push({
109
+ reason: `target ${newRoot} is already bound to pod ${pod.id}; choose a different new path`,
110
+ });
111
+ }
112
+ }
113
+ for (const listener of listeners) {
114
+ if (
115
+ listener.mode === "template" &&
116
+ listener.project !== undefined &&
117
+ resolve(listener.project) === newRoot
118
+ ) {
119
+ plan.refusals.push({
120
+ reason: `target ${newRoot} is already bound to template listener ${listener.id}; choose a different new path`,
121
+ });
122
+ }
123
+ }
124
+ if (projects.some((project) => resolve(project.project) === newRoot)) {
125
+ plan.refusals.push({
126
+ reason: `target ${newRoot} is already bound to a capacity record; choose a different new path`,
127
+ });
128
+ }
129
+
130
+ for (const pod of movingPods) {
131
+ const from = podDir(oldRoot, pod.id);
132
+ const to = podDir(newRoot, pod.id);
133
+ const present = await exists(to);
134
+ const artifact: MoveArtifact = present ? "present" : moveArtifacts ? "moved" : "missing";
135
+ plan.pods.push({ id: pod.id, from, to, artifact });
136
+ if (artifact === "missing") {
137
+ plan.refusals.push({
138
+ reason: `pod ${pod.id}: artifacts missing at ${to} (pass --move-artifacts)`,
139
+ });
140
+ }
141
+ }
142
+
143
+ // Normally an existing old root means the operator has not moved the tree.
144
+ // --move-artifacts is the explicit exception: its source workspace must
145
+ // still exist under oldRoot so apply can carry it to newRoot.
146
+ if (!moveArtifacts && (await exists(oldRoot))) {
147
+ plan.refusals.push({
148
+ reason: `old path ${oldRoot} still exists; move it to ${newRoot} before relocating records`,
149
+ });
150
+ }
151
+
152
+ return plan;
153
+ }
154
+
155
+ interface RegistryUpdate {
156
+ name: "pods.json" | "listeners.json" | "projects.json";
157
+ file: string;
158
+ changed: number;
159
+ body: string;
160
+ }
161
+
162
+ interface StagedUpdate extends RegistryUpdate {
163
+ tmp: string;
164
+ original: string | undefined;
165
+ committed: boolean;
166
+ }
167
+
168
+ async function readOriginal(file: string): Promise<string | undefined> {
169
+ try {
170
+ return await readFile(file, "utf8");
171
+ } catch (error) {
172
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ async function stageUpdate(update: RegistryUpdate): Promise<StagedUpdate> {
178
+ await mkdir(dirname(update.file), { recursive: true });
179
+ const tmp = `${update.file}.${randomBytes(4).toString("hex")}.tmp`;
180
+ const original = await readOriginal(update.file);
181
+ await writeFile(tmp, update.body, { flag: "wx" });
182
+ return { ...update, tmp, original, committed: false };
183
+ }
184
+
185
+ async function restoreOriginal(update: StagedUpdate): Promise<void> {
186
+ if (update.original === undefined) {
187
+ await rm(update.file, { force: true });
188
+ return;
189
+ }
190
+ const tmp = `${update.file}.${randomBytes(4).toString("hex")}.tmp`;
191
+ try {
192
+ await writeFile(tmp, update.original, { flag: "wx" });
193
+ await rename(tmp, update.file);
194
+ } catch (error) {
195
+ await rm(tmp, { force: true }).catch(() => undefined);
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ async function moveDirectory(from: string, to: string): Promise<void> {
201
+ await mkdir(dirname(to), { recursive: true });
202
+ try {
203
+ await rename(from, to);
204
+ } catch (error) {
205
+ if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
206
+ // rename cannot cross filesystems. Copy the complete tree before removing
207
+ // the source so an EXDEV move has the same all-or-nothing visible result.
208
+ await cp(from, to, { recursive: true, force: false, errorOnExist: true });
209
+ await rm(from, { recursive: true, force: true });
210
+ }
211
+ }
212
+
213
+ function changedCount<T>(before: T[], after: T[]): number {
214
+ let changed = 0;
215
+ for (let index = 0; index < before.length; index += 1) {
216
+ if (before[index] !== after[index]) changed += 1;
217
+ }
218
+ return changed;
219
+ }
220
+
221
+ /**
222
+ * Commit all three registries as one lock-held operation (ADRs 0018, 0028).
223
+ * Every complete JSON body and temp file is prepared before an artifact or
224
+ * registry changes. A later failure restores earlier renames and moved dirs.
225
+ */
226
+ export async function applyProjectMove(plan: ProjectMovePlan): Promise<ProjectMoveResult> {
227
+ if (plan.matched === 0) return { files: [] };
228
+ if (plan.refusals.length > 0) {
229
+ throw new Error(`project move refused: ${plan.refusals.map((refusal) => refusal.reason).join("; ")}`);
230
+ }
231
+
232
+ const applied = await withRegistryLock(async (): Promise<ProjectMoveResult> => {
233
+ const [pods, listeners, projects] = await Promise.all([
234
+ readPods(),
235
+ readListeners(),
236
+ readProjects(),
237
+ ]);
238
+ const podIds = new Set(plan.pods.map((pod) => pod.id));
239
+ const listenerIds = new Set(plan.listeners.map((listener) => listener.id));
240
+
241
+ // "The target is already bound" was decided by the dry run, outside this
242
+ // lock: a spawn or a second listener could have bound it in between, and
243
+ // merging two projects' records into one path is the ambiguity the refusal
244
+ // exists for. Re-read the fact instead of trusting the plan.
245
+ const foreignPod = pods.find(
246
+ (pod) => resolve(pod.project) === plan.newPath && !podIds.has(pod.id),
247
+ );
248
+ if (foreignPod !== undefined) {
249
+ throw new Error(
250
+ `target ${plan.newPath} is now bound to pod ${foreignPod.id}; nothing was moved — re-run project move`,
251
+ );
252
+ }
253
+ const foreignListener = listeners.find(
254
+ (listener) =>
255
+ listener.mode === "template" &&
256
+ listener.project !== undefined &&
257
+ resolve(listener.project) === plan.newPath &&
258
+ !listenerIds.has(listener.id),
259
+ );
260
+ if (foreignListener !== undefined) {
261
+ throw new Error(
262
+ `target ${plan.newPath} is now bound to template listener ${foreignListener.id}; nothing was moved — re-run project move`,
263
+ );
264
+ }
265
+ if (projects.some((project) => resolve(project.project) === plan.newPath)) {
266
+ throw new Error(
267
+ `target ${plan.newPath} is now bound to a capacity record; nothing was moved — re-run project move`,
268
+ );
269
+ }
270
+
271
+ const nextPods = pods.map((pod) =>
272
+ podIds.has(pod.id) && resolve(pod.project) === plan.oldPath
273
+ ? { ...pod, project: plan.newPath }
274
+ : pod,
275
+ );
276
+ const nextListeners = listeners.map((listener) =>
277
+ listenerIds.has(listener.id) &&
278
+ listener.mode === "template" &&
279
+ listener.project !== undefined &&
280
+ resolve(listener.project) === plan.oldPath
281
+ ? { ...listener, project: plan.newPath }
282
+ : listener,
283
+ );
284
+ const nextProjects = projects.map((project) =>
285
+ plan.projects.length > 0 && resolve(project.project) === plan.oldPath
286
+ ? { ...project, project: plan.newPath }
287
+ : project,
288
+ );
289
+
290
+ const updates: RegistryUpdate[] = [];
291
+ const podChanges = changedCount(pods, nextPods);
292
+ const listenerChanges = changedCount(listeners, nextListeners);
293
+ const projectChanges = changedCount(projects, nextProjects);
294
+ if (podChanges > 0) {
295
+ updates.push({
296
+ name: "pods.json",
297
+ file: podsFile(),
298
+ changed: podChanges,
299
+ body: `${JSON.stringify(nextPods, null, 2)}\n`,
300
+ });
301
+ }
302
+ if (listenerChanges > 0) {
303
+ updates.push({
304
+ name: "listeners.json",
305
+ file: listenersFile(),
306
+ changed: listenerChanges,
307
+ body: `${JSON.stringify(nextListeners, null, 2)}\n`,
308
+ });
309
+ }
310
+ if (projectChanges > 0) {
311
+ updates.push({
312
+ name: "projects.json",
313
+ file: projectsFile(),
314
+ changed: projectChanges,
315
+ body: `${JSON.stringify(nextProjects, null, 2)}\n`,
316
+ });
317
+ }
318
+
319
+ const changedPodIds = new Set(
320
+ plan.pods
321
+ .filter((pod) => {
322
+ const before = pods.find((candidate) => candidate.id === pod.id);
323
+ return before !== undefined && resolve(before.project) === plan.oldPath;
324
+ })
325
+ .map((pod) => pod.id),
326
+ );
327
+ const artifactMoves = plan.pods.filter(
328
+ (pod) => pod.artifact === "moved" && changedPodIds.has(pod.id),
329
+ );
330
+ if (updates.length === 0 && artifactMoves.length === 0) return { files: [] };
331
+
332
+ // Re-check the dry-run's filesystem facts before any write. A workspace
333
+ // that vanished between planning and apply must not become an empty Pod.
334
+ for (const pod of plan.pods) {
335
+ if (pod.artifact === "present" && changedPodIds.has(pod.id) && !(await exists(pod.to))) {
336
+ throw new Error(`pod ${pod.id}: artifacts missing at ${pod.to}; run project move again`);
337
+ }
338
+ }
339
+
340
+ const staged: StagedUpdate[] = [];
341
+ const moved: ProjectMovePod[] = [];
342
+ try {
343
+ for (const update of updates) staged.push(await stageUpdate(update));
344
+ for (const pod of artifactMoves) {
345
+ if (!(await exists(pod.from))) {
346
+ throw new Error(`pod ${pod.id}: artifacts missing at ${pod.from}`);
347
+ }
348
+ if (await exists(pod.to)) {
349
+ throw new Error(`pod ${pod.id}: artifacts already exist at ${pod.to}`);
350
+ }
351
+ await moveDirectory(pod.from, pod.to);
352
+ moved.push(pod);
353
+ }
354
+ for (const update of staged) {
355
+ await rename(update.tmp, update.file);
356
+ update.committed = true;
357
+ }
358
+ } catch (error) {
359
+ const rollbackErrors: unknown[] = [];
360
+ for (const update of [...staged].reverse()) {
361
+ if (!update.committed) continue;
362
+ try {
363
+ await restoreOriginal(update);
364
+ } catch (rollbackError) {
365
+ rollbackErrors.push(rollbackError);
366
+ }
367
+ }
368
+ for (const pod of [...moved].reverse()) {
369
+ try {
370
+ await moveDirectory(pod.to, pod.from);
371
+ } catch (rollbackError) {
372
+ rollbackErrors.push(rollbackError);
373
+ }
374
+ }
375
+ if (rollbackErrors.length > 0) {
376
+ throw new AggregateError(
377
+ [error, ...rollbackErrors],
378
+ "project move failed and rollback was incomplete",
379
+ );
380
+ }
381
+ throw error;
382
+ } finally {
383
+ for (const update of staged) {
384
+ if (!update.committed) await rm(update.tmp, { force: true }).catch(() => undefined);
385
+ }
386
+ }
387
+
388
+ return {
389
+ files: updates.map((update) => ({ name: update.name, changed: update.changed })),
390
+ };
391
+ });
392
+
393
+ if (applied === undefined) throw new Error("registry lock unavailable: project move not applied");
394
+ return applied;
395
+ }