pi-crew 0.11.1 → 0.11.2
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 +39 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +744 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +1 -3
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +193 -36
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +23 -0
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +23 -0
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +35 -0
- package/src/extension/registration/lifecycle-handlers.ts +91 -30
- package/src/extension/registration/lifecycle.ts +75 -10
- package/src/extension/registration/observability.ts +98 -35
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/wire-cross-extension.ts +28 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal.ts +4 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +87 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +17 -11
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +5 -1
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/recovery/crash-recovery.ts +25 -1
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +142 -45
- package/src/runtime/task-runner.ts +21 -1
- package/src/runtime/team-runner.ts +38 -1
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +140 -15
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +112 -22
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +11 -2
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/run-dashboard.ts +98 -5
- package/src/ui/run-snapshot-cache.ts +18 -1
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
|
@@ -80,7 +80,11 @@ function isLockHolderAlive(filePath: string): boolean {
|
|
|
80
80
|
*
|
|
81
81
|
* Returns `{ canSteal: true }` if the lock is stale OR the holder is dead
|
|
82
82
|
* (safe to forcibly remove); `{ canSteal: false }` if it is fresh AND held by
|
|
83
|
-
* a live process (must keep waiting).
|
|
83
|
+
* a live process (must keep waiting). RR-011 additionally reports
|
|
84
|
+
* `heldByLiveInProcess: true` when the stored token belongs to a run-lock
|
|
85
|
+
* acquisition of THIS process that is still inside its critical section — the
|
|
86
|
+
* async acquire loop then WAITS (timer-based retry) instead of throwing or
|
|
87
|
+
* stealing.
|
|
84
88
|
*
|
|
85
89
|
* ## EPERM Handling (Accepted Risk)
|
|
86
90
|
*
|
|
@@ -105,7 +109,11 @@ function isLockHolderAlive(filePath: string): boolean {
|
|
|
105
109
|
*
|
|
106
110
|
* See also: SECURITY-ISSUES.md SEC-008 for documented acceptance.
|
|
107
111
|
*/
|
|
108
|
-
function readLockSnapshot(
|
|
112
|
+
function readLockSnapshot(
|
|
113
|
+
filePath: string,
|
|
114
|
+
staleMs: number,
|
|
115
|
+
options?: { treatOwnPidAsStealable?: boolean; activeHolderTokens?: ReadonlySet<string> },
|
|
116
|
+
): { canSteal: boolean; heldByLiveInProcess: boolean } {
|
|
109
117
|
const treatOwnPidAsStealable = options?.treatOwnPidAsStealable === true;
|
|
110
118
|
let stat: fs.Stats | undefined;
|
|
111
119
|
let raw: string | undefined;
|
|
@@ -119,21 +127,23 @@ function readLockSnapshot(filePath: string, staleMs: number, options?: { treatOw
|
|
|
119
127
|
// "locked" error.
|
|
120
128
|
const code = (error as NodeJS.ErrnoException).code;
|
|
121
129
|
if (code === "ENOENT") {
|
|
122
|
-
return { canSteal: true };
|
|
130
|
+
return { canSteal: true, heldByLiveInProcess: false };
|
|
123
131
|
}
|
|
124
132
|
// Transient I/O error — be conservative (don't steal, retry the read on
|
|
125
133
|
// next attempt).
|
|
126
|
-
return { canSteal: false };
|
|
134
|
+
return { canSteal: false, heldByLiveInProcess: false };
|
|
127
135
|
}
|
|
128
136
|
// Staleness from a single snapshot.
|
|
129
137
|
let createdAt = parseCreatedAtFromLock(raw);
|
|
130
138
|
if (createdAt === undefined) createdAt = stat.mtimeMs;
|
|
131
139
|
const isStale = Date.now() - createdAt > staleMs;
|
|
132
140
|
let holderPid: number | undefined;
|
|
141
|
+
let holderToken: string | undefined;
|
|
133
142
|
let isAlive = true;
|
|
134
143
|
try {
|
|
135
|
-
const parsed = JSON.parse(raw) as { pid?: unknown };
|
|
144
|
+
const parsed = JSON.parse(raw) as { pid?: unknown; token?: unknown };
|
|
136
145
|
holderPid = typeof parsed.pid === "number" ? parsed.pid : undefined;
|
|
146
|
+
holderToken = typeof parsed.token === "string" ? parsed.token : undefined;
|
|
137
147
|
} catch {
|
|
138
148
|
/* malformed payload — keep isAlive=true */
|
|
139
149
|
}
|
|
@@ -151,9 +161,28 @@ function readLockSnapshot(filePath: string, staleMs: number, options?: { treatOw
|
|
|
151
161
|
// (used by withRunLock* which is single-process — a fresh lock with our pid
|
|
152
162
|
// between acquisitions is just a leftover from a previous call that
|
|
153
163
|
// releaseOwnLock didn't get to delete yet; safe to steal).
|
|
164
|
+
//
|
|
165
|
+
// RR-011 (F02): "our own pid" alone can no longer authorize a steal for the
|
|
166
|
+
// async run-lock path — a lock CURRENTLY HELD by another async context of
|
|
167
|
+
// this process also carries our pid while its holder is merely awaiting
|
|
168
|
+
// inside the critical section. The on-disk token disambiguates: a token in
|
|
169
|
+
// `activeHolderTokens` (runLockHeldTokens — every live acquisition of this
|
|
170
|
+
// process registers its token) means the holder is ALIVE in-process → the
|
|
171
|
+
// own-pid steal branch is suppressed (see acquireLockWithRetryAsync: such a
|
|
172
|
+
// holder WAITS instead of stealing/throwing). A token-less payload (legacy
|
|
173
|
+
// lock file) or a token not in the set is a leftover corpse → still stealable,
|
|
174
|
+
// preserving the anti-CI-flake behaviour the flag was added for.
|
|
175
|
+
// NOTE (trap, design.md §4.2): comparing the stored token to the CURRENT
|
|
176
|
+
// acquisition's token would be WRONG — each acquisition mints a fresh
|
|
177
|
+
// randomUUID, so a live holder's token always differs from ours and the
|
|
178
|
+
// predicate would steal anyway. Only a lookup into the LIVE-token set is
|
|
179
|
+
// correct.
|
|
154
180
|
const isOurOwnHolder = holderPid === process.pid;
|
|
181
|
+
const holderIsLiveInProcess = holderToken !== undefined && (options?.activeHolderTokens?.has(holderToken) ?? false);
|
|
182
|
+
const ownPidStealable = treatOwnPidAsStealable && isOurOwnHolder && !holderIsLiveInProcess;
|
|
155
183
|
return {
|
|
156
|
-
canSteal: isStale || !isAlive ||
|
|
184
|
+
canSteal: isStale || !isAlive || ownPidStealable,
|
|
185
|
+
heldByLiveInProcess: holderIsLiveInProcess,
|
|
157
186
|
};
|
|
158
187
|
}
|
|
159
188
|
|
|
@@ -239,25 +268,82 @@ function timingSafeTokenMatch(a: string, b: string): boolean {
|
|
|
239
268
|
}
|
|
240
269
|
|
|
241
270
|
/**
|
|
242
|
-
* Release the lock we (this process) just acquired.
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* a fresh randomUUID token, so token matching would falsely fail and leak the
|
|
246
|
-
* file across releases.
|
|
271
|
+
* Release the lock we (this process) just acquired. Used in the `finally` blocks
|
|
272
|
+
* of withRunLock and withRunLockSync, so the file was created earlier in the SAME
|
|
273
|
+
* call and carries OUR token.
|
|
247
274
|
*
|
|
248
275
|
* LOCK-1 (Round 2): PID-guarded release. Previously this deleted
|
|
249
276
|
* UNCONDITIONALLY — if our critical section exceeded staleMs, another process
|
|
250
277
|
* could steal our lock (overwrite the file with its own pid); our finally would
|
|
251
|
-
* then DELETE THE STEALER's lock, breaking mutual exclusion. We
|
|
252
|
-
* lock file still records OUR pid before removing; if it records a different
|
|
253
|
-
* the lock was stolen and the current holder owns it.
|
|
254
|
-
*
|
|
255
|
-
* (
|
|
278
|
+
* then DELETE THE STEALER's lock, breaking mutual exclusion. We therefore verify
|
|
279
|
+
* the lock file still records OUR pid before removing; if it records a different
|
|
280
|
+
* pid the lock was stolen and the current holder owns it.
|
|
281
|
+
*
|
|
282
|
+
* RR-011 (F02): within one process, PID equality no longer identifies "our" lock
|
|
283
|
+
* — two same-process async holders are distinguished ONLY by token. A finishing
|
|
284
|
+
* context must not delete another acquisition's lock (verification probe 2: the
|
|
285
|
+
* lock file was ENOENT while the second holder was still inside its critical
|
|
286
|
+
* section). So after the pid check, the stored token must also match ours. A
|
|
287
|
+
* token-less payload (legacy lock file from an older release) keeps the
|
|
288
|
+
* PID-only behaviour so old files are still cleaned up.
|
|
256
289
|
*
|
|
257
290
|
* Symlink guard is preserved: if a symlink appeared since our writeLockFile, we
|
|
258
291
|
* don't rm it (defense against attacker-planted symlinks).
|
|
259
292
|
*/
|
|
260
|
-
|
|
293
|
+
/**
|
|
294
|
+
* US-002 (2026-09-22): structured, safe, idempotent sweep of stale run locks.
|
|
295
|
+
*
|
|
296
|
+
* Locks whose holder died without releasing (kill -9, crash) previously lingered
|
|
297
|
+
* until some future acquirer hit the stale-steal path. This gives the
|
|
298
|
+
* stale-reconciler an explicit pass.
|
|
299
|
+
*
|
|
300
|
+
* IMPORTANT — the sweep predicate is STRICTER than acquire-time `canSteal`:
|
|
301
|
+
* acquire steals on `stale OR holder-dead` (deliberate: a stale lock must not
|
|
302
|
+
* block a live run forever), but a SWEEP must only remove a lock whose holder is
|
|
303
|
+
* provably DEAD. Removing a stale-but-live holder's lock would let two processes
|
|
304
|
+
* enter the critical section. So: stale AND (no pid OR pid dead). A live pid is
|
|
305
|
+
* never swept, no matter how old.
|
|
306
|
+
*
|
|
307
|
+
* Returns the lock files it removed. Idempotent: a second sweep finds nothing.
|
|
308
|
+
*/
|
|
309
|
+
export function sweepStaleLocks(lockFiles: readonly string[], options: RunLockOptions = {}): { removed: string[] } {
|
|
310
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
311
|
+
const removed: string[] = [];
|
|
312
|
+
for (const filePath of lockFiles) {
|
|
313
|
+
try {
|
|
314
|
+
if (!fs.existsSync(filePath)) continue;
|
|
315
|
+
if (!isLockStale(filePath, staleMs)) continue; // fresh → leave alone
|
|
316
|
+
// Stale. Remove ONLY when no live holder owns it.
|
|
317
|
+
if (isLockHolderAlive(filePath)) continue;
|
|
318
|
+
fs.rmSync(filePath, { force: true });
|
|
319
|
+
removed.push(filePath);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
logInternalError("locks.sweep-stale", error, `path=${filePath}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { removed };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* US-002: discover every `run.lock` under a runs root
|
|
329
|
+
* (`<runsRoot>/<runId>/run.lock`), bounded, for sweepStaleLocks. Missing → empty.
|
|
330
|
+
*/
|
|
331
|
+
export function discoverRunLockFiles(runsRoot: string, maxDirs = 500): string[] {
|
|
332
|
+
try {
|
|
333
|
+
if (!fs.existsSync(runsRoot)) return [];
|
|
334
|
+
return fs
|
|
335
|
+
.readdirSync(runsRoot, { withFileTypes: true })
|
|
336
|
+
.filter((e) => e.isDirectory())
|
|
337
|
+
.slice(0, maxDirs)
|
|
338
|
+
.map((e) => path.join(runsRoot, e.name, "run.lock"))
|
|
339
|
+
.filter((p) => fs.existsSync(p));
|
|
340
|
+
} catch (error) {
|
|
341
|
+
logInternalError("locks.discover-run-locks", error, `runsRoot=${runsRoot}`);
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function releaseOwnLock(filePath: string, token: string): void {
|
|
261
347
|
try {
|
|
262
348
|
const stat = fs.lstatSync(filePath);
|
|
263
349
|
if (stat.isSymbolicLink()) return;
|
|
@@ -266,11 +352,20 @@ export function releaseOwnLock(filePath: string, _token: string): void {
|
|
|
266
352
|
}
|
|
267
353
|
try {
|
|
268
354
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
355
|
+
const parsed = JSON.parse(raw) as { pid?: unknown; token?: unknown };
|
|
356
|
+
const holderPid = parsed.pid;
|
|
357
|
+
const storedToken = typeof parsed.token === "string" ? parsed.token : undefined;
|
|
358
|
+
if (holderPid !== process.pid) {
|
|
359
|
+
// holderPid !== process.pid → lock stolen by another process; do NOT touch.
|
|
360
|
+
return;
|
|
272
361
|
}
|
|
273
|
-
|
|
362
|
+
if (storedToken !== undefined && storedToken !== token) {
|
|
363
|
+
// RR-011 (F02): same process, different token → the lock now belongs to
|
|
364
|
+
// another acquisition of THIS process (steal window / superseded holder).
|
|
365
|
+
// Do not delete it — the current holder owns it.
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
fs.rmSync(filePath, { force: true });
|
|
274
369
|
} catch (error) {
|
|
275
370
|
const code = (error as NodeJS.ErrnoException).code;
|
|
276
371
|
if (code !== "ENOENT") {
|
|
@@ -322,6 +417,25 @@ function isLockContention(code: string | undefined): boolean {
|
|
|
322
417
|
return code === "EEXIST" || code === "EPERM" || code === "EBUSY";
|
|
323
418
|
}
|
|
324
419
|
|
|
420
|
+
// RR-011 (F02): tokens of run-lock acquisitions that are CURRENTLY HELD in this
|
|
421
|
+
// process. It answers exactly one question — "is this holder still alive
|
|
422
|
+
// in-process?" — and NOTHING else:
|
|
423
|
+
// - Re-entrance (bypass) decisions stay in `lockCtx` (per async context). This
|
|
424
|
+
// set must NEVER be consulted for bypass — that is the H-1 bug class.
|
|
425
|
+
// - The async steal predicate consults it (via readLockSnapshot's
|
|
426
|
+
// activeHolderTokens) so a SECOND independent async context can no longer
|
|
427
|
+
// steal a lock whose holder is merely awaiting inside its critical section.
|
|
428
|
+
//
|
|
429
|
+
// INVARIANT: a token is added synchronously right after a successful acquire
|
|
430
|
+
// (no await between acquire returning and the add) and deleted in the
|
|
431
|
+
// acquisition's `finally` BEFORE releaseOwnLock runs — so a token in this set
|
|
432
|
+
// always corresponds to a live critical section, and a leftover lock file from
|
|
433
|
+
// a finished acquisition (token no longer in the set, or a legacy token-less
|
|
434
|
+
// payload) remains stealable, preserving the CI-flake fix that
|
|
435
|
+
// treatOwnPidAsStealable was added for. If a `finally` were ever skipped, the
|
|
436
|
+
// leaked entry is inert: future lock files always mint a fresh randomUUID.
|
|
437
|
+
const runLockHeldTokens = new Set<string>();
|
|
438
|
+
|
|
325
439
|
function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind = "file"): string {
|
|
326
440
|
let attempt = 0;
|
|
327
441
|
const deadline = Date.now() + staleMs * 2;
|
|
@@ -381,10 +495,48 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
|
|
|
381
495
|
// file still exists with our own pid. Stealing it avoids spurious 'locked'
|
|
382
496
|
// errors. The sync file-lock path (withFileLockSync) above uses false to
|
|
383
497
|
// preserve the multi-process safety guarantee.
|
|
384
|
-
|
|
385
|
-
|
|
498
|
+
// RR-011 (F02): the steal now consults runLockHeldTokens — a lock whose
|
|
499
|
+
// stored token belongs to a LIVE acquisition of this process is NOT a
|
|
500
|
+
// leftover corpse and must not be stolen (that broke async↔async mutual
|
|
501
|
+
// exclusion: two independent async contexts entered the critical section
|
|
502
|
+
// together). Token-less legacy files are still stolen (CI-flake fix).
|
|
503
|
+
const verdict = readLockSnapshot(filePath, staleMs, {
|
|
504
|
+
treatOwnPidAsStealable: true,
|
|
505
|
+
activeHolderTokens: runLockHeldTokens,
|
|
506
|
+
});
|
|
507
|
+
if (!verdict.canSteal && verdict.heldByLiveInProcess) {
|
|
508
|
+
// RR-011 (F02): the holder is a LIVE acquisition in THIS process (another
|
|
509
|
+
// async context awaiting inside its critical section). It releases in its
|
|
510
|
+
// `finally` on this same event loop — WAIT via a timer (never sleepSync:
|
|
511
|
+
// blocking the event loop would starve the very holder we are waiting
|
|
512
|
+
// for, the v0.9.26 deadlock class) and retry the create. The loop deadline
|
|
513
|
+
// above plus the staleMs check in readLockSnapshot bound the wait, so a
|
|
514
|
+
// hung holder eventually becomes stale-stealable and this can never
|
|
515
|
+
// block indefinitely.
|
|
516
|
+
const delay = Math.min(250, 25 * 2 ** attempt);
|
|
517
|
+
await sleep(delay);
|
|
518
|
+
attempt++;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (!verdict.canSteal) {
|
|
386
522
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
387
523
|
}
|
|
524
|
+
if (verdict.heldByLiveInProcess) {
|
|
525
|
+
// Review MAJOR 2: the staleMs backstop is stealing a lock whose holder
|
|
526
|
+
// is STILL a live acquisition of this process (its critical section
|
|
527
|
+
// legitimately exceeded staleMs — fsync stalls, loaded machine). The
|
|
528
|
+
// steal is the documented design (ADR 2026-09-17-run-lock-async-
|
|
529
|
+
// ownership) but mutual exclusion is about to be violated knowingly,
|
|
530
|
+
// so it must be observable, never silent — production holders are
|
|
531
|
+
// ms-scale; seeing this warn means the invariant "critical section <
|
|
532
|
+
// staleMs" was broken and the budget should be revisited.
|
|
533
|
+
logInternalError(
|
|
534
|
+
"locks.steal-live-holder",
|
|
535
|
+
new Error(`staleMs steal of a lock held by a LIVE in-process acquisition (staleMs=${staleMs})`),
|
|
536
|
+
filePath,
|
|
537
|
+
"warn",
|
|
538
|
+
);
|
|
539
|
+
}
|
|
388
540
|
// Stale or dead holder — forcibly remove the lock.
|
|
389
541
|
try {
|
|
390
542
|
fs.rmSync(filePath, { force: true });
|
|
@@ -622,6 +774,10 @@ export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, optio
|
|
|
622
774
|
}
|
|
623
775
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
624
776
|
const token = acquireLockWithRetry(filePath, staleMs, "run");
|
|
777
|
+
// RR-011 (F02): register the hold so async contenders of this process see a
|
|
778
|
+
// LIVE holder (wait) instead of a stealable corpse. Sync here too, keeping the
|
|
779
|
+
// invariant uniform for every run-lock acquisition of this process.
|
|
780
|
+
runLockHeldTokens.add(token);
|
|
625
781
|
const prevHeld = lockCtx.getStore() ?? new Set<string>();
|
|
626
782
|
const newHeld = new Set(prevHeld);
|
|
627
783
|
newHeld.add(filePath);
|
|
@@ -632,14 +788,11 @@ export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, optio
|
|
|
632
788
|
try {
|
|
633
789
|
return fn();
|
|
634
790
|
} finally {
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
// then keep failing with EEXIST because the file lingers.
|
|
641
|
-
// Since withRunLock* is always single-process, unconditionally delete
|
|
642
|
-
// (symlink guard is still applied for safety).
|
|
791
|
+
// RR-011 (F02): unregister BEFORE releasing (see runLockHeldTokens
|
|
792
|
+
// invariant) — the token must not linger as "live" once the hold ends.
|
|
793
|
+
// releaseOwnLock is token-guarded: if our lock was superseded in the
|
|
794
|
+
// meantime, the current holder's file is left untouched.
|
|
795
|
+
runLockHeldTokens.delete(token);
|
|
643
796
|
releaseOwnLock(filePath, token);
|
|
644
797
|
}
|
|
645
798
|
});
|
|
@@ -654,6 +807,10 @@ export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promis
|
|
|
654
807
|
}
|
|
655
808
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
656
809
|
const token = await acquireLockWithRetryAsync(filePath, staleMs, "run");
|
|
810
|
+
// RR-011 (F02): register the hold IMMEDIATELY (no await between the acquire
|
|
811
|
+
// returning and this add — otherwise a concurrent contender could observe the
|
|
812
|
+
// fresh on-disk token as a stealable corpse). Deleted in the finally below.
|
|
813
|
+
runLockHeldTokens.add(token);
|
|
657
814
|
const prevHeld = lockCtx.getStore() ?? new Set<string>();
|
|
658
815
|
const newHeld = new Set(prevHeld);
|
|
659
816
|
newHeld.add(filePath);
|
|
@@ -664,8 +821,9 @@ export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promis
|
|
|
664
821
|
try {
|
|
665
822
|
return await fn();
|
|
666
823
|
} finally {
|
|
667
|
-
//
|
|
668
|
-
//
|
|
824
|
+
// RR-011 (F02): see withRunLockSync above — unregister the live token
|
|
825
|
+
// BEFORE the token-guarded releaseOwnLock (runLockHeldTokens invariant).
|
|
826
|
+
runLockHeldTokens.delete(token);
|
|
669
827
|
releaseOwnLock(filePath, token);
|
|
670
828
|
}
|
|
671
829
|
});
|
|
@@ -467,19 +467,20 @@ function readAllInboxMessages(manifest: TeamRunManifest): MailboxMessage[] {
|
|
|
467
467
|
// FIND-01: in-process delivery cache to avoid O(N²) re-reads on every append.
|
|
468
468
|
// Keyed by delivery file path; invalidated by mtime check on read + updated on
|
|
469
469
|
// write. Team-runner is single-process so in-process caching is sufficient.
|
|
470
|
-
const deliveryCache = new Map<string, { mtimeMs: number; state: MailboxDeliveryState }>();
|
|
470
|
+
const deliveryCache = new Map<string, { mtimeMs: number; size: number; state: MailboxDeliveryState }>();
|
|
471
471
|
const MAX_DELIVERY_CACHE_ENTRIES = 256;
|
|
472
472
|
// R1 review fix: setDeliveryCacheEntry stores an immutable snapshot (deep
|
|
473
473
|
// copy of `messages`) so callers mutating the returned state cannot corrupt
|
|
474
474
|
// the cache (TOCTOU race), and bounds the map size with FIFO eviction to
|
|
475
475
|
// prevent unbounded growth across runs.
|
|
476
|
-
function setDeliveryCacheEntry(filePath: string, entry: { mtimeMs: number; state: MailboxDeliveryState }): void {
|
|
476
|
+
function setDeliveryCacheEntry(filePath: string, entry: { mtimeMs: number; size: number; state: MailboxDeliveryState }): void {
|
|
477
477
|
if (deliveryCache.size >= MAX_DELIVERY_CACHE_ENTRIES) {
|
|
478
478
|
const oldest = deliveryCache.keys().next().value;
|
|
479
479
|
if (oldest !== undefined) deliveryCache.delete(oldest);
|
|
480
480
|
}
|
|
481
481
|
deliveryCache.set(filePath, {
|
|
482
482
|
mtimeMs: entry.mtimeMs,
|
|
483
|
+
size: entry.size,
|
|
483
484
|
state: { ...entry.state, messages: { ...entry.state.messages } },
|
|
484
485
|
});
|
|
485
486
|
}
|
|
@@ -497,7 +498,7 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
|
|
|
497
498
|
return { messages: {}, updatedAt: new Date().toISOString() };
|
|
498
499
|
}
|
|
499
500
|
const cached = deliveryCache.get(filePath);
|
|
500
|
-
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
501
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
501
502
|
// R2 review fix: return a copy so callers mutating the result cannot
|
|
502
503
|
// leak into the cached snapshot (residual TOCTOU: the cache holds the
|
|
503
504
|
// snapshot until the next write replaces it; without this copy, a
|
|
@@ -517,7 +518,7 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
|
|
|
517
518
|
messages,
|
|
518
519
|
updatedAt: typeof obj.updatedAt === "string" ? obj.updatedAt : new Date().toISOString(),
|
|
519
520
|
};
|
|
520
|
-
setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, state });
|
|
521
|
+
setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, state });
|
|
521
522
|
return state;
|
|
522
523
|
} catch (error) {
|
|
523
524
|
// NEW-R4: a corrupt delivery.json was previously swallowed silently, returning
|
|
@@ -543,22 +544,146 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
|
|
|
543
544
|
}
|
|
544
545
|
}
|
|
545
546
|
|
|
547
|
+
const MAX_DELIVERY_MESSAGES = 10000;
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* F09 (RR-016): an `acknowledged` delivery entry is the ONLY durable record
|
|
551
|
+
* that a message was handled — the inbox line keeps `status: "queued"` forever
|
|
552
|
+
* (nothing writes the ack back to the message), so `replayPendingMailboxMessages`
|
|
553
|
+
* keys on the delivery map. The old prune sorted `queued(0) < delivered(1) <
|
|
554
|
+
* acknowledged(2)` and kept `slice(0, MAX)`, i.e. it evicted ACKNOWLEDGED entries
|
|
555
|
+
* FIRST — the moment the cap was crossed, an acked message became replayable
|
|
556
|
+
* again, and it never self-healed (replay only writes "delivered"), so it was
|
|
557
|
+
* re-delivered on EVERY resume.
|
|
558
|
+
*
|
|
559
|
+
* Fix: the prune may only drop an acknowledged entry when the message it refers
|
|
560
|
+
* to is provably no longer replayable (absent from the whole replayable history:
|
|
561
|
+
* live inbox files + retained archives — the exact set `replayPendingMailboxMessages`
|
|
562
|
+
* reads). Non-acknowledged entries keep the previous eviction semantics
|
|
563
|
+
* (queued → delivered → acknowledged, oldest-inserted first within a tier).
|
|
564
|
+
*
|
|
565
|
+
* Acknowledged entries are tiny (`"<id>":"acknowledged"`), but they must not
|
|
566
|
+
* grow without bound either: a sweep that drops the PROVABLY-dead acks is run
|
|
567
|
+
* once the ack set is large enough to matter, and at most once per
|
|
568
|
+
* ACK_SWEEP_MIN_INTERVAL_MS (the sweep reads the mailbox history, so it is
|
|
569
|
+
* throttled rather than run on every append).
|
|
570
|
+
*/
|
|
571
|
+
const ACK_SWEEP_MIN_ACKS = 1000;
|
|
572
|
+
const ACK_SWEEP_FORCE_ACKS = 5000;
|
|
573
|
+
const ACK_SWEEP_MIN_INTERVAL_MS = 30_000;
|
|
574
|
+
// Bounded FIFO (the asyncAgentReaderCache pattern) so a long-running process
|
|
575
|
+
// cannot accumulate one timestamp per run ever seen.
|
|
576
|
+
const ACK_SWEEP_TIMESTAMP_MAX_ENTRIES = 256;
|
|
577
|
+
const lastAckSweepAt = new Map<string, number>();
|
|
578
|
+
|
|
579
|
+
function recordAckSweep(filePath: string, at: number): void {
|
|
580
|
+
if (lastAckSweepAt.has(filePath)) lastAckSweepAt.delete(filePath);
|
|
581
|
+
lastAckSweepAt.set(filePath, at);
|
|
582
|
+
while (lastAckSweepAt.size > ACK_SWEEP_TIMESTAMP_MAX_ENTRIES) {
|
|
583
|
+
const oldest = lastAckSweepAt.keys().next().value;
|
|
584
|
+
if (oldest === undefined) break;
|
|
585
|
+
lastAckSweepAt.delete(oldest);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Ids of every message `replayPendingMailboxMessages` could return (live inbox
|
|
590
|
+
* files + retained archives, run-level and per-task). Returns `undefined` when
|
|
591
|
+
* the history could not be READ (fail-closed, review MAJOR 1): an unreadable
|
|
592
|
+
* history must never be conflated with an empty one — the sweep treats an
|
|
593
|
+
* empty set as "every ack is dead" and would delete all acknowledged entries,
|
|
594
|
+
* replaying already-processed messages (the F09 bug class). */
|
|
595
|
+
function collectReplayableInboxIds(manifest: TeamRunManifest): Set<string> | undefined {
|
|
596
|
+
const ids = new Set<string>();
|
|
597
|
+
try {
|
|
598
|
+
for (const message of readAllInboxMessages(manifest)) ids.add(message.id);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
logInternalError("mailbox.collect-replayable-ids", error, `runId=${manifest.runId}`);
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
return ids;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Drop acknowledged entries whose message can no longer be replayed. Returns
|
|
607
|
+
* the number of entries dropped. Never drops an entry whose message is still
|
|
608
|
+
* in the replayable history. FAIL-CLOSED (review MAJOR 1): if the replayable
|
|
609
|
+
* history cannot be read, the sweep aborts dropping NOTHING and does not
|
|
610
|
+
* consume the throttle window (`recordAckSweep` is only recorded after a
|
|
611
|
+
* successful read), so the next prune retries once the FS is readable. */
|
|
612
|
+
function sweepDeadAcknowledgements(manifest: TeamRunManifest, state: MailboxDeliveryState): number {
|
|
613
|
+
const ackedIds = Object.entries(state.messages)
|
|
614
|
+
.filter(([, status]) => status === "acknowledged")
|
|
615
|
+
.map(([id]) => id);
|
|
616
|
+
if (ackedIds.length === 0) return 0;
|
|
617
|
+
const filePath = deliveryFile(manifest, true);
|
|
618
|
+
const now = Date.now();
|
|
619
|
+
const last = lastAckSweepAt.get(filePath) ?? 0;
|
|
620
|
+
// The sweep reads the whole replayable history, so it is time-throttled:
|
|
621
|
+
// at most once per ACK_SWEEP_MIN_INTERVAL_MS below ACK_SWEEP_FORCE_ACKS,
|
|
622
|
+
// and immediately above it so the ack set cannot grow unbounded.
|
|
623
|
+
const shouldSweep =
|
|
624
|
+
ackedIds.length >= ACK_SWEEP_FORCE_ACKS || (ackedIds.length >= ACK_SWEEP_MIN_ACKS && now - last >= ACK_SWEEP_MIN_INTERVAL_MS);
|
|
625
|
+
if (!shouldSweep) return 0;
|
|
626
|
+
const replayable = collectReplayableInboxIds(manifest);
|
|
627
|
+
if (replayable === undefined) return 0; // unreadable history → abort, keep every ack
|
|
628
|
+
recordAckSweep(filePath, now);
|
|
629
|
+
let dropped = 0;
|
|
630
|
+
for (const id of ackedIds) {
|
|
631
|
+
if (replayable.has(id)) continue;
|
|
632
|
+
delete state.messages[id];
|
|
633
|
+
dropped++;
|
|
634
|
+
}
|
|
635
|
+
return dropped;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function pruneDeliveryMessages(manifest: TeamRunManifest, state: MailboxDeliveryState): void {
|
|
639
|
+
const entries = Object.entries(state.messages);
|
|
640
|
+
const ackedCount = entries.reduce((count, [, status]) => (status === "acknowledged" ? count + 1 : count), 0);
|
|
641
|
+
if (ackedCount > ACK_SWEEP_MIN_ACKS) sweepDeadAcknowledgements(manifest, state);
|
|
642
|
+
const remaining = Object.entries(state.messages);
|
|
643
|
+
if (remaining.length <= MAX_DELIVERY_MESSAGES) return;
|
|
644
|
+
// Stable sort: within a status tier the previous insertion order (which is
|
|
645
|
+
// the order entries were first written) is preserved, so the eviction choice
|
|
646
|
+
// for non-acknowledged entries is unchanged from before this fix.
|
|
647
|
+
const sorted = [...remaining].sort(([, a], [, b]) => {
|
|
648
|
+
const order = { queued: 0, delivered: 1, acknowledged: 2 };
|
|
649
|
+
return (order[a] ?? 3) - (order[b] ?? 3);
|
|
650
|
+
});
|
|
651
|
+
const ackedAfterSweep = sorted.filter(([, status]) => status === "acknowledged").length;
|
|
652
|
+
if (ackedAfterSweep > MAX_DELIVERY_MESSAGES) {
|
|
653
|
+
// Pathological: more acks than the cap and every one of them still refers
|
|
654
|
+
// to a replayable message. Correctness (an acked message must never
|
|
655
|
+
// replay) wins over the memory bound — keep them and make it visible.
|
|
656
|
+
logInternalError(
|
|
657
|
+
"mailbox.delivery-ack-over-cap",
|
|
658
|
+
new Error(`delivery.json holds ${ackedAfterSweep} acknowledged entries for replayable messages (cap ${MAX_DELIVERY_MESSAGES})`),
|
|
659
|
+
`runId=${manifest.runId}`,
|
|
660
|
+
"warn",
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
const keptIds = new Set<string>();
|
|
664
|
+
let evictableBudget = Math.max(0, MAX_DELIVERY_MESSAGES - ackedAfterSweep);
|
|
665
|
+
for (const [id, status] of sorted) {
|
|
666
|
+
if (status === "acknowledged") {
|
|
667
|
+
keptIds.add(id);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (evictableBudget > 0) {
|
|
671
|
+
keptIds.add(id);
|
|
672
|
+
evictableBudget--;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// Filter the ORIGINAL entry order so surviving entries keep their positions.
|
|
676
|
+
state.messages = Object.fromEntries(remaining.filter(([id]) => keptIds.has(id)));
|
|
677
|
+
}
|
|
678
|
+
|
|
546
679
|
function writeDeliveryState(
|
|
547
680
|
manifest: TeamRunManifest,
|
|
548
681
|
state: MailboxDeliveryState,
|
|
549
682
|
options?: { durability?: "full" | "best-effort" },
|
|
550
683
|
): void {
|
|
551
684
|
ensureRunMailbox(manifest);
|
|
552
|
-
// Prune oldest entries if capped
|
|
553
|
-
|
|
554
|
-
if (Object.keys(state.messages).length > MAX_DELIVERY_MESSAGES) {
|
|
555
|
-
const sorted = Object.entries(state.messages).sort(([, a], [, b]) => {
|
|
556
|
-
const order = { queued: 0, delivered: 1, acknowledged: 2 };
|
|
557
|
-
return (order[a] ?? 3) - (order[b] ?? 3);
|
|
558
|
-
});
|
|
559
|
-
const trimmed = sorted.slice(0, MAX_DELIVERY_MESSAGES);
|
|
560
|
-
state.messages = Object.fromEntries(trimmed);
|
|
561
|
-
}
|
|
685
|
+
// Prune oldest entries if capped (F09: acknowledged entries are protected).
|
|
686
|
+
pruneDeliveryMessages(manifest, state);
|
|
562
687
|
// F4: mailbox delivery is informational — accept losing the very last write on
|
|
563
688
|
// a hard crash (the next message will overwrite it on disk). Cheaper fsync on
|
|
564
689
|
// the hot path; terminal/reply paths still pass full durability below.
|
|
@@ -572,7 +697,7 @@ function writeDeliveryState(
|
|
|
572
697
|
// setDeliveryCacheEntry stores an immutable snapshot (deep copy of
|
|
573
698
|
// messages) so subsequent read-modify-write callers mutating the
|
|
574
699
|
// returned state cannot corrupt the cache.
|
|
575
|
-
setDeliveryCacheEntry(filePath, { mtimeMs: postStat.mtimeMs, state });
|
|
700
|
+
setDeliveryCacheEntry(filePath, { mtimeMs: postStat.mtimeMs, size: postStat.size, state });
|
|
576
701
|
} catch {
|
|
577
702
|
deliveryCache.delete(filePath);
|
|
578
703
|
}
|
package/src/state/crew-init.ts
CHANGED
|
@@ -13,10 +13,20 @@
|
|
|
13
13
|
* The `node:path` import is retained as a *fallback* (only used when the
|
|
14
14
|
* binding is healthy). Don't add new dependencies on other pi-crew modules.
|
|
15
15
|
*
|
|
16
|
+
* TWO sanctioned exceptions (RR-020):
|
|
17
|
+
* - `node:os` — a builtin like fs/path, used ONLY inside try/catch so a
|
|
18
|
+
* jiti namespace race degrades to "no home/tmp boundary" (the pre-RR-020
|
|
19
|
+
* walk) instead of crashing ensureCrewDirectory.
|
|
20
|
+
* - `../utils/project-markers.ts` — a zero-import module of plain string
|
|
21
|
+
* literals, so it cannot itself suffer the namespace race and it keeps
|
|
22
|
+
* this resolver from drifting away from `src/utils/paths.ts`.
|
|
23
|
+
*
|
|
16
24
|
* See: https://github.com/baphuongna/pi-crew/issues/28
|
|
17
25
|
*/
|
|
18
26
|
import * as fs from "node:fs";
|
|
27
|
+
import * as os from "node:os";
|
|
19
28
|
import * as path from "node:path";
|
|
29
|
+
import { PROJECT_DIR_MARKERS, PROJECT_FILE_MARKERS } from "../utils/project-markers.ts";
|
|
20
30
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
21
31
|
import { updateGitignore } from "./gitignore-manager.ts";
|
|
22
32
|
|
|
@@ -164,29 +174,94 @@ function safeResolve(p: string, pathDep?: typeof path): string {
|
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
function findProjectRoot(start: string, pathDep?: typeof path): string | undefined {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
177
|
+
// Marker lists come from src/utils/project-markers.ts (RR-020 Fix 1) so this
|
|
178
|
+
// resolver can never drift from src/utils/paths.ts:computeRepoRoot again —
|
|
179
|
+
// the drift resolved `parent/subproject` (.pi) to `parent/.crew` here while
|
|
180
|
+
// paths.ts resolved the same cwd to `subproject/.pi/teams` (two roots).
|
|
181
|
+
// The module has NO imports of its own, so it is safe under the jiti
|
|
182
|
+
// namespace race documented at the top of this file.
|
|
183
|
+
// RR-020 hardening (cold-verify round 2): the marker arrays are the ONE
|
|
184
|
+
// critical-path dependency on a static import binding. Under the jiti
|
|
185
|
+
// namespace race (issue #28) a binding can arrive undefined — degrade to the
|
|
186
|
+
// narrow `.git`-only probe (findProjectRoot then returns undefined more
|
|
187
|
+
// often, so computeCrewRoot anchors the crew root at the cwd — the same
|
|
188
|
+
// fallback paths.ts uses) instead of throwing inside ensureCrewDirectory,
|
|
189
|
+
// the very function hardened for #28. Mirrors the defensive style of
|
|
190
|
+
// safeJoin/safeDirname/parseRoot and the lazy updateGitignore import.
|
|
191
|
+
function markerLists(): { dirs: string[]; files: string[] } {
|
|
192
|
+
try {
|
|
193
|
+
if (Array.isArray(PROJECT_DIR_MARKERS) && Array.isArray(PROJECT_FILE_MARKERS)) {
|
|
194
|
+
return { dirs: PROJECT_DIR_MARKERS as string[], files: PROJECT_FILE_MARKERS as string[] };
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// namespace unavailable — fall through to the narrow probe
|
|
198
|
+
}
|
|
199
|
+
return { dirs: [".git"], files: [] };
|
|
200
|
+
}
|
|
201
|
+
const markers = markerLists();
|
|
202
|
+
const hasMarker = (dir: string): boolean =>
|
|
203
|
+
markers.dirs.some((marker) => fs.existsSync(safeJoin(dir, marker))) ||
|
|
204
|
+
markers.files.some((marker) => fs.existsSync(safeJoin(dir, marker)));
|
|
172
205
|
let current = safeResolve(start, pathDep);
|
|
206
|
+
// RR-020 cold-verify follow-up: match findRepoRoot (paths.ts) which
|
|
207
|
+
// realpaths the start BEFORE walking, so the boundary comparisons below
|
|
208
|
+
// compare canonical-to-canonical (macOS /var -> /private/var). Best-effort:
|
|
209
|
+
// ENOENT keeps the lexical path, exactly like findRepoRoot's fallback.
|
|
210
|
+
try {
|
|
211
|
+
current = fs.realpathSync(current);
|
|
212
|
+
} catch {
|
|
213
|
+
// keep the lexical resolution
|
|
214
|
+
}
|
|
215
|
+
// Use `parseRoot` (inlined above) to avoid `path.parse` for the critical
|
|
216
|
+
// termination root — fixes the jiti namespace race in issue #28. Computed
|
|
217
|
+
// from the RESOLVED `current` (post-realpath) so a root-prefix change
|
|
218
|
+
// through a symlink cannot desync `current !== root` (paths.ts computes
|
|
219
|
+
// path.parse on the realpath'd start for the same reason).
|
|
220
|
+
const root = parseRoot(current);
|
|
221
|
+
// RR-020 cold-verify follow-up (bug-029 parity): home/tmp boundary STOP.
|
|
222
|
+
// computeRepoRoot (paths.ts) refuses to check markers at $HOME or the temp
|
|
223
|
+
// root; this walk did not, so once the marker lists were unified onto the
|
|
224
|
+
// wide set, `$HOME/.pi` (created by userPiRoot()) became a marker HERE — a
|
|
225
|
+
// MARKERLESS cwd under $HOME resolved crew-init to $HOME (⇒
|
|
226
|
+
// $HOME/.pi/teams) while projectCrewRoot resolved to <cwd>/.crew: the
|
|
227
|
+
// two-roots bug Fix 1 was meant to kill, in a new shape. `os` access is
|
|
228
|
+
// defensive: under the jiti namespace race (issue #28) the binding can be
|
|
229
|
+
// undefined — degrade to "no boundary" (the pre-RR-020 walk), never crash.
|
|
230
|
+
let home: string | undefined;
|
|
231
|
+
let tempRoot: string | undefined;
|
|
232
|
+
try {
|
|
233
|
+
home = canonicalBoundary(os.homedir());
|
|
234
|
+
tempRoot = canonicalBoundary(os.tmpdir());
|
|
235
|
+
} catch {
|
|
236
|
+
home = undefined;
|
|
237
|
+
tempRoot = undefined;
|
|
238
|
+
}
|
|
239
|
+
const atBoundary = (dir: string): boolean => (home !== undefined && dir === home) || (tempRoot !== undefined && dir === tempRoot);
|
|
173
240
|
// Walk up to find project root
|
|
174
241
|
while (current !== root) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
for (const marker of fileMarkers) {
|
|
179
|
-
if (fs.existsSync(safeJoin(current, marker))) return current;
|
|
180
|
-
}
|
|
242
|
+
// Stop walking before checking markers at home or temp root.
|
|
243
|
+
if (atBoundary(current)) return undefined;
|
|
244
|
+
if (hasMarker(current)) return current;
|
|
181
245
|
const parent = safeDirname(current);
|
|
182
246
|
if (parent === current) break;
|
|
183
247
|
current = parent;
|
|
184
248
|
}
|
|
185
249
|
// Check root as fallback
|
|
186
|
-
if (
|
|
250
|
+
if (atBoundary(root)) return undefined;
|
|
251
|
+
if (hasMarker(root)) return root;
|
|
187
252
|
return undefined;
|
|
188
253
|
}
|
|
189
254
|
|
|
255
|
+
/** Canonicalize a boundary dir for comparison with the (realpath'd) walk.
|
|
256
|
+
* Best-effort: an unresolvable path stays lexical. */
|
|
257
|
+
function canonicalBoundary(p: string): string {
|
|
258
|
+
try {
|
|
259
|
+
return fs.realpathSync(p);
|
|
260
|
+
} catch {
|
|
261
|
+
return p;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
190
265
|
/**
|
|
191
266
|
* Compute the crew root directory for a given working directory.
|
|
192
267
|
* Matches src/utils/paths.ts:projectCrewRoot() logic.
|