tickmarkr 2.6.6 → 2.6.8

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.
Files changed (51) hide show
  1. package/dist/adapters/claude-code.js +8 -2
  2. package/dist/adapters/codex-commit-check.d.ts +3 -2
  3. package/dist/adapters/codex-commit-check.js +37 -10
  4. package/dist/adapters/codex.js +10 -4
  5. package/dist/cli/commands/beat.d.ts +7 -2
  6. package/dist/cli/commands/beat.js +144 -77
  7. package/dist/cli/commands/fleet.js +66 -21
  8. package/dist/cli/commands/init.js +18 -7
  9. package/dist/cli/commands/plan.js +16 -18
  10. package/dist/cli/commands/verify.js +4 -2
  11. package/dist/cli/help.d.ts +1 -0
  12. package/dist/cli/help.js +14 -6
  13. package/dist/cli/index.d.ts +1 -1
  14. package/dist/cli/index.js +1 -1
  15. package/dist/drivers/herdr.js +2 -2
  16. package/dist/drivers/orca.d.ts +12 -4
  17. package/dist/drivers/orca.js +200 -39
  18. package/dist/gates/baseline.d.ts +9 -0
  19. package/dist/gates/baseline.js +14 -3
  20. package/dist/gates/cache.js +1 -1
  21. package/dist/gates/evidence.js +3 -3
  22. package/dist/gates/review.d.ts +1 -1
  23. package/dist/gates/review.js +77 -13
  24. package/dist/gates/run-gates.d.ts +21 -6
  25. package/dist/gates/run-gates.js +439 -224
  26. package/dist/gates/test-manifest.d.ts +42 -0
  27. package/dist/gates/test-manifest.js +203 -118
  28. package/dist/run/beat-lifecycle.d.ts +239 -0
  29. package/dist/run/beat-lifecycle.js +1782 -0
  30. package/dist/run/daemon.d.ts +4 -0
  31. package/dist/run/daemon.js +129 -40
  32. package/dist/run/git-trust.d.ts +102 -0
  33. package/dist/run/git-trust.js +334 -0
  34. package/dist/run/git.d.ts +24 -3
  35. package/dist/run/git.js +169 -30
  36. package/dist/run/journal.js +15 -3
  37. package/dist/run/lease.d.ts +23 -0
  38. package/dist/run/lease.js +232 -38
  39. package/dist/run/merge.js +4 -4
  40. package/dist/run/stall.js +20 -1
  41. package/dist/run/supervision.d.ts +8 -0
  42. package/dist/run/supervision.js +24 -4
  43. package/dist/run/verification-job.d.ts +53 -0
  44. package/dist/run/verification-job.js +159 -0
  45. package/dist/tui/ink/fleet-app.d.ts +16 -2
  46. package/dist/tui/ink/fleet-app.js +265 -141
  47. package/package.json +1 -1
  48. package/skills/tickmarkr-auto/SKILL.md +80 -27
  49. package/skills/tickmarkr-loop/SKILL.md +87 -27
  50. package/skills/tickmarkr-overseer/SKILL.md +125 -47
  51. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +9 -7
@@ -140,6 +140,10 @@ export declare const watchCommand: (runId: string) => string;
140
140
  * skip stays satisfied; a gate that ran and passed stays satisfied.
141
141
  */
142
142
  export declare const gateSatisfied: (g: GateResult) => boolean;
