claude-code-session-manager 0.83.0 → 0.85.0
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/dist/assets/{AgentLibrary-B-OM-4gk.js → AgentLibrary-Bkv-HcP1.js} +1 -1
- package/dist/assets/{DataModel-BC6ppcBr.js → DataModel-DRH-Ty20.js} +1 -1
- package/dist/assets/{History-D3Qw2BWn.js → History-CfRhT1Im.js} +2 -2
- package/dist/assets/{Hooks-OxBQ0nIc.js → Hooks-wEmh_U6c.js} +1 -1
- package/dist/assets/{HostBilko-BNFC6bNZ.js → HostBilko-D_t7Rbi7.js} +1 -1
- package/dist/assets/{Library-DAMyrUtY.js → Library-CpArQ-OJ.js} +1 -1
- package/dist/assets/{ListDetail-BwywrpvE.js → ListDetail-pjaKYs84.js} +1 -1
- package/dist/assets/{MarkdownEditor-HEwjN1bp.js → MarkdownEditor-Xc141kjj.js} +1 -1
- package/dist/assets/{McpServers-D1VZF7Ix.js → McpServers-ftqaV3kn.js} +1 -1
- package/dist/assets/{Memory-CgYJ_QqD.js → Memory-ChMWkNd0.js} +6 -6
- package/dist/assets/{Panel-dTNWC59Q.js → Panel-D9Kr40Ai.js} +1 -1
- package/dist/assets/{Permissions-qI_NKi1b.js → Permissions-DKoNVgzj.js} +1 -1
- package/dist/assets/{Plugins-DZ0_CHiF.js → Plugins-BtChISho.js} +2 -2
- package/dist/assets/{ProvenanceBadge-B2i_uxCu.js → ProvenanceBadge-DBA5EcYy.js} +1 -1
- package/dist/assets/{SaveBar-4tNMCjUd.js → SaveBar-I0_dWNTX.js} +1 -1
- package/dist/assets/{Scheduler-CUen18Gc.js → Scheduler-CbES7MC8.js} +7 -7
- package/dist/assets/{ScopeSwitcher-Co3tkLTT.js → ScopeSwitcher-5GTEveb2.js} +1 -1
- package/dist/assets/Settings-BX3FElXk.js +3 -0
- package/dist/assets/{SkillReferenceGraph-BKcHtMPc.js → SkillReferenceGraph-DNBFGrYE.js} +1 -1
- package/dist/assets/{Skills-DHb4INtF.js → Skills-DJB6-bBM.js} +2 -2
- package/dist/assets/SystemPrompt-BiDDrJUA.js +1 -0
- package/dist/assets/{TagLibrary-xLP5dJvh.js → TagLibrary-_Wrevtop.js} +1 -1
- package/dist/assets/{TiptapBody-T-ULTjK4.js → TiptapBody-OWWXdLRy.js} +1 -1
- package/dist/assets/{Toggle-B-5M2hGe.js → Toggle-B122N0HL.js} +1 -1
- package/dist/assets/{index-CYhdtisq.css → index-CDo9xBR9.css} +1 -1
- package/dist/assets/{index-CzAxC432.js → index-DhvuQL4C.js} +316 -314
- package/dist/assets/{settingsSchema-C_nsemcF.js → settingsSchema-sGoCTd7J.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -1
- package/scripts/hooks/guard-destructive-git.cjs +514 -0
- package/scripts/hooks/guard-inline-implementation.cjs +219 -0
- package/scripts/hooks/guard-prd-writes.cjs +200 -0
- package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
- package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
- package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +149 -0
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
- package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
- package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
- package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
- package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
- package/src/main/__tests__/telemetryClient.test.cjs +242 -1
- package/src/main/__tests__/telemetryContract.test.cjs +930 -0
- package/src/main/crashDiagnostics.cjs +29 -1
- package/src/main/health.cjs +197 -3
- package/src/main/index.cjs +65 -6
- package/src/main/ipcSchemas.cjs +19 -2
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +103 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
- package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
- package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
- package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
- package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +626 -0
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +123 -0
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +136 -0
- package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +95 -0
- package/src/main/lib/crashTelemetry.cjs +37 -0
- package/src/main/lib/delegationReadiness.cjs +119 -1
- package/src/main/lib/epicMint.cjs +2 -0
- package/src/main/lib/gitWorktree.cjs +427 -17
- package/src/main/lib/jobWorktree.cjs +2 -1
- package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
- package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
- package/src/main/lib/opsErrorLog.cjs +78 -25
- package/src/main/lib/queueStore.cjs +241 -22
- package/src/main/lib/reaperHelpers.cjs +23 -1
- package/src/main/lib/scheduleJobSchema.cjs +7 -0
- package/src/main/lib/scheduleJobTransitions.cjs +12 -0
- package/src/main/lib/telemetryBacklog.cjs +601 -0
- package/src/main/lib/telemetryBoot.cjs +76 -0
- package/src/main/lib/telemetryClient.cjs +150 -5
- package/src/main/lib/telemetryConsent.cjs +34 -0
- package/src/main/lib/telemetryCounters.cjs +45 -0
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +2 -0
- package/src/main/scheduler.cjs +481 -33
- package/src/preload/api.d.ts +84 -4
- package/src/preload/index.cjs +9 -0
- package/dist/assets/Settings-DXVgKLUx.js +0 -3
- package/dist/assets/SystemPrompt-BjqFOHSk.js +0 -1
|
@@ -172,8 +172,31 @@ function isUnderRoot(p, root) {
|
|
|
172
172
|
// versa). Reset to 0 per kind on every restart by design — a crash can never
|
|
173
173
|
// leave either counter permanently wedged above its cap; reconcileWorktreesOnBoot
|
|
174
174
|
// cleans up any leaked ON-DISK checkouts separately (see below).
|
|
175
|
+
//
|
|
176
|
+
// This counter can still drift UPWARD of reality (a missed decrement —
|
|
177
|
+
// cleanupWorktree never ran because the owning process crashed, was
|
|
178
|
+
// reaped, or SIGTERM'd before teardown) for as long as the app keeps
|
|
179
|
+
// running. `reserveWorktreeSlot` below is what makes that drift
|
|
180
|
+
// self-healing: instead of trusting this counter forever once it reaches
|
|
181
|
+
// the cap, a rejection is verified against OBSERVED on-disk reality
|
|
182
|
+
// (`getObservedWorktreeCount`) before being honored, and the counter is
|
|
183
|
+
// corrected down to match reality when it's proven to have leaked.
|
|
175
184
|
const activeWorktreeCount = { job: 0, epic: 0 };
|
|
176
185
|
|
|
186
|
+
// Per-kind promise chain used as a simple async mutex (see `withKindLock`)
|
|
187
|
+
// so the cap-check-and-reserve decision in `reserveWorktreeSlot` — which now
|
|
188
|
+
// sometimes needs to `await` a fresh on-disk observation — stays a single
|
|
189
|
+
// atomic step across concurrent callers, the same guarantee the old
|
|
190
|
+
// purely-synchronous check+increment gave for free.
|
|
191
|
+
const worktreeCapLockChain = { job: Promise.resolve(), epic: Promise.resolve() };
|
|
192
|
+
|
|
193
|
+
// Short-TTL memo of `getObservedWorktreeCount`'s result, per kind, so a burst
|
|
194
|
+
// of callers hitting the cap in quick succession (e.g. several scheduler
|
|
195
|
+
// ticks in a row while the machine is genuinely full) doesn't turn into a
|
|
196
|
+
// `git worktree list` storm. Cleared per-kind by the test hook below.
|
|
197
|
+
const observedWorktreeCountCache = { job: { count: null, at: 0 }, epic: { count: null, at: 0 } };
|
|
198
|
+
const OBSERVED_WORKTREE_COUNT_TTL_MS = 2000;
|
|
199
|
+
|
|
177
200
|
function execGit(args, { cwd, timeout = 20_000 } = {}) {
|
|
178
201
|
return new Promise((resolve, reject) => {
|
|
179
202
|
execFile('git', args, { cwd, timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
@@ -350,15 +373,27 @@ async function teardownOrphanedCheckout(dir) {
|
|
|
350
373
|
*
|
|
351
374
|
* A checkout younger than `staleAgeMs` (mtime-based) is NEVER touched — this
|
|
352
375
|
* is what keeps an in-progress job or Epic safe from a concurrent boot sweep.
|
|
353
|
-
* This intentionally does NOT accept
|
|
376
|
+
* This intentionally does NOT accept a queue-aware `isLive` predicate the way
|
|
354
377
|
* `reconcileWorktreesOnBoot`'s per-cwd pass does: that predicate is scoped to
|
|
355
378
|
* ONE project's own active-index.json per call, but this sweep walks EVERY
|
|
356
379
|
* project's checkouts under the shared kind root in one pass — applying one
|
|
357
380
|
* project's liveness answer to another project's checkout would be worse
|
|
358
381
|
* than no answer at all (a false "not live" for a foreign key would tear
|
|
359
382
|
* down a genuinely active Epic before its own project's turn ever runs this
|
|
360
|
-
* sweep).
|
|
361
|
-
*
|
|
383
|
+
* sweep).
|
|
384
|
+
*
|
|
385
|
+
* It DOES, however, check `hasLiveHolder` on every candidate that clears the
|
|
386
|
+
* age threshold: unlike a queue-derived `isLive`, a live `/proc` cwd holder
|
|
387
|
+
* is project-agnostic and machine-global — a directory's mtime does NOT
|
|
388
|
+
* advance when a long-running process only writes into subdirectories of it
|
|
389
|
+
* (exactly the "starry-night-ships"-class workload), so an old mtime is not
|
|
390
|
+
* proof of abandonment. A process still holding the checkout as its cwd is
|
|
391
|
+
* authoritative over any timer and is never reaped, regardless of age.
|
|
392
|
+
* `holders` may be supplied precomputed (one `/proc` scan reused across many
|
|
393
|
+
* candidates/kinds); when omitted this computes its own. On darwin (no
|
|
394
|
+
* `/proc`) `listCwdHolders`/`hasLiveHolder` always report no holder, so the
|
|
395
|
+
* age threshold alone gates darwin — same accepted tradeoff as
|
|
396
|
+
* `reclaimTerminalJobOrphans`'s `isLive`.
|
|
362
397
|
*
|
|
363
398
|
* Every path visited is verified (`isUnderRoot`) to be nested under this
|
|
364
399
|
* kind's own root before any read or delete, so a maliciously-shaped on-disk
|
|
@@ -371,6 +406,7 @@ async function sweepStaleWorktreeCheckouts(kind, opts = {}) {
|
|
|
371
406
|
const staleAgeMs = Number.isFinite(opts.staleAgeMs) && opts.staleAgeMs >= 0
|
|
372
407
|
? opts.staleAgeMs
|
|
373
408
|
: getStaleSweepAgeMs(kind);
|
|
409
|
+
const holders = opts.holders instanceof Set ? opts.holders : listCwdHolders();
|
|
374
410
|
const result = { checkoutsRemoved: 0, emptyRootsRemoved: 0 };
|
|
375
411
|
|
|
376
412
|
let hashEntries;
|
|
@@ -405,6 +441,11 @@ async function sweepStaleWorktreeCheckouts(kind, opts = {}) {
|
|
|
405
441
|
}
|
|
406
442
|
if (Date.now() - stat.mtimeMs < staleAgeMs) continue; // still fresh — never touched
|
|
407
443
|
|
|
444
|
+
if (hasLiveHolder(checkoutDir, holders)) {
|
|
445
|
+
console.log(`[gitWorktree] stale-age sweep (kind:${kind}): skipping ${checkoutDir} — live cwd holder outlasts the age threshold`);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
408
449
|
try {
|
|
409
450
|
await teardownOrphanedCheckout(checkoutDir);
|
|
410
451
|
result.checkoutsRemoved++;
|
|
@@ -465,6 +506,199 @@ async function captureAndCarryBaseDiff({ cwd, dir }) {
|
|
|
465
506
|
return { ok: true, paths };
|
|
466
507
|
}
|
|
467
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Counts how many worktrees of `kind` genuinely exist on disk RIGHT NOW,
|
|
511
|
+
* machine-wide — not limited to any one project's cwd, since the in-memory
|
|
512
|
+
* cap (`activeWorktreeCount`) is itself shared across every project using
|
|
513
|
+
* this kind. Walks the kind's own root directory (same enumeration
|
|
514
|
+
* `sweepStaleWorktreeCheckouts` uses) to find candidate checkouts, resolves
|
|
515
|
+
* each to its owning main tree via `mainTreeFromWorktreeGitFile` (never
|
|
516
|
+
* trusting a directory's mere presence — a half-created or already-removed
|
|
517
|
+
* checkout must not count), then asks EACH distinct main tree's own `git
|
|
518
|
+
* worktree list --porcelain` (the same invocation `reconcileWorktreesOnBoot`
|
|
519
|
+
* already uses, via `execGit`/`parseWorktreeListPorcelain` — no second
|
|
520
|
+
* git-invocation style) which of its registered worktrees still live under
|
|
521
|
+
* this kind's root. Never throws; an unreadable root or a git failure for a
|
|
522
|
+
* given main tree simply contributes 0 from that tree rather than aborting
|
|
523
|
+
* the whole count.
|
|
524
|
+
*/
|
|
525
|
+
async function computeObservedWorktreeCount(kind) {
|
|
526
|
+
const root = worktreeRootFor(kind);
|
|
527
|
+
let hashEntries;
|
|
528
|
+
try {
|
|
529
|
+
hashEntries = await fsp.readdir(root, { withFileTypes: true });
|
|
530
|
+
} catch {
|
|
531
|
+
return 0; // root doesn't exist (or unreadable) — nothing on disk
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const checkoutDirs = [];
|
|
535
|
+
for (const hashEntry of hashEntries) {
|
|
536
|
+
if (!hashEntry.isDirectory()) continue;
|
|
537
|
+
const hashDir = path.join(root, hashEntry.name);
|
|
538
|
+
if (!isUnderRoot(hashDir, root)) continue;
|
|
539
|
+
let keyEntries;
|
|
540
|
+
try {
|
|
541
|
+
keyEntries = await fsp.readdir(hashDir, { withFileTypes: true });
|
|
542
|
+
} catch {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
for (const keyEntry of keyEntries) {
|
|
546
|
+
if (!keyEntry.isDirectory()) continue;
|
|
547
|
+
const checkoutDir = path.join(hashDir, keyEntry.name);
|
|
548
|
+
if (isUnderRoot(checkoutDir, root)) checkoutDirs.push(checkoutDir);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (!checkoutDirs.length) return 0;
|
|
552
|
+
|
|
553
|
+
const mainTrees = new Set();
|
|
554
|
+
for (const dir of checkoutDirs) {
|
|
555
|
+
const mainTree = await mainTreeFromWorktreeGitFile(dir);
|
|
556
|
+
if (mainTree) mainTrees.add(mainTree);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const liveWorktreePaths = new Set();
|
|
560
|
+
for (const mainTree of mainTrees) {
|
|
561
|
+
let out = '';
|
|
562
|
+
try {
|
|
563
|
+
out = await execGit(['worktree', 'list', '--porcelain'], { cwd: mainTree, timeout: 15_000 });
|
|
564
|
+
} catch {
|
|
565
|
+
continue; // that main tree is unreadable right now — its worktrees simply don't count this pass
|
|
566
|
+
}
|
|
567
|
+
for (const entry of parseWorktreeListPorcelain(out)) {
|
|
568
|
+
if (entry.worktree && isUnderRoot(entry.worktree, root)) {
|
|
569
|
+
liveWorktreePaths.add(path.resolve(entry.worktree));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return liveWorktreePaths.size;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Enumerates every process on the machine currently holding some directory as
|
|
578
|
+
* its cwd, as a Set of resolved absolute paths (never throws). Linux-only —
|
|
579
|
+
* walks `/proc/*\/cwd` via `readlinkSync`, tolerating a per-pid `EACCES`
|
|
580
|
+
* (owned by another user) or `ENOENT`/`ESRCH` (process exited between the
|
|
581
|
+
* `readdir` and the `readlink`) by simply skipping that pid rather than
|
|
582
|
+
* aborting the whole scan. On darwin (no `/proc`) returns an empty Set with
|
|
583
|
+
* no log line — `hasLiveHolder`'s caller is expected to also gate on a live
|
|
584
|
+
* `runtime.pid`, which is the darwin-covering half of that check.
|
|
585
|
+
*
|
|
586
|
+
* A readlink target ending in ` (deleted)` (the kernel's marker for a cwd
|
|
587
|
+
* whose directory entry was unlinked while still held open) has that suffix
|
|
588
|
+
* stripped before being added, so a holder of an already-unlinked directory
|
|
589
|
+
* still counts as a holder of the original path.
|
|
590
|
+
*/
|
|
591
|
+
function listCwdHolders() {
|
|
592
|
+
const holders = new Set();
|
|
593
|
+
if (process.platform !== 'linux') return holders;
|
|
594
|
+
let pids;
|
|
595
|
+
try {
|
|
596
|
+
pids = fs.readdirSync('/proc').filter((name) => /^\d+$/.test(name));
|
|
597
|
+
} catch {
|
|
598
|
+
return holders;
|
|
599
|
+
}
|
|
600
|
+
const DELETED_SUFFIX = ' (deleted)';
|
|
601
|
+
for (const pid of pids) {
|
|
602
|
+
try {
|
|
603
|
+
const target = fs.readlinkSync(`/proc/${pid}/cwd`);
|
|
604
|
+
const clean = target.endsWith(DELETED_SUFFIX) ? target.slice(0, -DELETED_SUFFIX.length) : target;
|
|
605
|
+
holders.add(path.resolve(clean));
|
|
606
|
+
} catch {
|
|
607
|
+
// EACCES (not our process), ENOENT/ESRCH (pid exited mid-scan) — skip.
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return holders;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* True when any process on the machine holds `dir` (or a path nested under
|
|
615
|
+
* it) as its cwd — the liveness signal that lets boot reconciliation tell a
|
|
616
|
+
* worktree still backing a running `claude -p` executor (survives an Electron
|
|
617
|
+
* restart via `detached: true`) apart from a genuinely abandoned one. Never
|
|
618
|
+
* throws.
|
|
619
|
+
*
|
|
620
|
+
* `holders`, when supplied, must be a `Set` already produced by
|
|
621
|
+
* `listCwdHolders()` — this lets a caller sweeping N candidate worktrees
|
|
622
|
+
* compute the `/proc` scan once and reuse it, rather than paying a full
|
|
623
|
+
* `readdir` + per-pid `readlink` pass for every candidate. When omitted, this
|
|
624
|
+
* computes its own (a single-candidate convenience, and what the bare
|
|
625
|
+
* `hasLiveHolder(dir)` contract in the AC exercises directly).
|
|
626
|
+
*/
|
|
627
|
+
function hasLiveHolder(dir, holders) {
|
|
628
|
+
try {
|
|
629
|
+
if (!dir || typeof dir !== 'string') return false;
|
|
630
|
+
if (process.platform !== 'linux') return false;
|
|
631
|
+
const resolvedDir = path.resolve(dir);
|
|
632
|
+
const set = holders instanceof Set ? holders : listCwdHolders();
|
|
633
|
+
for (const holder of set) {
|
|
634
|
+
if (holder === resolvedDir || holder.startsWith(resolvedDir + path.sep)) return true;
|
|
635
|
+
}
|
|
636
|
+
return false;
|
|
637
|
+
} catch {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** TTL-memoized wrapper over `computeObservedWorktreeCount` — see the cache header above. */
|
|
643
|
+
async function getObservedWorktreeCount(kind, ttlMs = OBSERVED_WORKTREE_COUNT_TTL_MS) {
|
|
644
|
+
const cache = observedWorktreeCountCache[kind];
|
|
645
|
+
const now = Date.now();
|
|
646
|
+
if (cache.count !== null && now - cache.at < ttlMs) return cache.count;
|
|
647
|
+
const count = await computeObservedWorktreeCount(kind);
|
|
648
|
+
cache.count = count;
|
|
649
|
+
cache.at = now;
|
|
650
|
+
return count;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Serializes `fn` per kind via a promise chain, so the cap-check-and-reserve
|
|
655
|
+
* decision below stays a single atomic step across concurrent callers even
|
|
656
|
+
* though it now sometimes needs to `await` a fresh on-disk observation.
|
|
657
|
+
* `worktreeCapLockChain[kind]` is deliberately kept always-resolving (its
|
|
658
|
+
* own rejection is swallowed) so one caller's failure can never wedge every
|
|
659
|
+
* later caller behind it.
|
|
660
|
+
*/
|
|
661
|
+
function withKindLock(kind, fn) {
|
|
662
|
+
const previous = worktreeCapLockChain[kind];
|
|
663
|
+
const result = previous.then(fn, fn);
|
|
664
|
+
worktreeCapLockChain[kind] = result.then(() => {}, () => {});
|
|
665
|
+
return result;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Decides whether a new worktree of `kind` may be created right now, and if
|
|
670
|
+
* so reserves the slot — the single choke point `createWorktree` calls
|
|
671
|
+
* before doing any actual git work. Replaces the old bare
|
|
672
|
+
* `if (activeWorktreeCount[kind] >= cap) reject; else activeWorktreeCount[kind]++`
|
|
673
|
+
* with the same fast path when comfortably under cap (no await at all, same
|
|
674
|
+
* as before), but when the in-memory counter is AT OR OVER the cap, verifies
|
|
675
|
+
* that against observed on-disk reality before honoring the rejection —
|
|
676
|
+
* self-healing a leaked (never-decremented) counter instead of wedging the
|
|
677
|
+
* cap at a stale high-water mark for the rest of the process's life.
|
|
678
|
+
*
|
|
679
|
+
* TOCTOU safety for concurrent callers: the entire decision (including the
|
|
680
|
+
* observed-count await) runs inside `withKindLock`, so only one caller per
|
|
681
|
+
* kind is ever mid-decision at a time — a strictly stronger guarantee than
|
|
682
|
+
* the old purely-synchronous check+increment, which only protected callers
|
|
683
|
+
* that happened to land in the same tick.
|
|
684
|
+
*/
|
|
685
|
+
async function reserveWorktreeSlot(kind) {
|
|
686
|
+
return withKindLock(kind, async () => {
|
|
687
|
+
const cap = getMaxConcurrentWorktrees(kind);
|
|
688
|
+
if (activeWorktreeCount[kind] >= cap) {
|
|
689
|
+
const observed = await getObservedWorktreeCount(kind);
|
|
690
|
+
if (observed < activeWorktreeCount[kind]) {
|
|
691
|
+
activeWorktreeCount[kind] = observed;
|
|
692
|
+
}
|
|
693
|
+
if (activeWorktreeCount[kind] >= cap) {
|
|
694
|
+
return { ok: false, reason: `worktree cap reached (${cap} concurrent)` };
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
activeWorktreeCount[kind]++;
|
|
698
|
+
return { ok: true };
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
468
702
|
/**
|
|
469
703
|
* Create a linked worktree on a fresh branch checked out from the main
|
|
470
704
|
* tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd,
|
|
@@ -500,16 +734,11 @@ async function createWorktree({ kind, cwd, key }) {
|
|
|
500
734
|
|
|
501
735
|
const baseWasClean = await isBaseTreeClean(cwd);
|
|
502
736
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
// proceed — without this, the cap is a TOCTOU race: N concurrent callers
|
|
509
|
-
// all read the pre-increment count before either increments it. Released
|
|
510
|
-
// again below on any failure path so a failed create never permanently
|
|
511
|
-
// shrinks capacity.
|
|
512
|
-
activeWorktreeCount[kind]++;
|
|
737
|
+
// Cap check + slot reservation as a single atomic step, per kind — see
|
|
738
|
+
// reserveWorktreeSlot's header. Released again below on any failure path
|
|
739
|
+
// so a failed create never permanently shrinks capacity.
|
|
740
|
+
const reservation = await reserveWorktreeSlot(kind);
|
|
741
|
+
if (!reservation.ok) return reservation;
|
|
513
742
|
|
|
514
743
|
const dir = worktreeDirFor(kind, cwd, key);
|
|
515
744
|
const branch = branchNameFor(kind, key);
|
|
@@ -607,6 +836,49 @@ function parseBlockingMergePaths(stderrText) {
|
|
|
607
836
|
return { tracked: Array.from(new Set(tracked)), untracked: Array.from(new Set(untracked)) };
|
|
608
837
|
}
|
|
609
838
|
|
|
839
|
+
/**
|
|
840
|
+
* Resolves `cwd`'s default branch, without ever hardcoding `main`: prefers
|
|
841
|
+
* the remote-tracked default (`origin/HEAD`, set by `git clone`/`git remote
|
|
842
|
+
* set-head`), falls back to the repo's own `init.defaultBranch` config, then
|
|
843
|
+
* to whichever of a local `main`/`master` branch actually exists, and only
|
|
844
|
+
* as a last resort assumes `main`. Never throws — every git failure along
|
|
845
|
+
* this chain (no remote, no config, no matching local branch) simply falls
|
|
846
|
+
* through to the next source.
|
|
847
|
+
*/
|
|
848
|
+
async function resolveDefaultBranch(cwd) {
|
|
849
|
+
try {
|
|
850
|
+
const out = (await execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd, timeout: 10_000 })).trim();
|
|
851
|
+
const match = out.match(/^refs\/remotes\/origin\/(.+)$/);
|
|
852
|
+
if (match && match[1]) return match[1];
|
|
853
|
+
} catch { /* no remote, or origin/HEAD not set — fall through */ }
|
|
854
|
+
try {
|
|
855
|
+
const out = (await execGit(['config', '--get', 'init.defaultBranch'], { cwd, timeout: 10_000 })).trim();
|
|
856
|
+
if (out) return out;
|
|
857
|
+
} catch { /* not configured — fall through */ }
|
|
858
|
+
try {
|
|
859
|
+
await execGit(['show-ref', '--verify', '--quiet', 'refs/heads/main'], { cwd, timeout: 10_000 });
|
|
860
|
+
return 'main';
|
|
861
|
+
} catch { /* no local main — fall through */ }
|
|
862
|
+
try {
|
|
863
|
+
await execGit(['show-ref', '--verify', '--quiet', 'refs/heads/master'], { cwd, timeout: 10_000 });
|
|
864
|
+
return 'master';
|
|
865
|
+
} catch { /* no local master either — fall through */ }
|
|
866
|
+
return 'main';
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* `cwd`'s current branch name, or `null` on a detached HEAD (or any other
|
|
871
|
+
* state `git symbolic-ref` can't resolve to a branch). Never throws.
|
|
872
|
+
*/
|
|
873
|
+
async function getCurrentBranch(cwd) {
|
|
874
|
+
try {
|
|
875
|
+
const out = (await execGit(['symbolic-ref', '-q', '--short', 'HEAD'], { cwd, timeout: 10_000 })).trim();
|
|
876
|
+
return out || null;
|
|
877
|
+
} catch {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
610
882
|
/**
|
|
611
883
|
* Integrate a branch back into `cwd`'s current HEAD — fast-forward when
|
|
612
884
|
* possible, a real merge commit when the main tree advanced underneath (a
|
|
@@ -625,9 +897,30 @@ function parseBlockingMergePaths(stderrText) {
|
|
|
625
897
|
* job's own work — landing such a commit would just re-apply the human's
|
|
626
898
|
* uncommitted edit back onto itself via a merge, and could conflict with
|
|
627
899
|
* the base tree still holding that same path dirty.
|
|
900
|
+
*
|
|
901
|
+
* Refuses outright — before touching any other git state — when `cwd`'s
|
|
902
|
+
* HEAD is not on the repo's own default branch (resolved via
|
|
903
|
+
* `resolveDefaultBranch`, never hardcoded to `main`) or is detached. A
|
|
904
|
+
* stray checkout onto some other branch (e.g. a leftover `sm-job/*` branch
|
|
905
|
+
* from a prior run) would otherwise make every subsequent merge-base/merge
|
|
906
|
+
* silently target the WRONG tree, producing merge "conflicts" that are
|
|
907
|
+
* really just wrong-base artifacts (RCA: starry-night-ships, 2026-09-12).
|
|
908
|
+
* This refusal never checks out, resets, or switches anything — it only
|
|
909
|
+
* reports; a human fixes the checkout.
|
|
628
910
|
*/
|
|
629
911
|
async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
|
|
630
912
|
if (!cwd || !branch) return { ok: false, reason: 'missing cwd/branch' };
|
|
913
|
+
|
|
914
|
+
const defaultBranch = await resolveDefaultBranch(cwd);
|
|
915
|
+
const currentBranch = await getCurrentBranch(cwd);
|
|
916
|
+
if (currentBranch !== defaultBranch) {
|
|
917
|
+
const actual = currentBranch === null ? 'detached HEAD' : currentBranch;
|
|
918
|
+
return {
|
|
919
|
+
ok: false,
|
|
920
|
+
reason: `refusing to integrate: HEAD is on "${actual}", not the repo's default branch "${defaultBranch}" — a stray checkout must be fixed before integration can proceed`,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
631
924
|
let branchHead;
|
|
632
925
|
try {
|
|
633
926
|
branchHead = (await execGit(['rev-parse', branch], { cwd, timeout: 10_000 })).trim();
|
|
@@ -901,6 +1194,99 @@ function keyFromBranch(kind, branch) {
|
|
|
901
1194
|
return branch.slice(prefix.length);
|
|
902
1195
|
}
|
|
903
1196
|
|
|
1197
|
+
/**
|
|
1198
|
+
* Reclaims a job-kind worktree checkout whose OWNING QUEUE ROW is already
|
|
1199
|
+
* terminal (`completed`/`failed`/`skipped` — passed in via `terminalSlugs`)
|
|
1200
|
+
* without waiting for either the stale-age sweep (`sweepStaleWorktreeCheckouts`,
|
|
1201
|
+
* gated on `staleSweepAgeMs`, default 24h) or the next app boot
|
|
1202
|
+
* (`reconcileWorktreesOnBoot` only runs once, at startup). A job whose run
|
|
1203
|
+
* ended without ever reaching `cleanupWorktree` (crash, reap, SIGTERM, an
|
|
1204
|
+
* exception before teardown) otherwise leaves its checkout on disk
|
|
1205
|
+
* indefinitely even though the row itself already resolved.
|
|
1206
|
+
*
|
|
1207
|
+
* Never assumes it's safe to delete — proves it, two ways, for each
|
|
1208
|
+
* candidate:
|
|
1209
|
+
* 1. `isBranchMergedIntoHead`: the branch holds ZERO commits that aren't
|
|
1210
|
+
* already on `cwd`'s HEAD (equivalently, zero commits ahead of main) —
|
|
1211
|
+
* so integrating it again could add nothing, and removing it loses no
|
|
1212
|
+
* committed work.
|
|
1213
|
+
* 2. Every path still dirty in the checkout itself (`git status
|
|
1214
|
+
* --porcelain` run with cwd = the checkout dir) lives under
|
|
1215
|
+
* `session-manager-operations/` — i.e. it's carried-over ops-folder
|
|
1216
|
+
* noise (queue.json/history.jsonl churn picked up by
|
|
1217
|
+
* `captureAndCarryBaseDiff` when the worktree was created), never real
|
|
1218
|
+
* uncommitted job output.
|
|
1219
|
+
*
|
|
1220
|
+
* A candidate failing either check is left alone for a human/the stale-age
|
|
1221
|
+
* sweep to deal with. Returns the list of slugs actually reclaimed. Never
|
|
1222
|
+
* throws.
|
|
1223
|
+
*
|
|
1224
|
+
* Neither proof above is a liveness check: a still-running executor that
|
|
1225
|
+
* hasn't committed yet trivially satisfies proof 1 (zero commits ahead), and
|
|
1226
|
+
* satisfies proof 2 whenever its in-progress output is gitignored. So an
|
|
1227
|
+
* optional `isLive(slug, entry)` predicate — supplied by the caller, which
|
|
1228
|
+
* alone knows the owning queue row's recorded pid; this module keeps no
|
|
1229
|
+
* queue knowledge of its own — is checked FIRST, before either proof, since
|
|
1230
|
+
* it is the cheapest and most decisive gate. A candidate it reports live is
|
|
1231
|
+
* skipped and logged once, without ever reaching (or affecting) the
|
|
1232
|
+
* merge/status proofs below. `isLive` throwing is treated fail-SAFE: assume
|
|
1233
|
+
* live (skip), never assume dead — leaking a checkout one more sweep cycle
|
|
1234
|
+
* is far cheaper than deleting a running job's worktree out from under it.
|
|
1235
|
+
*/
|
|
1236
|
+
async function reclaimTerminalJobOrphans({ cwd, terminalSlugs, isLive }) {
|
|
1237
|
+
const kind = 'job';
|
|
1238
|
+
const root = worktreeRootFor(kind);
|
|
1239
|
+
const reclaimed = [];
|
|
1240
|
+
if (!cwd || !(terminalSlugs instanceof Set) || terminalSlugs.size === 0) return reclaimed;
|
|
1241
|
+
if (!(await isGitRepo(cwd))) return reclaimed;
|
|
1242
|
+
|
|
1243
|
+
let out = '';
|
|
1244
|
+
try {
|
|
1245
|
+
out = await execGit(['worktree', 'list', '--porcelain'], { cwd, timeout: 15_000 });
|
|
1246
|
+
} catch {
|
|
1247
|
+
return reclaimed;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
for (const entry of parseWorktreeListPorcelain(out)) {
|
|
1251
|
+
if (!entry.worktree || !isUnderRoot(entry.worktree, root)) continue;
|
|
1252
|
+
const slug = keyFromBranch(kind, entry.branch);
|
|
1253
|
+
if (!slug || !terminalSlugs.has(slug)) continue;
|
|
1254
|
+
|
|
1255
|
+
if (typeof isLive === 'function') {
|
|
1256
|
+
let live;
|
|
1257
|
+
try {
|
|
1258
|
+
live = await isLive(slug, entry);
|
|
1259
|
+
} catch {
|
|
1260
|
+
live = true; // fail-safe: an isLive that throws must never be read as "dead"
|
|
1261
|
+
}
|
|
1262
|
+
if (live) continue;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const merged = await isBranchMergedIntoHead(cwd, entry.branch);
|
|
1266
|
+
if (!merged) continue; // real un-landed work — never touch
|
|
1267
|
+
|
|
1268
|
+
let statusOut = '';
|
|
1269
|
+
try {
|
|
1270
|
+
statusOut = await execGit(['status', '--porcelain'], { cwd: entry.worktree, timeout: 15_000 });
|
|
1271
|
+
} catch {
|
|
1272
|
+
continue; // can't prove the checkout is clean-of-real-work — never assume
|
|
1273
|
+
}
|
|
1274
|
+
const dirtyPaths = statusOut.split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
|
|
1275
|
+
const opsOnly = dirtyPaths.every(
|
|
1276
|
+
(p) => p === 'session-manager-operations' || p.startsWith('session-manager-operations/')
|
|
1277
|
+
);
|
|
1278
|
+
if (!opsOnly) continue; // real non-ops dirty content present — leave for a human
|
|
1279
|
+
|
|
1280
|
+
await removeWorktreeDir(cwd, entry.worktree);
|
|
1281
|
+
if (entry.branch) {
|
|
1282
|
+
try { await execGit(['branch', '-D', entry.branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
|
|
1283
|
+
}
|
|
1284
|
+
reclaimed.push(slug);
|
|
1285
|
+
}
|
|
1286
|
+
try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best-effort */ }
|
|
1287
|
+
return reclaimed;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
904
1290
|
/**
|
|
905
1291
|
* Boot reconciliation: a worktree that survives a process crash (app killed
|
|
906
1292
|
* mid-run, host reboot) leaks disk and a dangling branch forever unless
|
|
@@ -916,9 +1302,14 @@ function keyFromBranch(kind, branch) {
|
|
|
916
1302
|
* what lets the epic kind skip a worktree whose owning Epic is still
|
|
917
1303
|
* `active` in the project's active-index.json (the caller supplies that
|
|
918
1304
|
* check; this module has no active-index.json knowledge of its own — see
|
|
919
|
-
* this chain's next PRD for the real wiring).
|
|
920
|
-
*
|
|
921
|
-
*
|
|
1305
|
+
* this chain's next PRD for the real wiring). The job kind uses the same
|
|
1306
|
+
* mechanism for a different signal: a job is spawned `detached: true`
|
|
1307
|
+
* (scheduler.cjs), so its `claude -p` executor can survive an app restart —
|
|
1308
|
+
* scheduler.cjs's own `isLive` treats a worktree as live when its owning
|
|
1309
|
+
* queue row is still `running` with a live pid, OR `hasLiveHolder` finds a
|
|
1310
|
+
* process whose cwd is still the checkout itself (see jobWorktreeBootLive.cjs).
|
|
1311
|
+
* A caller that omits `isLive` entirely (e.g. an ad hoc test) still gets the
|
|
1312
|
+
* old unconditional-reap behavior for that kind.
|
|
922
1313
|
*
|
|
923
1314
|
* After the per-cwd pass, this also runs `sweepStaleWorktreeCheckouts` —
|
|
924
1315
|
* a second, root-directory-driven pass that is NOT limited to `cwds`, so a
|
|
@@ -961,7 +1352,7 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
|
|
|
961
1352
|
|
|
962
1353
|
let sweep = { checkoutsRemoved: 0, emptyRootsRemoved: 0 };
|
|
963
1354
|
try {
|
|
964
|
-
sweep = await sweepStaleWorktreeCheckouts(kind, { staleAgeMs: opts.staleAgeMs });
|
|
1355
|
+
sweep = await sweepStaleWorktreeCheckouts(kind, { staleAgeMs: opts.staleAgeMs, holders: opts.holders });
|
|
965
1356
|
} catch {
|
|
966
1357
|
/* never let the orphan sweep take down boot reconciliation */
|
|
967
1358
|
}
|
|
@@ -1018,15 +1409,22 @@ module.exports = {
|
|
|
1018
1409
|
createWorktree,
|
|
1019
1410
|
captureAndCarryBaseDiff,
|
|
1020
1411
|
pathsIdenticalToBranch,
|
|
1412
|
+
resolveDefaultBranch,
|
|
1413
|
+
getCurrentBranch,
|
|
1021
1414
|
integrateBranch,
|
|
1022
1415
|
cleanupWorktree,
|
|
1023
1416
|
salvageWorktreeDiff,
|
|
1024
1417
|
salvageDirtyDelta,
|
|
1025
1418
|
isBranchMergedIntoHead,
|
|
1026
1419
|
parseWorktreeListPorcelain,
|
|
1420
|
+
listCwdHolders,
|
|
1421
|
+
hasLiveHolder,
|
|
1027
1422
|
reconcileWorktreesOnBoot,
|
|
1028
1423
|
sweepStaleWorktreeCheckouts,
|
|
1029
1424
|
mainTreeFromWorktreeGitFile,
|
|
1425
|
+
getObservedWorktreeCount,
|
|
1426
|
+
reserveWorktreeSlot,
|
|
1427
|
+
reclaimTerminalJobOrphans,
|
|
1030
1428
|
// Job-kind convenience wrappers — same call shape jobWorktree.cjs has
|
|
1031
1429
|
// always exposed.
|
|
1032
1430
|
createJobWorktree,
|
|
@@ -1047,4 +1445,16 @@ module.exports = {
|
|
|
1047
1445
|
configFor(kind);
|
|
1048
1446
|
return activeWorktreeCount[kind];
|
|
1049
1447
|
},
|
|
1448
|
+
// Test-only escape hatch for the observed-worktree-count TTL cache, so a
|
|
1449
|
+
// test simulating a leak-then-recover sequence isn't at the mercy of a
|
|
1450
|
+
// stale count left behind by an earlier test in the same process.
|
|
1451
|
+
_resetObservedWorktreeCountCacheForTests(kind) {
|
|
1452
|
+
if (kind) {
|
|
1453
|
+
configFor(kind);
|
|
1454
|
+
observedWorktreeCountCache[kind] = { count: null, at: 0 };
|
|
1455
|
+
} else {
|
|
1456
|
+
observedWorktreeCountCache.job = { count: null, at: 0 };
|
|
1457
|
+
observedWorktreeCountCache.epic = { count: null, at: 0 };
|
|
1458
|
+
}
|
|
1459
|
+
},
|
|
1050
1460
|
};
|
|
@@ -65,7 +65,8 @@ module.exports = {
|
|
|
65
65
|
salvageJobWorktreeDiff: gitWorktree.salvageJobWorktreeDiff,
|
|
66
66
|
salvageJobDirtyDelta: gitWorktree.salvageJobDirtyDelta,
|
|
67
67
|
parseWorktreeListPorcelain: gitWorktree.parseWorktreeListPorcelain,
|
|
68
|
-
reconcileWorktreesOnBoot: (cwds) => gitWorktree.reconcileWorktreesOnBoot(cwds, { kind: KIND }),
|
|
68
|
+
reconcileWorktreesOnBoot: (cwds, opts = {}) => gitWorktree.reconcileWorktreesOnBoot(cwds, { kind: KIND, isLive: opts.isLive }),
|
|
69
|
+
reclaimTerminalJobOrphans: gitWorktree.reclaimTerminalJobOrphans,
|
|
69
70
|
// Test-only escape hatch for the in-memory concurrency counter.
|
|
70
71
|
_resetActiveWorktreeCountForTests(n = 0) { gitWorktree._resetActiveWorktreeCountForTests(KIND, n); },
|
|
71
72
|
_getActiveWorktreeCountForTests() { return gitWorktree._getActiveWorktreeCountForTests(KIND); },
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* jobWorktreeBootLive.cjs — builds the `isLive` predicate scheduler.cjs's
|
|
5
|
+
* boot sweep hands to `jobWorktree.reconcileWorktreesOnBoot` (PRD 1162).
|
|
6
|
+
*
|
|
7
|
+
* Kept in a separate lib file — same reasoning as reaperHelpers.cjs's header
|
|
8
|
+
* — so the predicate's logic is unit-testable without importing scheduler.cjs
|
|
9
|
+
* (which requires electron/ipcMain).
|
|
10
|
+
*
|
|
11
|
+
* A job is spawned `detached: true` (scheduler.cjs), so its `claude -p`
|
|
12
|
+
* executor SURVIVES the Electron app being killed/restarted — a worktree
|
|
13
|
+
* found at boot is NOT, by itself, proof the run that owns it died. This
|
|
14
|
+
* predicate treats a worktree as live via either signal:
|
|
15
|
+
* 1. its owning queue row is still `status: 'running'` with a `runtime.pid`
|
|
16
|
+
* that `claudePidAlive` reports alive, or
|
|
17
|
+
* 2. `hasLiveHolder` finds a process whose cwd is the checkout itself (or a
|
|
18
|
+
* path under it) — covers a row that went terminal (or was never
|
|
19
|
+
* written) while the process is still actually running.
|
|
20
|
+
* Either match logs one line naming the slug and which check held it, so a
|
|
21
|
+
* persistently-skipped leak stays visible (RCA: PRD 1108).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ bootJobs: Array<object>, claudePidAlive: (pid: number) => boolean, hasLiveHolder: (dir: string, holders?: Set<string>) => boolean, cwdHolders?: Set<string> }} deps
|
|
26
|
+
* @returns {(slug: string, entry: { worktree: string, branch: string|null }) => boolean}
|
|
27
|
+
*/
|
|
28
|
+
function buildJobWorktreeIsLive({ bootJobs, claudePidAlive, hasLiveHolder, cwdHolders }) {
|
|
29
|
+
const runningPidBySlug = new Map();
|
|
30
|
+
for (const j of Array.isArray(bootJobs) ? bootJobs : []) {
|
|
31
|
+
if (j && j.status === 'running' && j.runtime && j.runtime.pid) {
|
|
32
|
+
runningPidBySlug.set(j.slug, j.runtime.pid);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return function isLive(slug, entry) {
|
|
37
|
+
const pid = runningPidBySlug.get(slug);
|
|
38
|
+
if (pid && claudePidAlive(pid)) {
|
|
39
|
+
console.log(`[gitWorktree] boot sweep: skipping job worktree slug=${slug} — running row with live pid=${pid}`);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
const dir = entry && entry.worktree;
|
|
43
|
+
if (dir && hasLiveHolder(dir, cwdHolders)) {
|
|
44
|
+
console.log(`[gitWorktree] boot sweep: skipping job worktree slug=${slug} — live cwd holder under ${dir}`);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { buildJobWorktreeIsLive };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* jobWorktreeTerminalOrphanLive.cjs — builds the `isLive` predicate
|
|
5
|
+
* scheduler.cjs's `reclaimTerminalJobOrphansThrottled` hands to
|
|
6
|
+
* `jobWorktree.reclaimTerminalJobOrphans` (PRD 1163).
|
|
7
|
+
*
|
|
8
|
+
* Kept in a separate lib file — same reasoning as jobWorktreeBootLive.cjs's
|
|
9
|
+
* header — so the predicate's logic is unit-testable without importing
|
|
10
|
+
* scheduler.cjs (which requires electron/ipcMain).
|
|
11
|
+
*
|
|
12
|
+
* A queue row already resolved to a terminal status (completed/failed/
|
|
13
|
+
* skipped) is not, by itself, proof its executor actually stopped —
|
|
14
|
+
* overrunning-job escalation, a phantom pidless reap, or a cancel whose kill
|
|
15
|
+
* didn't land can all produce a terminal row while the real process keeps
|
|
16
|
+
* running. This predicate treats a candidate worktree as live via either
|
|
17
|
+
* signal, checked in this order (cheapest/most decisive first):
|
|
18
|
+
* 1. `hasLiveHolder` finds a process whose cwd is the checkout itself (or a
|
|
19
|
+
* path under it) — project-agnostic, catches a still-running executor
|
|
20
|
+
* regardless of what its queue row says.
|
|
21
|
+
* 2. the terminal row's own recorded `runtime.pid` is still alive
|
|
22
|
+
* (`claudePidAlive`) — catches the case `hasLiveHolder` can't see
|
|
23
|
+
* (darwin has no `/proc`, so `hasLiveHolder` always reports no holder
|
|
24
|
+
* there; the pid check is the only gate on that platform, which is an
|
|
25
|
+
* accepted, documented tradeoff).
|
|
26
|
+
* Either match logs one line naming the slug and the reason, and reports it
|
|
27
|
+
* to the optional `onLive(slug, reason)` callback — this is how the caller
|
|
28
|
+
* (scheduler.cjs) learns which terminal rows to stamp
|
|
29
|
+
* `worktreeReclaimBlockedLive` on, without this module ever reading or
|
|
30
|
+
* writing queue state itself.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{
|
|
35
|
+
* terminalJobs: Array<object>,
|
|
36
|
+
* claudePidAlive: (pid: number) => boolean,
|
|
37
|
+
* hasLiveHolder: (dir: string, holders?: Set<string>) => boolean,
|
|
38
|
+
* cwdHolders?: Set<string>,
|
|
39
|
+
* onLive?: (slug: string, reason: string) => void,
|
|
40
|
+
* }} deps
|
|
41
|
+
* @returns {(slug: string, entry: { worktree: string, branch: string|null }) => boolean}
|
|
42
|
+
*/
|
|
43
|
+
function buildTerminalOrphanIsLive({ terminalJobs, claudePidAlive, hasLiveHolder, cwdHolders, onLive }) {
|
|
44
|
+
const pidBySlug = new Map();
|
|
45
|
+
for (const j of Array.isArray(terminalJobs) ? terminalJobs : []) {
|
|
46
|
+
if (j && j.slug && j.runtime && j.runtime.pid) pidBySlug.set(j.slug, j.runtime.pid);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return function isLive(slug, entry) {
|
|
50
|
+
const dir = entry && entry.worktree;
|
|
51
|
+
if (dir && hasLiveHolder(dir, cwdHolders)) {
|
|
52
|
+
const reason = `live cwd holder under ${dir}`;
|
|
53
|
+
console.log(`[gitWorktree] terminal orphan reclaim: skipping job worktree slug=${slug} — ${reason}`);
|
|
54
|
+
if (onLive) onLive(slug, reason);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
const pid = pidBySlug.get(slug);
|
|
58
|
+
if (pid && claudePidAlive(pid)) {
|
|
59
|
+
const reason = `terminal row's recorded pid=${pid} still alive`;
|
|
60
|
+
console.log(`[gitWorktree] terminal orphan reclaim: skipping job worktree slug=${slug} — ${reason}`);
|
|
61
|
+
if (onLive) onLive(slug, reason);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { buildTerminalOrphanIsLive };
|