pi-crew 0.9.42 → 0.9.44
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 +147 -0
- package/README.md +14 -0
- package/dist/build-meta.json +6692 -13022
- package/dist/index.mjs +54585 -65621
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
- package/scripts/build-bundle.mjs +2 -1
- package/src/agents/agent-config.ts +1 -1
- package/src/config/config.ts +1 -1
- package/src/config/types.ts +0 -2
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/font-detect.ts +16 -3
- package/src/extension/cross-extension-rpc.ts +8 -4
- package/src/extension/registration/commands.ts +5 -1
- package/src/extension/registration/foreground-run-controller.ts +28 -0
- package/src/extension/team-tool.ts +5 -0
- package/src/prompt/prompt-runtime.ts +15 -0
- package/src/runtime/child-pi-constants.ts +42 -0
- package/src/runtime/child-pi-kill.ts +180 -0
- package/src/runtime/child-pi-spawn.ts +234 -0
- package/src/runtime/child-pi-steering.ts +128 -0
- package/src/runtime/child-pi-streams.ts +296 -0
- package/src/runtime/child-pi-transcript.ts +169 -0
- package/src/runtime/child-pi.ts +75 -837
- package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
- package/src/runtime/dwf-state-store.ts +5 -0
- package/src/runtime/dynamic-workflow-context.ts +78 -18
- package/src/runtime/dynamic-workflow-runner.ts +18 -2
- package/src/runtime/goal-evaluator.ts +59 -27
- package/src/runtime/goal-loop-runner.ts +40 -11
- package/src/runtime/live-agent-manager.ts +18 -0
- package/src/runtime/pi-json-output.ts +2 -1
- package/src/runtime/pi-spawn.ts +7 -1
- package/src/runtime/run-coalesced-task-group.ts +18 -10
- package/src/runtime/task-runner/prompt-builder.ts +2 -2
- package/src/runtime/team-runner.ts +6 -1
- package/src/state/event-log.ts +1 -0
- package/src/state/locks.ts +132 -49
- package/src/state/worker-atomic-writer.ts +1 -0
- package/src/utils/paths.ts +1 -1
- package/src/worktree/worktree-manager.ts +47 -19
package/src/state/locks.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
3
|
import * as fs from "node:fs";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { DEFAULT_LOCKS } from "../config/defaults.ts";
|
|
6
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
5
7
|
import { sleepSync } from "../utils/sleep.ts";
|
|
6
8
|
import { isSymlinkSafePath } from "./atomic-write.ts";
|
|
7
9
|
import type { TeamRunManifest } from "./types.ts";
|
|
@@ -103,41 +105,56 @@ function isLockHolderAlive(filePath: string): boolean {
|
|
|
103
105
|
*
|
|
104
106
|
* See also: SECURITY-ISSUES.md SEC-008 for documented acceptance.
|
|
105
107
|
*/
|
|
106
|
-
function readLockSnapshot(filePath: string, staleMs: number): { canSteal: boolean } {
|
|
108
|
+
function readLockSnapshot(filePath: string, staleMs: number, options?: { treatOwnPidAsStealable?: boolean }): { canSteal: boolean } {
|
|
109
|
+
const treatOwnPidAsStealable = options?.treatOwnPidAsStealable === true;
|
|
107
110
|
let stat: fs.Stats | undefined;
|
|
108
111
|
let raw: string | undefined;
|
|
109
112
|
try {
|
|
110
113
|
stat = fs.statSync(filePath);
|
|
111
114
|
raw = fs.readFileSync(filePath, "utf-8");
|
|
112
|
-
} catch {
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// FIX (CI flake): when the lock file vanishes between writeLockFile's
|
|
117
|
+
// EEXIST and our read here, the holder has released — return canSteal:true
|
|
118
|
+
// so the acquire loop retries the create instead of throwing a false
|
|
119
|
+
// "locked" error.
|
|
120
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
121
|
+
if (code === "ENOENT") {
|
|
122
|
+
return { canSteal: true };
|
|
123
|
+
}
|
|
124
|
+
// Transient I/O error — be conservative (don't steal, retry the read on
|
|
125
|
+
// next attempt).
|
|
115
126
|
return { canSteal: false };
|
|
116
127
|
}
|
|
117
128
|
// Staleness from a single snapshot.
|
|
118
129
|
let createdAt = parseCreatedAtFromLock(raw);
|
|
119
130
|
if (createdAt === undefined) createdAt = stat.mtimeMs;
|
|
120
131
|
const isStale = Date.now() - createdAt > staleMs;
|
|
121
|
-
|
|
122
|
-
let isAlive = true;
|
|
132
|
+
let holderPid: number | undefined;
|
|
133
|
+
let isAlive = true;
|
|
123
134
|
try {
|
|
124
135
|
const parsed = JSON.parse(raw) as { pid?: unknown };
|
|
125
|
-
|
|
126
|
-
if (pid !== undefined) {
|
|
127
|
-
try {
|
|
128
|
-
process.kill(pid, 0);
|
|
129
|
-
isAlive = true;
|
|
130
|
-
} catch (error) {
|
|
131
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
132
|
-
// EPERM/ESRCH → treat as not-alive (stealable), see isLockHolderAlive.
|
|
133
|
-
isAlive = false;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
+
holderPid = typeof parsed.pid === "number" ? parsed.pid : undefined;
|
|
136
137
|
} catch {
|
|
137
138
|
/* malformed payload — keep isAlive=true */
|
|
138
139
|
}
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
if (holderPid !== undefined) {
|
|
141
|
+
try {
|
|
142
|
+
process.kill(holderPid, 0);
|
|
143
|
+
isAlive = true;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
146
|
+
// EPERM/ESRCH → treat as not-alive (stealable), see isLockHolderAlive.
|
|
147
|
+
isAlive = false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Steal if stale OR holder dead. Optionally steal if holder is our own pid
|
|
151
|
+
// (used by withRunLock* which is single-process — a fresh lock with our pid
|
|
152
|
+
// between acquisitions is just a leftover from a previous call that
|
|
153
|
+
// releaseOwnLock didn't get to delete yet; safe to steal).
|
|
154
|
+
const isOurOwnHolder = holderPid === process.pid;
|
|
155
|
+
return {
|
|
156
|
+
canSteal: isStale || !isAlive || (treatOwnPidAsStealable && isOurOwnHolder),
|
|
157
|
+
};
|
|
141
158
|
}
|
|
142
159
|
|
|
143
160
|
/**
|
|
@@ -221,6 +238,34 @@ function timingSafeTokenMatch(a: string, b: string): boolean {
|
|
|
221
238
|
return timingSafeEqual(bufA, bufB);
|
|
222
239
|
}
|
|
223
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Release the lock we (this process) just acquired. Unlike releaseLock, this
|
|
243
|
+
* unconditionally removes the file because we know we created it in the same
|
|
244
|
+
* withRunLock* call — token matching is only needed for multi-process scenarios
|
|
245
|
+
* (where a different holder's token must not be deleted). Within the same process
|
|
246
|
+
* the new acquire uses a fresh randomUUID token, so token matching would falsely
|
|
247
|
+
* fail and leak the file across releases.
|
|
248
|
+
*
|
|
249
|
+
* Symlink guard is preserved: if a symlink appeared since our writeLockFile, we
|
|
250
|
+
* don't rm it (defense against attacker-planted symlinks).
|
|
251
|
+
*/
|
|
252
|
+
export function releaseOwnLock(filePath: string, _token: string): void {
|
|
253
|
+
try {
|
|
254
|
+
const stat = fs.lstatSync(filePath);
|
|
255
|
+
if (stat.isSymbolicLink()) return;
|
|
256
|
+
} catch {
|
|
257
|
+
/* ENOENT is fine */
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
fs.rmSync(filePath, { force: true });
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
263
|
+
if (code !== "ENOENT") {
|
|
264
|
+
logInternalError("lock-release-own", error, filePath, "warn");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
224
269
|
function releaseLock(filePath: string, token: string): void {
|
|
225
270
|
// FIX: Do not delete a symlink — it may have been planted by an attacker
|
|
226
271
|
// after our lock was released. A legitimate lock file should never be a
|
|
@@ -265,7 +310,9 @@ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind
|
|
|
265
310
|
}
|
|
266
311
|
// Round 26 (BUG 1): single-snapshot read closes the TOCTOU window between
|
|
267
312
|
// separate stale + alive reads (which could race stale→fresh).
|
|
268
|
-
|
|
313
|
+
// treatOwnPidAsStealable=false (this is the sync file-lock path used
|
|
314
|
+
// by withFileLockSync — never steal a fresh lock even if it has our pid).
|
|
315
|
+
const { canSteal } = readLockSnapshot(filePath, staleMs, { treatOwnPidAsStealable: false });
|
|
269
316
|
if (!canSteal) {
|
|
270
317
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
271
318
|
}
|
|
@@ -300,7 +347,13 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
|
|
|
300
347
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
301
348
|
}
|
|
302
349
|
// Round 26 (BUG 1): single-snapshot read (see sync variant).
|
|
303
|
-
|
|
350
|
+
// FIX (CI flake): treatOwnPidAsStealable=true for the run-lock async path
|
|
351
|
+
// (withRunLock). Between acquisitions in parallel-research scaffold mode,
|
|
352
|
+
// the previous releaseOwnLock leaves a microsecond window where the
|
|
353
|
+
// file still exists with our own pid. Stealing it avoids spurious 'locked'
|
|
354
|
+
// errors. The sync file-lock path (withFileLockSync) above uses false to
|
|
355
|
+
// preserve the multi-process safety guarantee.
|
|
356
|
+
const { canSteal } = readLockSnapshot(filePath, staleMs, { treatOwnPidAsStealable: true });
|
|
304
357
|
if (!canSteal) {
|
|
305
358
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
306
359
|
}
|
|
@@ -379,48 +432,78 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
|
|
|
379
432
|
}
|
|
380
433
|
}
|
|
381
434
|
|
|
435
|
+
// H-1: track re-entrant run-lock acquisitions PER ASYNC CONTEXT (not process-global).
|
|
436
|
+
// Previously a module-global Map caused a bug: when withRunLock (async) yielded at
|
|
437
|
+
// `await fn()`, a concurrent withRunLockSync for the same run — fired from a
|
|
438
|
+
// different async context (e.g. a child stdout event handler) — saw the holder's
|
|
439
|
+
// token and bypassed the file lock entirely, allowing two writers in the critical
|
|
440
|
+
// section simultaneously. AsyncLocalStorage scopes the "held" set to the current
|
|
441
|
+
// async context, so a call from a different async context no longer bypasses and
|
|
442
|
+
// properly serializes against the on-disk lock. True nested calls in the SAME
|
|
443
|
+
// async context (e.g. handleResume -> executeTeamRun -> executeTeamRunCore) still
|
|
444
|
+
// bypass via lockCtx.getStore(), avoiding the deadlock the bypass was designed for.
|
|
445
|
+
const lockCtx = new AsyncLocalStorage<Set<string>>();
|
|
446
|
+
// Round 29: parallel map for withFileLockSync re-entrance. See the comment
|
|
447
|
+
// at the top of withFileLockSync for the full deadlock mechanism.
|
|
448
|
+
const fileLockHeldByUs = new Map<string, string>(); // lockFile -> token
|
|
449
|
+
|
|
382
450
|
export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, options: RunLockOptions = {}): T {
|
|
383
451
|
const filePath = lockPath(manifest);
|
|
384
452
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
453
|
+
// H-1: re-entrance check is scoped to the current async context, not process-global.
|
|
454
|
+
// A call from a DIFFERENT async context (e.g. a child-stdout event handler that
|
|
455
|
+
// runs while an async holder is awaiting) sees no held set and properly serializes.
|
|
456
|
+
if (lockCtx.getStore()?.has(filePath)) {
|
|
457
|
+
// Re-entrant within the same async context — already hold this lock.
|
|
388
458
|
return fn();
|
|
389
459
|
}
|
|
390
460
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
391
461
|
const token = acquireLockWithRetry(filePath, staleMs, "run");
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
462
|
+
const prevHeld = lockCtx.getStore() ?? new Set<string>();
|
|
463
|
+
const newHeld = new Set(prevHeld);
|
|
464
|
+
newHeld.add(filePath);
|
|
465
|
+
// Enter a new async context (or extend the current one) carrying the held set.
|
|
466
|
+
// On exit (sync return OR async resume after an awaited child), the context is
|
|
467
|
+
// restored — so the held set only contains locks acquired within fn()'s subtree.
|
|
468
|
+
return lockCtx.run(newHeld, () => {
|
|
469
|
+
try {
|
|
470
|
+
return fn();
|
|
471
|
+
} finally {
|
|
472
|
+
// FIX (CI flake): releaseLock uses token matching to prevent the
|
|
473
|
+
// "losing contender wipes winner's lock" race in multi-process scenarios.
|
|
474
|
+
// But within withRunLockSync/withRunLock (same process), the new acquire
|
|
475
|
+
// uses a FRESH random token — the previous stored token doesn't match the
|
|
476
|
+
// current token — and the lock file is never removed. Repeat acquisitions
|
|
477
|
+
// then keep failing with EEXIST because the file lingers.
|
|
478
|
+
// Since withRunLock* is always single-process, unconditionally delete
|
|
479
|
+
// (symlink guard is still applied for safety).
|
|
480
|
+
releaseOwnLock(filePath, token);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
399
483
|
}
|
|
400
484
|
|
|
401
|
-
// Track re-entrant lock acquisitions per lock file path. When a lock is
|
|
402
|
-
// already held by this call stack (handleResume -> executeTeamRun ->
|
|
403
|
-
// executeTeamRunCore), we skip re-acquisition to avoid deadlock.
|
|
404
|
-
const runLockHeldByUs = new Map<string, string>(); // filePath -> token
|
|
405
|
-
// Round 29: parallel map for withFileLockSync re-entrance. See the comment
|
|
406
|
-
// at the top of withFileLockSync for the full deadlock mechanism.
|
|
407
|
-
const fileLockHeldByUs = new Map<string, string>(); // lockFile -> token
|
|
408
|
-
|
|
409
485
|
export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promise<T>, options: RunLockOptions = {}): Promise<T> {
|
|
410
486
|
const filePath = lockPath(manifest);
|
|
411
487
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
// Re-entrant: already hold this lock, just run the callback.
|
|
488
|
+
if (lockCtx.getStore()?.has(filePath)) {
|
|
489
|
+
// Re-entrant within the same async context.
|
|
415
490
|
return await fn();
|
|
416
491
|
}
|
|
417
492
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
418
493
|
const token = await acquireLockWithRetryAsync(filePath, staleMs, "run");
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
494
|
+
const prevHeld = lockCtx.getStore() ?? new Set<string>();
|
|
495
|
+
const newHeld = new Set(prevHeld);
|
|
496
|
+
newHeld.add(filePath);
|
|
497
|
+
// AsyncLocalStorage propagates across await boundaries, so any code that
|
|
498
|
+
// resumes after an await inside fn() (including microtasks, timers, and
|
|
499
|
+
// awaited child Pi results) still sees newHeld.
|
|
500
|
+
return await lockCtx.run(newHeld, async () => {
|
|
501
|
+
try {
|
|
502
|
+
return await fn();
|
|
503
|
+
} finally {
|
|
504
|
+
// FIX (CI flake): see withRunLockSync above — use releaseOwnLock for
|
|
505
|
+
// unconditional deletion of the lock file we created (same process).
|
|
506
|
+
releaseOwnLock(filePath, token);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
426
509
|
}
|
|
@@ -137,6 +137,7 @@ function getWorker(): Worker {
|
|
|
137
137
|
// Reject ALL pending requests — worker died.
|
|
138
138
|
for (const [, entry] of pending) entry.reject(error);
|
|
139
139
|
pending.clear();
|
|
140
|
+
worker = undefined;
|
|
140
141
|
});
|
|
141
142
|
worker.unref(); // don't keep event loop alive (unless tests request it)
|
|
142
143
|
if (keepRefForTests) worker.ref();
|
package/src/utils/paths.ts
CHANGED
|
@@ -44,7 +44,7 @@ export function userPiRoot(): string {
|
|
|
44
44
|
// "undefined/.pi/agent" relative to cwd and silently create a junk "undefined/"
|
|
45
45
|
// tree. Treat the literal "undefined" (and empty) as unset and fall back to
|
|
46
46
|
// os.homedir().
|
|
47
|
-
const rawHome = process.env.PI_TEAMS_HOME?.trim();
|
|
47
|
+
const rawHome = (process.env.PI_TEAMS_HOME ?? process.env.PI_CREW_HOME)?.trim();
|
|
48
48
|
const home = rawHome && rawHome !== "undefined" ? rawHome : os.homedir();
|
|
49
49
|
const resolved = path.join(home, ".pi", "agent");
|
|
50
50
|
|
|
@@ -134,7 +134,11 @@ export function findGitRoot(cwd: string): string {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
export function assertCleanLeader(repoRoot: string): void {
|
|
137
|
-
|
|
137
|
+
// H-7 follow-up: --untracked-files=no so pi-crew's own auto-created .gitignore
|
|
138
|
+
// (and any other untracked files the user hasn't staged) doesn't block worktree mode.
|
|
139
|
+
// The worktree contract is "no tracked changes" — untracked files are safe since
|
|
140
|
+
// they're either in .gitignore or the user can decide later.
|
|
141
|
+
const status = git(repoRoot, ["status", "--porcelain", "--untracked-files=no"]);
|
|
138
142
|
if (status.trim()) {
|
|
139
143
|
throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'.");
|
|
140
144
|
}
|
|
@@ -168,7 +172,9 @@ export function clearCleanLeaderCache(): void {
|
|
|
168
172
|
|
|
169
173
|
export async function assertCleanLeaderAsync(repoRoot: string): Promise<void> {
|
|
170
174
|
if (_cleanLeaderCache.has(repoRoot)) return;
|
|
171
|
-
|
|
175
|
+
// H-7 follow-up: --untracked-files=no so pi-crew's own auto-created .gitignore
|
|
176
|
+
// (and any other untracked files the user hasn't staged) doesn't block worktree mode.
|
|
177
|
+
const status = await gitAsync(repoRoot, ["status", "--porcelain", "--untracked-files=no"]);
|
|
172
178
|
if (status.trim()) {
|
|
173
179
|
throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'.");
|
|
174
180
|
}
|
|
@@ -558,7 +564,7 @@ export function overlaySeedPaths(repoRoot: string, worktreePath: string, seedPat
|
|
|
558
564
|
* recovered via `git apply` / manual restore. Best-effort: a snapshot failure
|
|
559
565
|
* only logs (it must not block the clean-slate reuse flow).
|
|
560
566
|
*/
|
|
561
|
-
function snapshotDirtyWorktree(manifest: TeamRunManifest, task: TeamTaskState, worktreePath: string, dirtyStatus: string):
|
|
567
|
+
function snapshotDirtyWorktree(manifest: TeamRunManifest, task: TeamTaskState, worktreePath: string, dirtyStatus: string): boolean {
|
|
562
568
|
try {
|
|
563
569
|
const parts: string[] = [
|
|
564
570
|
`# Worktree recovery snapshot`,
|
|
@@ -596,12 +602,14 @@ function snapshotDirtyWorktree(manifest: TeamRunManifest, task: TeamTaskState, w
|
|
|
596
602
|
producer: "worktree-manager.snapshotDirtyWorktree",
|
|
597
603
|
retention: "run",
|
|
598
604
|
});
|
|
605
|
+
return true;
|
|
599
606
|
} catch (err) {
|
|
600
607
|
logInternalError(
|
|
601
608
|
"worktree.recovery.snapshotFailed",
|
|
602
609
|
err instanceof Error ? err : new Error(String(err)),
|
|
603
610
|
`runId=${manifest.runId}, taskId=${task.id}`,
|
|
604
611
|
);
|
|
612
|
+
return false;
|
|
605
613
|
}
|
|
606
614
|
}
|
|
607
615
|
|
|
@@ -722,14 +730,24 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
|
|
|
722
730
|
if (dirtyStatus.trim()) {
|
|
723
731
|
// Snapshot uncommitted work to a recovery artifact BEFORE discarding, so the
|
|
724
732
|
// previous run's changes are never silently destroyed on reuse.
|
|
725
|
-
snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus);
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
+
const snapshotOk = snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus);
|
|
734
|
+
if (snapshotOk) {
|
|
735
|
+
logInternalError(
|
|
736
|
+
"worktree.reused.dirty",
|
|
737
|
+
new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath} (snapshot saved to artifacts)`),
|
|
738
|
+
`runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
|
|
739
|
+
);
|
|
740
|
+
git(worktreePath, ["checkout", "--", "."]);
|
|
741
|
+
git(worktreePath, ["clean", "-fd"]);
|
|
742
|
+
} else {
|
|
743
|
+
logInternalError(
|
|
744
|
+
"worktree.reused.dirtyPreserved",
|
|
745
|
+
new Error(
|
|
746
|
+
`Snapshot failed — preserving dirty worktree at ${worktreePath} (skipping git checkout/clean to prevent data loss)`,
|
|
747
|
+
),
|
|
748
|
+
`runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
733
751
|
}
|
|
734
752
|
// Overlay seed paths from config + step-level seedPaths (reused worktree)
|
|
735
753
|
const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
|
|
@@ -869,14 +887,24 @@ export async function prepareTaskWorkspaceAsync(
|
|
|
869
887
|
}
|
|
870
888
|
const dirtyStatus = await gitAsync(worktreePath, ["status", "--porcelain"]);
|
|
871
889
|
if (dirtyStatus.trim()) {
|
|
872
|
-
snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus);
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
890
|
+
const snapshotOk = snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus);
|
|
891
|
+
if (snapshotOk) {
|
|
892
|
+
logInternalError(
|
|
893
|
+
"worktree.reused.dirty",
|
|
894
|
+
new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath} (snapshot saved to artifacts)`),
|
|
895
|
+
`runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
|
|
896
|
+
);
|
|
897
|
+
await gitAsync(worktreePath, ["checkout", "--", "."]);
|
|
898
|
+
await gitAsync(worktreePath, ["clean", "-fd"]);
|
|
899
|
+
} else {
|
|
900
|
+
logInternalError(
|
|
901
|
+
"worktree.reused.dirtyPreserved",
|
|
902
|
+
new Error(
|
|
903
|
+
`Snapshot failed — preserving dirty worktree at ${worktreePath} (skipping git checkout/clean to prevent data loss)`,
|
|
904
|
+
),
|
|
905
|
+
`runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
880
908
|
}
|
|
881
909
|
const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
|
|
882
910
|
const mergedReused = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
|