143
+ /** OBS-1010 / v2.6.8 T1: the review seats a journal's demotions carry into a resumed run as a SOFT
144
+ * preference (ranked last, never excluded) — every review-pool-demotion except the launch-caused ones, which
145
+ * never demote a seat (live or replayed). plan.ts previews a resume with this same fold. */
146
+ export declare function replayedReviewerDemotions(events: readonly JournalEvent[]): Set<string>;
143
147
  /**
144
148
  * T4 (OBS-265): the journal with the review objections a round did NOT hinge on removed. Judge and
145
149
  * review are now launched together, so a round can journal a failed review that the serial walk would
@@ -33,7 +33,7 @@ import { executionSignal, remainingExecutionMs, withExecutionBudget, withoutExec
33
33
  import { repairSelectionDecision, repairSelectionEnabled } from "./repair-selection.js";
34
34
  import { failureDisposition, reserveInfrastructureRetry } from "./recovery.js";
35
35
  import { runEnvironment } from "./environment.js";
36
- import { changeRepresented, cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, removeWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shGit, SUITE_PARENT_ENV, verificationProtocol, WORKTREE_LAYOUT_CONTRACT, WORKTREES_DIR, worktreePath } from "./git.js";
36
+ import { addDetachedWorktree, changeRepresented, cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, removeWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shGit, SUITE_PARENT_ENV, verificationProtocol, WORKTREE_LAYOUT_CONTRACT, WORKTREES_DIR, worktreePath } from "./git.js";
37
37
  import { MASK } from "./redact.js";
38
38
  import { runInteractiveSeed } from "./interactive-seed.js";
39
39
  import { classifyRepairDisposition, resolveScopeHints } from "./repair-disposition.js";
@@ -376,9 +376,26 @@ function classifyInfraResult(g) {
376
376
  // diagnostics (never-started files, a worker RPC timeout) that make such a red infrastructure-SHAPED.
377
377
  // Shape alone never parks anything: it only buys one isolated re-observation before a charge. A
378
378
  // fresh-fingerprint section masks digits to `#`, so both spellings of a number are read.
379
- /** C (E1): the one no-verdict that is the host's slowness, not the seat's — the same predicate run-gates
380
- * applies to its run-scoped tally, read here for the live task strike and the resume reconstruction. */
381
- const proofTimeoutNote = (data) => data.cause === "seat-launch-failed" && data.launchCause === "checkout-proof-timeout";
379
+ /** v2.6.8 T1: a seat that never LAUNCHED (any launchCause — checkout-proof-timeout, none, or other
380
+ * detail) is not a seat that failed to answer — the same predicate run-gates applies to its run-scoped
381
+ * tally, read here for the live task strike and the resume reconstruction. It never strikes. */
382
+ const launchFailureNote = (data) => data.cause === "seat-launch-failed";
383
+ /** v2.6.8 T1: a journaled review-pool-demotion a launch failure caused. The pre-2.6.8 engine struck seats
384
+ * that never launched, so an older journal can carry a demotion whose trigger was a launch failure, or whose
385
+ * two-strike pair counted one beside a single real no-verdict that was not silent on its own. */
386
+ const launchCausedDemotion = (data) => {
387
+ if (launchFailureNote(data))
388
+ return true;
389
+ const silent = data.seatAuthoredBytes === 0 || data.cause === "silent" || data.cause === "launch-never-started";
390
+ return Array.isArray(data.causes) && !silent && data.causes.filter((cause) => cause !== "seat-launch-failed").length < 2;
391
+ };
392
+ /** OBS-1010 / v2.6.8 T1: the review seats a journal's demotions carry into a resumed run as a SOFT
393
+ * preference (ranked last, never excluded) — every review-pool-demotion except the launch-caused ones, which
394
+ * never demote a seat (live or replayed). plan.ts previews a resume with this same fold. */
395
+ export function replayedReviewerDemotions(events) {
396
+ return new Set(events.filter((event) => event.event === "review-pool-demotion" && typeof event.data.reviewer === "string"
397
+ && !launchCausedDemotion(event.data)).map((event) => event.data.reviewer));
398
+ }
382
399
  /** C: the gate-fresh-forced reason that bounds the out-of-scope re-execution, one per journaled subject. */
383
400
  const OUTSIDE_SCOPE_RED = "no-commit-out-of-scope-red";
384
401
  const NAMED_FAILURE_FILE_RE = /^\s*(\S+\.(?:test|spec)\.[cm]?[jt]sx?)\s+>/gm;
@@ -2232,25 +2249,25 @@ export async function runDaemon(repoRoot, opts = {}) {
2232
2249
  process.on("SIGINT", onTermination);
2233
2250
  process.on("SIGTERM", onTermination);
2234
2251
  journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
2235
- const demotedReviewers = new Set(journal.read()
2236
- .filter((event) => event.event === "review-pool-demotion" && typeof event.data.reviewer === "string")
2237
- .map((event) => event.data.reviewer));
2238
- // OBS-1010: a demotion the JOURNAL carries into this session left the rotation for the run — a
2239
- // resumed daemon excludes that seat from every review pick (reviewer role only; the worker channel
2240
- // is untouched — channel-demotion is the worker fold, replayExcludedChannels). In-session demotions
2241
- // keep their soft ordering above so a still-live seat can be re-tried at the end of the rotation.
2242
- const replayedReviewerExclusions = opts.resume ? [...demotedReviewers] : [];
2252
+ // OBS-1010 / v2.6.8 T1: a demotion the JOURNAL carries into this session is replayed as what it was
2253
+ // live — a SOFT preference (reviewer role only; the worker channel is untouched — channel-demotion is
2254
+ // the worker fold, replayExcludedChannels). The seat ranks last, even over prefer, and may still be
2255
+ // re-tried at the end of the rotation; a resume never hardens one strike into an exclusion. Retirement
2256
+ // is the only hard rule: two real no-verdict strikes (the tally below) put a seat out for the run,
2257
+ // in-session and after resume alike, whatever prefer or history say. A launch-caused demotion an older
2258
+ // engine journaled is not replayed at all — neither soft nor hard.
2259
+ const demotedReviewers = replayedReviewerDemotions(journal.read());
2243
2260
  // OBS-1025 add.2 / OBS-1052 (Leg-2): the run-scoped no-verdict tally run-gates retires a seat from at two
2244
2261
  // strikes. execTask's badReviewers already excludes a seat after ONE strike within a task, so this map
2245
2262
  // is what bounds a seat that flakes once per task (or once per session) across the run. Seeded from the
2246
2263
  // journal's review-no-verdict rows — the same notes noteReviewEvent appends — so a resumed daemon keeps
2247
2264
  // the count; run-gates appends to it in place, exactly as it does demotedReviewers.
2248
- // C (E1): a checkout-proof timeout never struck the live tally (run-gates), so a resume never seeds one.
2265
+ // v2.6.8 T1: a launch failure never struck the live tally (run-gates), so a resume never seeds one.
2249
2266
  const reviewNoVerdicts = new Map();
2250
2267
  for (const event of journal.read()) {
2251
2268
  if (event.event !== "review-no-verdict" || event.data.noVerdict !== true || typeof event.data.reviewer !== "string")
2252
2269
  continue;
2253
- if (proofTimeoutNote(event.data))
2270
+ if (launchFailureNote(event.data))
2254
2271
  continue;
2255
2272
  reviewNoVerdicts.set(event.data.reviewer, [...(reviewNoVerdicts.get(event.data.reviewer) ?? []), String(event.data.cause)]);
2256
2273
  }
@@ -2777,7 +2794,8 @@ export async function runDaemon(repoRoot, opts = {}) {
2777
2794
  const pristine = mkdtempSync(join(parent, `baseline-recapture-${process.pid}-`));
2778
2795
  const staged = `${baselinePath}.${process.pid}.tmp`;
2779
2796
  try {
2780
- const added = await shGit(`git worktree add --detach ${shq(pristine)} ${shq(baseRef)}`, repoRoot);
2797
+ // the detached worktree capability (see addDetachedWorktree); a commondir moved while it ran is refused
2798
+ const added = await addDetachedWorktree(repoRoot, pristine, baseRef);
2781
2799
  if (added.code !== 0)
2782
2800
  throw new Error(`baseline recapture: journaled base ${baseRef} cannot be checked out: ${added.stderr.trim()}`);
2783
2801
  if (!linkNodeModules(repoRoot, pristine, { force: true }))
@@ -3413,15 +3431,19 @@ export async function runDaemon(repoRoot, opts = {}) {
3413
3431
  await park(t, `${bootstrapExhausted} failed at bootstrap and no eligible channel remains after resume${poolExhaustion(t, cfg)}`, "infra", assignment, rs?.attempts ?? 0, startMs, 0, 0, undefined, 0, "fresh", { cause: "bootstrap", channel: bootstrapExhausted });
3414
3432
  return;
3415
3433
  }
3416
- // Keep one live list: recovery retries must see exclusions added by onGate during the round.
3417
- const badReviewers = [...replayedReviewerExclusions];
3418
- // C (E1): a checkout-proof timeout is the host being slow, not the seat failing — it is excluded for
3419
- // the CURRENT round only (so an always-timeout pool still ends the round), never struck task-wide.
3434
+ // Keep one live list: recovery retries must see exclusions added by onGate during the round. It starts
3435
+ // empty on resume too: a replayed demotion is soft (demotedReviewers), retirement is the run tally.
3436
+ const badReviewers = [];
3437
+ // v2.6.8 T1: a seat that failed to launch (any launchCause) is excluded for the CURRENT round only —
3438
+ // after its one bounded same-seat launch retry (run-gates), so an always-refusing pool still ends the
3439
+ // round — and is never struck task-wide or run-wide: a later round may seat it again.
3420
3440
  let roundTimeouts = [];
3421
- const noteReviewEvent = (e) => {
3441
+ const noteReviewEvent = (note) => {
3442
+ // The same-seat launch retry is bounded per candidate: its row carries the commit it was spent on.
3443
+ const e = note.name === "review-infra-retry" && gateSubject ? { ...note, payload: { ...note.payload, commit: gateSubject.commit } } : note;
3422
3444
  journal.append(e.name, t.id, e.payload);
3423
3445
  if (e.name === "review-no-verdict" && typeof e.payload.reviewer === "string") {
3424
- const strikes = proofTimeoutNote(e.payload) ? roundTimeouts : badReviewers;
3446
+ const strikes = launchFailureNote(e.payload) ? roundTimeouts : badReviewers;
3425
3447
  if (!strikes.includes(e.payload.reviewer))
3426
3448
  strikes.push(e.payload.reviewer);
3427
3449
  }
@@ -3434,7 +3456,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3434
3456
  }
3435
3457
  };
3436
3458
  // Keep the measured siblings, including a red judge, while replacing only a missing review.
3437
- // Every dispatched seat is excluded for this task, so the loop is bounded by the eligible pool.
3459
+ // Every seat that returned no verdict is excluded for this task — a launch failure for this round only,
3460
+ // after its one bounded relaunch — and a retired seat for the run, so the loop is bounded by the eligible pool.
3438
3461
  const runReviewRecovery = async (task, ctx, allowSelection = true) => {
3439
3462
  ctx.authorizeInfraRetry = cfg.executionPolicy?.boundedInfrastructure
3440
3463
  ? (subject, cause) => reserveInfrastructureRetry(journal.read(), task.id, subject, journal.append.bind(journal), cause) : undefined;
@@ -3460,28 +3483,83 @@ export async function runDaemon(repoRoot, opts = {}) {
3460
3483
  && row.event === "phase-start" && row.data.phase === "gates").length,
3461
3484
  };
3462
3485
  roundTimeouts = [];
3486
+ // v2.6.8 T1: the same-seat launch retries this candidate already spent, replayed from the journal so
3487
+ // a recovery round, a recheck or a resume never re-grants one (and never turns one into a strike).
3488
+ const launchRetried = () => journal.read().filter((row) => row.taskId === task.id && row.event === "review-infra-retry"
3489
+ && row.data.sameSeat === true && row.data.commit === gateSubject?.commit && typeof row.data.reviewer === "string")
3490
+ .map((row) => row.data.reviewer);
3463
3491
  // C: every gate-round boundary — a returned or a THROWN round — publishes the one row held behind
3464
3492
  // a still-pending sibling and forgets the pending set, before any park or task-failed row lands.
3493
+ // v2.6.8 T1: a review result whose recovery pool is retired-only is classified BEFORE it is published
3494
+ // (retiredOnly), and the round returns that same parked result, so the journal row and the result the
3495
+ // park reads state one outcome — an infrastructure no-verdict, never a failed review.
3465
3496
  const gateRound = async (subject) => {
3497
+ const parked = new Map();
3466
3498
  try {
3467
- return await runGates(subject, { ...ctx, excludeReviewers: [...badReviewers, ...roundTimeouts] });
3499
+ const done = await runGates(subject, { ...ctx, excludeReviewers: [...badReviewers, ...roundTimeouts], launchRetried: launchRetried(),
3500
+ onGate: async (e) => {
3501
+ const park = e.phase === "end" && e.gate === "review" ? retiredOnly(e.result) : undefined;
3502
+ if (park === undefined || e.phase !== "end")
3503
+ return ctx.onGate?.(e);
3504
+ parked.set(e.result, park);
3505
+ return ctx.onGate?.({ ...e, result: park });
3506
+ } });
3507
+ return { ...done, results: done.results.map((g) => parked.get(g) ?? g) };
3468
3508
  }
3469
3509
  finally {
3470
3510
  settleParallelRound();
3471
3511
  }
3472
3512
  };
3473
- const round = await gateRound(task);
3474
- let review = round.results.find((g) => g.gate === "review");
3475
3513
  // The recovery pick carries every preserved author, exactly as reviewGate's own pick does.
3476
3514
  const authors = ctx.carriedAuthors?.length ? [...ctx.carriedAuthors] : [channelKey(ctx.author)];
3477
- while (review?.meta?.noVerdict === true) {
3478
- // Recovery must honor the gate's floor, including the seats that just failed to
3479
- // return a verdict; a below-floor alternative cannot replace the infra result. This round's
3480
- // proof timeouts count too — runGates folds every excluded seat into its own floor.
3481
- const { floor } = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers, ...roundTimeouts]);
3482
- const next = pickReviewer(ctx.author, ctx.channels, [...badReviewers, ...roundTimeouts], cfg.review.prefer ?? [], floor, reviewHistory, undefined, demotedReviewers, carriedAuthorVendors(ctx.channels, authors), authors);
3483
- if (!next)
3515
+ // Recovery must honor the gate's floor, including the seats that just failed to
3516
+ // return a verdict; a below-floor alternative cannot replace the infra result. This round's
3517
+ // launch failures count too — runGates folds every excluded seat into its own floor.
3518
+ const pick = (exclude) => pickReviewer(ctx.author, ctx.channels, exclude, cfg.review.prefer ?? [], gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers, ...roundTimeouts]).floor, reviewHistory, undefined, demotedReviewers, carriedAuthorVendors(ctx.channels, authors), authors);
3519
+ // v2.6.8 T1: soft preference is not retirement. demotedReviewers — live or replayed on resume — only
3520
+ // ranks a seat last, so a once-demoted seat stays seatable; a seat two REAL no-verdict strikes retired
3521
+ // is out for the run, so every recovery pick excludes it outright — prefer and history can never
3522
+ // resurrect it, and runGates would refuse it anyway. A launch failure is neither: it buys one bounded
3523
+ // same-seat relaunch per candidate (launchRetried) and then leaves the seat out of THIS round only.
3524
+ const retiredSeats = () => [...reviewNoVerdicts].filter(([, causes]) => causes.length >= 2).map(([seat]) => seat);
3525
+ // The retired-only recovery park: when retirement is what empties the pool (the pick without the
3526
+ // retired exclusion would seat a retired seat), record the seat the pool cannot seat and why, buy
3527
+ // no dispatch, and park infrastructure naming it out for the run — never a soft-ranked pick that
3528
+ // dispatches it again, never a gate-fail. The tally is seeded from the journal, so every resume
3529
+ // or recheck replays this same row and park, whether its round dispatched a seat or found none.
3530
+ // It is decided where the review result is published (gateRound): a round that found no eligible
3531
+ // reviewer, or a no-verdict no recovery pick can replace — and once more after the round,
3532
+ // should another task retire a seat in between. The parked result is the same no-verdict shape every
3533
+ // review.ts no-verdict has: in memory `pass: false` (GateResult.pass is a boolean, and an unreviewed
3534
+ // candidate fails closed — it never merges), `noVerdict` + `infra`; the gate-result row written for a
3535
+ // noVerdict review below drops `pass` and adds `skipped`, so outcome readers, round budgets and
3536
+ // failure counters read one infrastructure outcome, and the infra park — not the gate-fail one — is
3537
+ // what the task gets.
3538
+ const retiredOnly = (r) => {
3539
+ if (r.meta?.cannotSeat !== undefined || (r.meta?.noEligibleReviewer !== true && (r.meta?.noVerdict !== true
3540
+ || pick([...badReviewers, ...roundTimeouts, ...retiredSeats()]))))
3541
+ return undefined;
3542
+ const retired = retiredSeats();
3543
+ const blocked = pick([...badReviewers, ...roundTimeouts].filter((seat) => !retired.includes(seat)));
3544
+ if (!blocked || !retired.includes(channelKey(blocked)))
3545
+ return undefined;
3546
+ const seat = channelKey(blocked);
3547
+ const causes = reviewNoVerdicts.get(seat) ?? [];
3548
+ journal.append("review-infra-retry", t.id, { reviewer: seat, cause: "retired", cannotSeat: true, causes });
3549
+ const { noEligibleReviewer: _none, ...meta } = r.meta ?? {};
3550
+ return { ...r, pass: false, meta: { cause: "retired", ...meta, noVerdict: true, cannotSeat: seat, infra: true, classification: "infra" },
3551
+ details: `${r.details}\nreview recovery cannot seat ${seat}: two real no-verdicts (${causes.join(", ")}) retired it — it is out for the run, and no other eligible seat remains` };
3552
+ };
3553
+ const round = await gateRound(task);
3554
+ let review = round.results.find((g) => g.gate === "review");
3555
+ while (review?.meta?.noVerdict === true && review.meta.cannotSeat === undefined) {
3556
+ const next = pick([...badReviewers, ...roundTimeouts, ...retiredSeats()]);
3557
+ if (!next) {
3558
+ const parked = retiredOnly(review);
3559
+ if (parked)
3560
+ round.results = round.results.map((g) => g === review ? parked : g);
3484
3561
  break;
3562
+ }
3485
3563
  journal.append("review-infra-retry", t.id, { reviewer: channelKey(next), cause: review.meta.cause });
3486
3564
  const retried = await gateRound({ ...task, gates: ["review"] });
3487
3565
  const replacement = retried.results.find((g) => g.gate === "review");
@@ -3510,7 +3588,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3510
3588
  gate: "review", flaked: rr.flaked, retried: rr.retried,
3511
3589
  ...(g.meta?.unparseable === true ? { secondUnparseable: true } : {}),
3512
3590
  });
3513
- // C (E1): a seat re-routed after a checkout-proof timeout is excluded for this round only.
3591
+ // v2.6.8 T1: a seat re-routed after a launch failure is excluded for this round only.
3514
3592
  if (!roundTimeouts.includes(rr.flaked))
3515
3593
  badReviewers.push(rr.flaked);
3516
3594
  }
@@ -6042,13 +6120,23 @@ export async function runDaemon(repoRoot, opts = {}) {
6042
6120
  reapFailure = error instanceof Error ? error.message : String(error);
6043
6121
  }
6044
6122
  }
6045
- else if (keepOpen && (workerFinished || processExited || driver.id !== "subprocess")) {
6046
- await reapWorker(slot);
6047
- keptSlots.push(slot);
6048
- supersededWorkerSlot = slot;
6049
- }
6050
6123
  else {
6051
- await closeSlot(slot);
6124
+ // A worker that exited cleanly can still leave a census the reap cannot read. That is the
6125
+ // same stall disposition as above, never a silent task failure; a kept slot is recorded
6126
+ // first so run end still closes it (public CI 37324617042, D-1278).
6127
+ try {
6128
+ if (keepOpen && (workerFinished || processExited || driver.id !== "subprocess")) {
6129
+ keptSlots.push(slot);
6130
+ supersededWorkerSlot = slot;
6131
+ await reapWorker(slot);
6132
+ }
6133
+ else {
6134
+ await closeSlot(slot);
6135
+ }
6136
+ }
6137
+ catch (error) {
6138
+ reapFailure = error instanceof Error ? error.message : String(error);
6139
+ }
6052
6140
  }
6053
6141
  if (retryMode === "resume") {
6054
6142
  const transcript = readResumeTranscript();
@@ -6072,7 +6160,8 @@ export async function runDaemon(repoRoot, opts = {}) {
6072
6160
  tokens = addUsage(tokens, attemptUsage);
6073
6161
  metered++;
6074
6162
  }
6075
- if (deadWorkerPark) {
6163
+ // An unreadable cleanup outranks a dead-worker park: only the reapFailure park re-verifies the census.
6164
+ if (deadWorkerPark && !reapFailure) {
6076
6165
  await park(t, deadWorkerPark.reason, "stall", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode, { ref: deadWorkerPark.ref });
6077
6166
  return;
6078
6167
  }
@@ -0,0 +1,102 @@
1
+ /** A covered git invocation refused before spawn; `path` names the offending metadata. */
2
+ export declare class GitTrustRefusal extends Error {
3
+ readonly path: string;
4
+ readonly detail: string;
5
+ constructor(path: string, detail: string, options?: ErrorOptions);
6
+ }
7
+ /**
8
+ * The canonical, independently expected common git directory for the checkout enclosing `cwd` — never what
9
+ * a mutable commondir says. Throws GitTrustRefusal naming the authority path when it cannot be established.
10
+ */
11
+ export declare function trustedCommonDir(cwd: string): string;
12
+ /**
13
+ * The uncached pre-spawn check for a covered git invocation at `cwd`. Outside any checkout it is a no-op
14
+ * (unrelated shell commands stay compatible). Inside one it refuses, naming the file, when the commondir git
15
+ * would follow resolves anywhere but the expected common directory, or when the trusted common config enables
16
+ * extensions.worktreeConfig and the linked gitdir holds a config.worktree.
17
+ */
18
+ export declare function assertGitTrust(cwd: string): void;
19
+ /** The child of tickmarkr's own git in a linked checkout: its pinned environment, the post-exit recheck and its release. */
20
+ export interface OwnGitPin {
21
+ /** The whole child environment: the caller's, with the pin set and every unpinned inherited ref backend removed. */
22
+ env: NodeJS.ProcessEnv;
23
+ /** Throws GitTrustRefusal naming commondir when it changed in ANY way since the check that derived `env`. */
24
+ recheck: () => void;
25
+ /** Removes this attempt's private ref view; called once its child has exited, or when it never spawned. */
26
+ release: () => void;
27
+ /** git names ref paths through the view; its output names the trusted common directory instead. */
28
+ unview: (text: string) => string;
29
+ /** The trusted common directory this attempt derived from the layout: the authority a raced worktree add rolls back through. */
30
+ store: string;
31
+ }
32
+ /**
33
+ * v2.6.8 T2: what an own-git CALLER declares at its call site — never read from the command text, its tokens, login or
34
+ * cwd, and never a way out of any pin: every own-git child keeps the path pin, the ref-store pin and the recheck.
35
+ * `createsWorktree: "branch"` is declared by the three branch-creating adds (git writes a branch add's new HEAD as the
36
+ * symbolic branch ref, which the pin leaves intact). The two DETACHED adds declare the capability by calling
37
+ * `addDetachedWorktree` (src/run/git.ts), because under the pin git stubs a detached add's new HEAD as
38
+ * `ref: refs/heads/.invalid`.
39
+ */
40
+ export interface OwnGitCapability {
41
+ createsWorktree?: "branch";
42
+ rollback?: WorktreeRollback;
43
+ }
44
+ /**
45
+ * What a worktree-creating call site declares it creates, so a result refused AFTER its child ran (a commondir raced
46
+ * mid-child) is rolled back: the checkout `dir` and its registration, and — when the add creates or moves one — the
47
+ * branch (`prior` restores a moved branch; without it the new branch is deleted). The rollback runs through the
48
+ * attempt's own trusted common directory (TrustedStoreCapability), never the caller's checkout, so it completes while
49
+ * the caller's commondir stays hostile.
50
+ */
51
+ export interface WorktreeRollback {
52
+ dir: string;
53
+ branch?: {
54
+ name: string;
55
+ prior?: string;
56
+ };
57
+ }
58
+ /**
59
+ * Internal to addDetachedWorktree's add step: its ref store is pinned to the trusted common directory itself, not to a
60
+ * view. git stubs the new checkout's HEAD under ANY ref payload and, through a view, would resolve that stub into a
61
+ * shared `refs/heads/.invalid`; naming the common directory keeps it in the new gitdir, where the next step replaces
62
+ * it. That step writes no ref of the CALLER's checkout, so a commondir removed under it has no HEAD to redirect.
63
+ */
64
+ export interface DetachedAddCapability {
65
+ createsWorktree: "detached";
66
+ rollback: WorktreeRollback;
67
+ }
68
+ /**
69
+ * v2.6.8 T2: cleanup authority. The child reads and writes ONLY `store` — a trusted common directory derived once from
70
+ * the trusted layout (gitfile and `<common>/worktrees/<name>`, never a mutable commondir) — as an ordinary repository:
71
+ * GIT_DIR names it and every inherited redirect (common dir, work tree, ref backend, index, objects) is removed. It never
72
+ * consults the caller's checkout, so removal completes even while that checkout's commondir stays hostile.
73
+ */
74
+ export interface TrustedStoreCapability {
75
+ store: string;
76
+ }
77
+ /**
78
+ * v2.6.8 T2 (P3): the same uncached check, returning the child of tickmarkr's OWN git in a linked checkout — `env`
79
+ * (the caller's) with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set to the canonical gitdir, trusted common directory
80
+ * and worktree root derived above, AND the ref store: GIT_REFERENCE_BACKEND names this attempt's private view of the
81
+ * trusted store (refStoreView), since git's ref store otherwise resolves shared refs through the gitdir's commondir
82
+ * FILE even under GIT_COMMON_DIR. So a commondir rewritten after this check (by a live worker or a surviving
83
+ * descendant) is followed for neither config, hooks, info/, objects nor refs: HEAD, branches, status and diffs read
84
+ * the real repository and every ref write lands in the trusted store, whatever an inherited GIT_CONFIG_* or ref backend
85
+ * says; a commondir REMOVED after it cannot move the main checkout's branch. Before such a child spawns, the git it
86
+ * would run must prove it honours that pin (refStorePinHonoured); one that cannot (git < 2.54) runs without it, below.
87
+ * Nothing here reads the command, and no caller leaves any pin. `recheck` (after the child exits) fails closed on ANY change to commondir since the check: the result
88
+ * of that child is never returned. An ordinary checkout or a non-repository gets no pin.
89
+ * NAMED RESIDUAL: git still reads $GIT_DIR/config.worktree when the trusted common config already enables
90
+ * extensions.worktreeConfig, so a config.worktree planted AFTER this check is not closed.
91
+ */
92
+ export declare function ownGitPin(cwd: string, env: NodeJS.ProcessEnv, capability?: OwnGitCapability | DetachedAddCapability | TrustedStoreCapability): OwnGitPin | undefined;
93
+ /** The inert hooks directory every protected git child receives (no hook can exist beneath it). */
94
+ export declare const INERT_HOOKS_PATH = "/dev/null";
95
+ /**
96
+ * A copy of `env` whose git children run with core.fsmonitor=false and an inert core.hooksPath, despite repo
97
+ * config and inherited GIT_CONFIG_COUNT/GIT_CONFIG_PARAMETERS: git applies GIT_CONFIG_PARAMETERS after
98
+ * GIT_CONFIG_COUNT and the last value wins, so the forced pair is appended last. Every other value survives.
99
+ * Named keys only — this does not neutralize arbitrary executable drivers. v2.6.8 T2: only tickmarkr's OWN git
100
+ * children (shGit/shGitOk) receive it; payload shells (gate commands, suites) keep the operator's git config.
101
+ */
102
+ export declare function protectedGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;