tickmarkr 2.8.7 → 2.8.9

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.
@@ -23,7 +23,7 @@ import { herdrSealShellPrefix, MAX_BUF, SubprocessDriver } from "../drivers/subp
23
23
  import { formatOwnedName } from "../drivers/types.js";
24
24
  import { captureBaseline, detectGateCommands, detectVacuousOracles } from "../gates/baseline.js";
25
25
  import { reobserveTestFiles, runGates } from "../gates/run-gates.js";
26
- import { getVerdictStore, isInfraResult, resolveStateDir } from "../gates/cache.js";
26
+ import { environmentFingerprint, getVerdictStore, isInfraResult, resolveStateDir } from "../gates/cache.js";
27
27
  import { RUNNER_INFRA_DIAGNOSTIC_RE, timeoutShaped } from "../gates/timeout-shaped.js";
28
28
  import { filesGlob } from "../graph/files-glob.js";
29
29
  import { addEvidence, attributeBlocked, batteryPriority, blockedTasks, getTask, graphDefinitionHash, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
@@ -34,7 +34,7 @@ import { executionSignal, remainingExecutionMs, withExecutionBudget, withoutExec
34
34
  import { repairSelectionDecision, repairSelectionEnabled } from "./repair-selection.js";
35
35
  import { failureDisposition, reserveInfrastructureRetry } from "./recovery.js";
36
36
  import { runEnvironment } from "./environment.js";
37
- import { addDetachedWorktree, changeRepresented, cleanupRunWorktrees, declaredBaseContainment, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, reflogWitness, removeWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shell, shGit, SUITE_PARENT_ENV, verificationProtocol, WorktreeAdminRaceError, WORKTREE_LAYOUT_CONTRACT, WORKTREES_DIR, worktreePath } from "./git.js";
37
+ import { addDetachedWorktree, changeRepresented, cleanupRunWorktrees, declaredBaseContainment, dependencyLinkRefusal, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, reflogWitness, removeWorktree, resolvedCapacity, runWithForkBudget, runWithVerificationBudget, sameCapacity, sameVerification, sh, shell, shGit, SUITE_PARENT_ENV, verificationProtocol, WorktreeAdminRaceError, WORKTREE_LAYOUT_CONTRACT, WORKTREES_DIR, worktreePath } from "./git.js";
38
38
  import { MASK } from "./redact.js";
39
39
  import { runInteractiveSeed } from "./interactive-seed.js";
40
40
  import { classifyRepairDisposition, namedPaths, resolveScopeHints } from "./repair-disposition.js";
@@ -883,6 +883,32 @@ export const HARVESTED_RESULT_SUMMARY = "harvested: the worktree carries committ
883
883
  export function commandsHash(commands) {
884
884
  return createHash("sha256").update(JSON.stringify(Object.entries(commands).sort())).digest("hex").slice(0, 12);
885
885
  }
886
+ // Queue row 24 (D-2134): refuse links before npm probes; an unreadable identity stays absent for the battery's infra row.
887
+ function measureTipEnvironment(worktree, capacity) {
888
+ if (dependencyLinkRefusal(worktree))
889
+ return undefined;
890
+ try {
891
+ return environmentFingerprint({ worktree, scope: "tip", capacity });
892
+ }
893
+ catch {
894
+ return undefined;
895
+ }
896
+ }
897
+ // Queue row 24 (D-2134): validate the parts too; a matching fingerprint alone cannot rescue a malformed stamp.
898
+ function sameTipEnvironment(recorded, current) {
899
+ if (!current || !recorded || typeof recorded !== "object" || Array.isArray(recorded))
900
+ return false;
901
+ const { fingerprint, parts } = recorded;
902
+ if (fingerprint !== current.fingerprint || !parts || typeof parts !== "object" || Array.isArray(parts))
903
+ return false;
904
+ const p = parts;
905
+ const verification = p.verification;
906
+ return p.nodeRuntime === current.parts.nodeRuntime && p.runner === current.parts.runner
907
+ && p.lockfile === current.parts.lockfile && p.selectedSet === undefined
908
+ && p.capacity !== undefined && sameCapacity(p.capacity, current.parts.capacity)
909
+ && !!verification && (verification.source === "explicit" || verification.source === "npm-config")
910
+ && sameVerification(verification, current.parts.verification);
911
+ }
886
912
  /**
887
913
  * T4 (OBS-266): the journal's LAST verification cycle — the (tip, cmdHash) pair the most recent run
888
914
  * of the verify commands spoke for, the gates it got a pass from, and whether anything failed in it.
@@ -904,7 +930,7 @@ function lastVerifyCycle(events) {
904
930
  if (e.event === "tip-verify-start") {
905
931
  const { tip, cmdHash } = e.data;
906
932
  cur = typeof tip === "string" && typeof cmdHash === "string"
907
- ? { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [e.data.capacity], evidenceByGate: new Map() }
933
+ ? { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [e.data.capacity], tipEnvironments: [e.data.tipEnvironment], evidenceByGate: new Map() }
908
934
  : undefined;
909
935
  afterRunEnd = false;
910
936
  continue;
@@ -927,7 +953,7 @@ function lastVerifyCycle(events) {
927
953
  continue;
928
954
  }
929
955
  if (!cur || afterRunEnd || cur.tip !== tip || cur.cmdHash !== cmdHash) {
930
- cur = { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [], evidenceByGate: new Map() };
956
+ cur = { tip, cmdHash, gates: new Set(), failed: false, forgiven: false, capacities: [], tipEnvironments: [], evidenceByGate: new Map() };
931
957
  }
932
958
  afterRunEnd = false;
933
959
  // T7: EVERY verdict row's own capacity, not just the start row's. The start row is a statement of
@@ -935,6 +961,7 @@ function lastVerifyCycle(events) {
935
961
  // these rows, so a row whose capacity differs from the session's — or which is malformed — has to
936
962
  // be able to sink the cycle by itself.
937
963
  cur.capacities.push(e.data.capacity);
964
+ cur.tipEnvironments.push(e.data.tipEnvironment);
938
965
  if (e.event === "tip-verify-failed")
939
966
  cur.failed = true;
940
967
  else {
@@ -1204,13 +1231,13 @@ export function identityDetails(leg, state, branch, runId, expected) {
1204
1231
  */
1205
1232
  export function appendIdentityFailure(journal, f) {
1206
1233
  if (!f.started)
1207
- journal.append("tip-verify-start", undefined, { tip: f.expected, cmdHash: f.cmdHash, capacity: f.capacity, gates: [], cached: false });
1234
+ journal.append("tip-verify-start", undefined, { tip: f.expected, cmdHash: f.cmdHash, capacity: f.capacity, tipEnvironment: f.tipEnvironment, gates: [], cached: false });
1208
1235
  const found = f.state?.kind === "regular" || f.state?.kind === "symbolic" ? f.state.commit : undefined;
1209
1236
  journal.append("tip-verify-failed", undefined, {
1210
1237
  gate: "integration", cmd: "", exitCode: 1, fingerprints: [], details: f.details, ...(f.infra ? { infra: true } : {}),
1211
1238
  integration: { branch: f.branch, expected: f.expected ?? null, found: found ?? null, leg: f.leg,
1212
1239
  ...(f.state?.kind === "symbolic" ? { target: f.state.target } : {}) },
1213
- tip: f.expected, cmdHash: f.cmdHash, capacity: f.capacity,
1240
+ tip: f.expected, cmdHash: f.cmdHash, capacity: f.capacity, tipEnvironment: f.tipEnvironment,
1214
1241
  });
1215
1242
  }
1216
1243
  /** O: I(expected) — or, with no expected value, the ref must be regular and its commit becomes the expected. */
@@ -1271,16 +1298,12 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1271
1298
  opts.signal?.throwIfAborted();
1272
1299
  const cmdHash = commandsHash(commands);
1273
1300
  const { identity } = opts;
1274
- // T7: the capacity this session's verify children WOULD run under — the third thing a carried
1275
- // green must match, beside the tip and the command set. A cached verdict is the one place a green
1276
- // crosses a session boundary with nothing re-run, and a session resumed at a different capacity
1277
- // divides the machine by a different number: that green was established in another world, so it is
1278
- // not carried forward and the commands run again. A pre-T7 cycle records no capacity and keeps
1279
- // exactly the behaviour it has today.
1301
+ // Queue row 24 (D-2134): measure once against the repository whose installed packages the verifier receives.
1280
1302
  const capacity = resolvedCapacity();
1303
+ const tipEnvironment = measureTipEnvironment(identity?.repo ?? intWt, capacity);
1281
1304
  const identityFailure = (f) => {
1282
- appendIdentityFailure(journal, { ...f, branch: identity.branch, cmdHash, capacity });
1283
- return { failed: true, identityFailed: true, deferred: [] };
1305
+ appendIdentityFailure(journal, { ...f, branch: identity.branch, cmdHash, capacity, tipEnvironment });
1306
+ return { failed: true, identityFailed: true, deferred: [], tipEnvironment };
1284
1307
  };
1285
1308
  let tip;
1286
1309
  let clean = true; // with `identity` the battery runs in a fresh checkout and K checks tracked content
@@ -1299,25 +1322,30 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1299
1322
  const gates = Object.keys(commands).filter((g) => g !== "tipTest");
1300
1323
  if (!gates.includes("test") && commands.tipTest)
1301
1324
  gates.push("test");
1325
+ // Queue row 24 (D-2134 R2/Q6/Q7): carry requires tip, commands, every gate and capacity AND the stamped runner
1326
+ // and environment identity with a measured lifecycle on every row. An unstamped cycle re-verifies once.
1327
+ // Package content drift under equal lockfile and links is the declared residual, as in per-gate reuse.
1302
1328
  const cached = last !== undefined && !last.failed && !last.forgiven && last.tip === tip && last.cmdHash === cmdHash
1303
1329
  && gates.every((g) => last.gates.has(g))
1304
- && last.capacities.every((recorded) => sameCapacity(recorded, capacity));
1330
+ && last.capacities.every((recorded) => sameCapacity(recorded, capacity))
1331
+ && last.tipEnvironments.every((recorded) => sameTipEnvironment(recorded, tipEnvironment))
1332
+ && !dependencyLinkRefusal(identity?.repo ?? intWt);
1305
1333
  // A pair can be verified red and then green without either SHA or command hash changing (for
1306
1334
  // example, an external service or ignored fixture recovers). Delimit attempts explicitly so that
1307
1335
  // the earlier red cannot remain latched into the later complete green cycle.
1308
- journal.append("tip-verify-start", undefined, { tip, cmdHash, capacity, gates, cached: clean && cached });
1336
+ journal.append("tip-verify-start", undefined, { tip, cmdHash, capacity, tipEnvironment, gates, cached: clean && cached });
1309
1337
  opts.signal?.throwIfAborted();
1310
1338
  if (clean && cached) {
1311
- journal.append("tip-verify-cached", undefined, { tip, cmdHash, capacity, gates });
1339
+ journal.append("tip-verify-cached", undefined, { tip, cmdHash, capacity, tipEnvironment, gates });
1312
1340
  // The skip must not read as a red. Every surface derives the tip's verdict from this cycle's
1313
1341
  // `tip-verify` events (cockpit derive.ts tipVerificationPassed: a run-end claiming "passed" with
1314
1342
  // ZERO events is fail-closed to FALSE), so a carried-forward green still journals its per-gate
1315
1343
  // pass — `cached: true` keeps it honest about not having re-run the command.
1316
1344
  for (const gate of gates) {
1317
1345
  const cmd = gate === "test" && commands.tipTest ? commands.tipTest : commands[gate];
1318
- journal.append("tip-verify", undefined, { ...reusedTipEvidence(last.evidenceByGate.get(gate) ?? {}), gate, cmd, pass: true, exitCode: 0, cached: true, tip, cmdHash, capacity });
1346
+ journal.append("tip-verify", undefined, { ...reusedTipEvidence(last.evidenceByGate.get(gate) ?? {}), gate, cmd, pass: true, exitCode: 0, cached: true, tip, cmdHash, capacity, tipEnvironment });
1319
1347
  }
1320
- return identity ? { failed: false, expected: tip, identityFailed: false, deferred: [] } : false;
1348
+ return identity ? { failed: false, expected: tip, identityFailed: false, deferred: [], tipEnvironment } : false;
1321
1349
  }
1322
1350
  const battery = async (dir) => {
1323
1351
  let tipFailed = false;
@@ -1340,7 +1368,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1340
1368
  };
1341
1369
  if (r.pass) {
1342
1370
  // Q121s: a forgiven pass journals its fingerprints — honest about what was carried, never a silent green.
1343
- journal.append("tip-verify", undefined, { ...evidence, gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.reused ? { cached: true } : {}), ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints, ...(r.baselineProvenance ? { baselineProvenance: r.baselineProvenance } : {}) } : {}), tip, cmdHash, capacity: measuredCapacity });
1371
+ journal.append("tip-verify", undefined, { ...evidence, gate: r.gate, cmd: r.cmd, pass: true, exitCode: r.exitCode, details: r.details, ...(r.reused ? { cached: true } : {}), ...(r.forgiven ? { forgiven: true, fingerprints: r.fingerprints, ...(r.baselineProvenance ? { baselineProvenance: r.baselineProvenance } : {}) } : {}), tip, cmdHash, capacity: measuredCapacity, tipEnvironment });
1344
1372
  }
1345
1373
  else {
1346
1374
  journal.append("tip-verify-failed", undefined, {
@@ -1355,6 +1383,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1355
1383
  tip,
1356
1384
  cmdHash,
1357
1385
  capacity: measuredCapacity,
1386
+ tipEnvironment,
1358
1387
  });
1359
1388
  tipFailed = true;
1360
1389
  }
@@ -1372,7 +1401,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1372
1401
  const changed = await checkoutChange(opened.dir, tip, opened.h0);
1373
1402
  if (changed)
1374
1403
  return identityFailure({ started: true, expected: tip, leg: "checkout", details: `verification checkout changed during the battery: ${changed}` });
1375
- return { failed: tipFailed, expected: tip, identityFailed: false, deferred };
1404
+ return { failed: tipFailed, expected: tip, identityFailed: false, deferred, tipEnvironment };
1376
1405
  }
1377
1406
  finally {
1378
1407
  if (checkout)
@@ -2104,6 +2133,54 @@ function endedRunIdsForReconcile(repoRoot) {
2104
2133
  }
2105
2134
  return ended;
2106
2135
  }
2136
+ // Queue row 240 (D-2138): ownership comes only from dispatch rows in this checkout's runs directory.
2137
+ async function removeJournaledTaskWorktrees(repo, owner, current, activeBranch) {
2138
+ let events;
2139
+ try {
2140
+ events = owner.read();
2141
+ }
2142
+ catch {
2143
+ return;
2144
+ } // Queue row 240 (D-2138): unreadable ownership proves no task checkout.
2145
+ const branch = activeBranch ?? events.find((e) => e.event === "run-start")?.data.branch;
2146
+ if (typeof branch !== "string")
2147
+ return;
2148
+ await cleanupRunWorktrees(repo, branch, {
2149
+ removeTaskIds: events.flatMap((e) => e.event === "task-dispatch" && e.taskId ? [e.taskId] : []),
2150
+ run: owner.runId, by: current.runId,
2151
+ producer: (task) => knownProducer(events, task),
2152
+ append: (event, task, data) => {
2153
+ // Queue row 240 (D-2138): the recovery handle retains its terminal task reason without reopening the decision.
2154
+ const terminal = task && [...events].reverse().find((e) => e.taskId === task
2155
+ && ["task-human", "task-failed", "task-done", "task-dispatch"].includes(e.event));
2156
+ const reason = terminal && terminal.data.reason;
2157
+ owner.append(event, task, { ...data, ...(typeof reason === "string" ? { reason } : {}) });
2158
+ },
2159
+ failed: (data) => current.append("worktree-removal-failed", undefined, data),
2160
+ });
2161
+ }
2162
+ async function sweepDeadRunWorktrees(repo, current) {
2163
+ let runs;
2164
+ try {
2165
+ runs = readdirSync(join(tickmarkrDir(repo), "runs"));
2166
+ }
2167
+ catch {
2168
+ return;
2169
+ }
2170
+ for (const run of runs) {
2171
+ if (run === current.runId)
2172
+ continue;
2173
+ try {
2174
+ const owner = Journal.open(repo, run);
2175
+ const lifecycle = [...owner.read()].reverse().find((e) => e.event === "run-start" || e.event === "run-resume");
2176
+ const pid = lifecycle?.data.pid;
2177
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0 || isPidLive(pid))
2178
+ continue;
2179
+ await removeJournaledTaskWorktrees(repo, owner, current);
2180
+ }
2181
+ catch { /* Queue row 240 (D-2138): unreadable ownership proves nothing and never refuses a start. */ }
2182
+ }
2183
+ }
2107
2184
  export async function runDaemon(repoRoot, opts = {}) {
2108
2185
  // OBS-1045: a daemon launched from inside a leased command (a nested runner, an operator's test
2109
2186
  // shell) inherits its ancestor's lease token through the environment, and every gate it ran would
@@ -3188,6 +3265,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3188
3265
  // panes too. OBS-777: the one startup snapshot lets it reclaim only older runs proven ended, while
3189
3266
  // every live or unknown run remains spared.
3190
3267
  await reconcile();
3268
+ if (!keepForever)
3269
+ await sweepDeadRunWorktrees(repoRoot, journal);
3191
3270
  // v1.4 self-reference guard: a random nonce on the worker trailer AND exit marker. Displayed
3192
3271
  // source/diffs (e.g. a worker editing tickmarkr's own prompt.ts/daemon.ts) can't know it, so an echoed
3193
3272
  // TICKMARKR_RESULT/TICKMARKR_EXIT literal can never premature-harvest the worker. Quote-split keeps the
@@ -3380,7 +3459,11 @@ export async function runDaemon(repoRoot, opts = {}) {
3380
3459
  const worker = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "worker-result");
3381
3460
  const disposition = classifyRepairDisposition({
3382
3461
  // OBS-1072 add.2: hints are lexed from the readable excerpt, never from terminal control bytes.
3383
- results: results.map((g) => ({ ...g, details: readableExcerpt(g.details) })), files: t.files, inventory: changed,
3462
+ results: results.map((g) => ({ ...g, details: readableExcerpt(g.details),
3463
+ // Queue row 235 (D-2134): oracle output has the same readable boundary as gate details.
3464
+ ...(Array.isArray(g.meta?.oracles) ? { meta: { ...g.meta, oracles: g.meta.oracles.map((oracle) => oracle && typeof oracle === "object" && typeof oracle.output === "string"
3465
+ ? { ...oracle, output: readableExcerpt(oracle.output) } : oracle) } } : {}),
3466
+ })), files: t.files, inventory: changed,
3384
3467
  refusalSummary: worker?.data.ok === false && typeof worker.data.summary === "string" ? worker.data.summary : undefined,
3385
3468
  });
3386
3469
  for (const diagnostic of disposition.diagnostics) {
@@ -3520,6 +3603,64 @@ export async function runDaemon(repoRoot, opts = {}) {
3520
3603
  // row is deliberately written before the later worktree-recreation row so the journal cannot
3521
3604
  // describe only the commits it carried while omitting uncommitted work the removal destroyed.
3522
3605
  const producerNow = () => knownProducer(journal.read(), t.id);
3606
+ const readTaskWork = async (taskBase, priorWt, events = journal.read()) => {
3607
+ if (existsSync(priorWt))
3608
+ return { head: await gitHead(priorWt), commits: await commitsAheadOf(taskBase, priorWt) };
3609
+ const taskBranch = `${branch}--${t.id}`;
3610
+ try {
3611
+ const read = async (command) => {
3612
+ const result = await shGit(command, repoRoot);
3613
+ if (result.code !== 0)
3614
+ throw new Error(result.stderr || result.stdout || `git exited ${result.code}`);
3615
+ return result.stdout.trim();
3616
+ };
3617
+ const ahead = async (head) => (await read(`git log --reverse --format=%H ${shq(taskBase)}..${shq(head)}`)).split("\n").filter(Boolean);
3618
+ const ancestor = async (base, head) => {
3619
+ const result = await shGit(`git merge-base --is-ancestor ${shq(base)} ${shq(head)}`, repoRoot);
3620
+ if (result.code !== 0 && result.code !== 1)
3621
+ throw new Error(result.stderr || result.stdout || `ancestry exited ${result.code}`);
3622
+ return result.code === 0;
3623
+ };
3624
+ const branchRef = `refs/heads/${taskBranch}`;
3625
+ const exists = await shGit(`git show-ref --verify --quiet ${shq(branchRef)}`, repoRoot);
3626
+ if (exists.code !== 0 && exists.code !== 1)
3627
+ throw new Error(exists.stderr || exists.stdout || `branch read exited ${exists.code}`);
3628
+ const tip = exists.code === 0 ? await read(`git rev-parse --verify ${shq(`${branchRef}^{commit}`)}`) : undefined;
3629
+ const removalAt = events.map((e) => e.event === "worktree-removed" && e.data.task === t.id).lastIndexOf(true);
3630
+ const removal = events[removalAt];
3631
+ if (removal && !events.slice(removalAt + 1).some((e) => e.event === "worktree-recreation" && e.taskId === t.id)
3632
+ && typeof removal.data.head === "string") {
3633
+ const resolved = await shGit(`git rev-parse --verify ${shq(`${removal.data.head}^{commit}`)}`, repoRoot);
3634
+ const head = resolved.stdout.trim();
3635
+ if (resolved.code === 0 && head && (!tip || await ancestor(tip, head))) {
3636
+ const commits = await ahead(head);
3637
+ let snapshot;
3638
+ if (typeof removal.data.ref === "string") {
3639
+ const pinned = await read(`git rev-parse --verify ${shq(`${removal.data.ref}^{commit}`)}`);
3640
+ if (pinned !== head)
3641
+ snapshot = removal.data.ref;
3642
+ }
3643
+ return { head, commits, snapshot, rebuiltFrom: "removal" };
3644
+ }
3645
+ }
3646
+ if (!tip)
3647
+ return { commits: [] };
3648
+ const commits = await ahead(tip);
3649
+ if (commits.length === 0)
3650
+ return { commits: [] };
3651
+ const dispatch = [...events].reverse().find((e) => e.event === "task-dispatch" && e.taskId === t.id);
3652
+ const base = dispatch?.data.base === undefined ? events.find((e) => e.event === "run-start")?.data.baseRef : dispatch.data.base;
3653
+ if (typeof base !== "string" || !await ancestor(base, tip))
3654
+ throw new Error("tip does not descend from its recorded dispatch base");
3655
+ const ref = `refs/tickmarkr/preserved/${tip}`;
3656
+ await read(`git update-ref ${shq(ref)} ${shq(tip)}`);
3657
+ journal.append("worktree-preserved", t.id, { ref, ...producerFields(producerNow()) });
3658
+ return { head: tip, commits, rebuiltFrom: "branch" };
3659
+ }
3660
+ catch (error) {
3661
+ return { commits: [], error: `Queue row 240 (D-2138): cannot rebuild task branch ${taskBranch}: ${error instanceof Error ? error.message : String(error)}` };
3662
+ }
3663
+ };
3523
3664
  const recreateTaskWorktree = async (taskBranch, taskBase, priorWt) => {
3524
3665
  const producer = producerNow();
3525
3666
  const ref = await preserveWorktree(priorWt, producer);
@@ -3895,8 +4036,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3895
4036
  ...(typeof g.meta.producerAttempt === "number" ? { producerAttempt: g.meta.producerAttempt } : {}),
3896
4037
  } : {}),
3897
4038
  ...((cfg.executionPolicy || repairSelection) && !g.pass ? { disposition: failureDisposition(g) } : {}),
3898
- ...Object.fromEntries(["runnerInfraRerun", "hostStarvedRerun", "recoveryBlocked", "failingFiles", "selectionDecision", "failureEvidence",
3899
- "forgivenFingerprints", "freshFingerprints", "baselineProvenance", "judgment", "adjudication", "judgeDisagreement", "interruptions"]
4039
+ ...Object.fromEntries(["runnerInfraRerun", "judgeIndependent", "hostStarvedRerun", "recoveryBlocked", "failingFiles", "selectionDecision", "failureEvidence",
4040
+ "forgivenFingerprints", "freshFingerprints", "baselineProvenance", "oracles", "judgment", "adjudication", "judgeDisagreement", "interruptions"]
3900
4041
  .filter((key) => g.meta?.[key] !== undefined).map((key) => [key, g.meta[key]])),
3901
4042
  // OBS-540: preserve terminal-vs-retryable infra exactly. normalizeGateOutcome deliberately
3902
4043
  // defaults a legacy infra row to retryable, so dropping an explicit false here reverses the
@@ -4345,7 +4486,12 @@ export async function runDaemon(repoRoot, opts = {}) {
4345
4486
  if (uncertain)
4346
4487
  return decline(uncertain);
4347
4488
  }
4348
- const commits = existsSync(wt) ? await commitsAheadOf(published, wt) : [];
4489
+ const work = await readTaskWork(published, wt);
4490
+ if (work.error) {
4491
+ await park(t, work.error, "infra", found.assignment, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4492
+ return "parked";
4493
+ }
4494
+ const commits = work.commits;
4349
4495
  if (!found.result.finished && commits.length === 0)
4350
4496
  return decline("unfinished");
4351
4497
  recordHarvest(commits, found.result.finished, found.result.summary);
@@ -4402,6 +4548,8 @@ export async function runDaemon(repoRoot, opts = {}) {
4402
4548
  return found.assignment;
4403
4549
  };
4404
4550
  const harvestAuthor = rs && !satisfiedGate && !replayedGates && !recheck ? await harvestInterruptedAttempt() : undefined;
4551
+ if (harvestAuthor === "parked")
4552
+ return;
4405
4553
  // OBS-1202: a recheck bound to a stall park that recorded a reapFailure re-verifies the parked
4406
4554
  // attempt's owned census through the same reapWorker a live harvest uses, BEFORE any gate reads
4407
4555
  // its tree. Only a complete, empty census releases the harvested commits to the battery; an
@@ -4454,7 +4602,12 @@ export async function runDaemon(repoRoot, opts = {}) {
4454
4602
  const preservedRef = recheck
4455
4603
  ? effectiveEvents(journal.read()).reverse().find((e) => e.taskId === t.id && e.event === "task-approved" && e.data.release === RECHECK_RELEASE)?.data.recheckedRef
4456
4604
  : undefined;
4457
- if (!existsSync(priorWt) && !preservedRef) {
4605
+ const work = preservedRef ? undefined : await readTaskWork(taskBase, priorWt);
4606
+ if (work?.error) {
4607
+ await park(t, work.error, "infra", assignment, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4608
+ return;
4609
+ }
4610
+ if (!work?.head && !preservedRef) {
4458
4611
  if (satisfiedGate || recheck)
4459
4612
  throw new Error(`${recheck ? "recheck" : `approved gate ${satisfiedGate}`} cannot resume: task worktree is missing`);
4460
4613
  // Observed passes are an optimization, never authority: without the task worktree there is no
@@ -4469,9 +4622,15 @@ export async function runDaemon(repoRoot, opts = {}) {
4469
4622
  : satisfiedGate
4470
4623
  ? `approved gate ${satisfiedGate}`
4471
4624
  : `recorded gates on ${replayedGates.commit.slice(0, 10)}`;
4472
- const priorTaskTip = preservedRef ? (await shGit(`git rev-parse ${shq(preservedRef)}`, repoRoot)).stdout.trim() : await gitHead(priorWt);
4473
- const priorTaskSubject = await gateCommitSubject(taskBase, priorTaskTip, preservedRef ? repoRoot : priorWt);
4474
- const commitsToCarry = preservedRef ? await commitsAheadOfRef(taskBase, priorTaskTip, repoRoot) : await commitsAheadOf(taskBase, priorWt);
4625
+ const priorTaskTip = preservedRef ? (await shGit(`git rev-parse ${shq(preservedRef)}`, repoRoot)).stdout.trim() : work.head;
4626
+ const priorTaskSubject = await gateCommitSubject(taskBase, priorTaskTip, preservedRef || work?.rebuiltFrom ? repoRoot : priorWt);
4627
+ const commitsToCarry = preservedRef ? await commitsAheadOfRef(taskBase, priorTaskTip, repoRoot) : [...work.commits];
4628
+ // Queue row 240 (D-2138): rechecking a dirt refusal gates the committed work as a present checkout did.
4629
+ // The refused bytes remain recoverable; an explicitly checked ref still selects its exact tree.
4630
+ const dirtRecheck = recheck && [...journal.read()].reverse()
4631
+ .find((e) => e.taskId === t.id && e.event === "gate-result")?.data.dirtyWorktree === true;
4632
+ if (work?.snapshot && !dirtRecheck)
4633
+ commitsToCarry.push(work.snapshot);
4475
4634
  // 2.8.1 T2 (ANL-023 §7.2): a task with a recorded merge whose prior tip is PROVEN in E (`merge-base --is-ancestor`
4476
4635
  // exit 0 — an empty or failed history read is no proof) keeps that tip as its gated subject — recreated on E it
4477
4636
  // would be gated AT E, and a sibling's publication since its merge would hide the recorded merge. Nothing is gated
@@ -4505,6 +4664,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4505
4664
  satisfiedGate = journal.replaySatisfiedGates(new Map([[t.id, currentTaskSubject]])).get(t.id);
4506
4665
  }
4507
4666
  journal.append("worktree-recreation", t.id, {
4667
+ ...(work?.rebuiltFrom ? { rebuiltFrom: work.rebuiltFrom } : {}),
4508
4668
  attempted: commitsToCarry, carried: carriedCommits, ...(accountedCommits.length > 0 ? { accounted: accountedCommits } : {}),
4509
4669
  });
4510
4670
  // OBS-212: same fail-closed rule as the dispatch path — but this path is worse, because it runs
@@ -5063,6 +5223,15 @@ export async function runDaemon(repoRoot, opts = {}) {
5063
5223
  });
5064
5224
  }
5065
5225
  lastContextTokens = undefined;
5226
+ const taskBase = published; // deps are merged → visible to this task (E, never the ref as it stands)
5227
+ const taskBranch = `${branch}--${t.id}`;
5228
+ const priorWt = worktreePath(repoRoot, taskBranch);
5229
+ const recreating = existsSync(priorWt);
5230
+ const work = await readTaskWork(taskBase, priorWt, journaledSoFar);
5231
+ if (work.error) {
5232
+ await park(t, work.error, "infra", assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode);
5233
+ return;
5234
+ }
5066
5235
  graph = setStatus(graph, t.id, "running");
5067
5236
  saveGraph(repoRoot, graph);
5068
5237
  await trackedDriver.project?.(t.id, "in-progress");
@@ -5073,6 +5242,7 @@ export async function runDaemon(repoRoot, opts = {}) {
5073
5242
  const workerDispatchOrdinal = nextWorkerDispatchOrdinal++;
5074
5243
  vendorEscalated.delete(channelKey(assignment)); // dispatched now: from here on it IS tried
5075
5244
  journal.append("task-dispatch", t.id, {
5245
+ base: taskBase,
5076
5246
  ...(scopeApproval?.data.release === "scope-request" ? { files: t.files, graphDefinitionHash: graphDefinitionHash(graph) } : {}),
5077
5247
  assignment, attempt, workerDispatchOrdinal, provenance: dispatchProvenance([
5078
5248
  channelKey(assignment) === channelKey(r.assignment) ? r.provenance
@@ -5090,15 +5260,17 @@ export async function runDaemon(repoRoot, opts = {}) {
5090
5260
  ...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
5091
5261
  });
5092
5262
  journal.phaseStart(t.id, "worker", { attempt, assignment });
5093
- const taskBase = published; // deps are merged → visible to this task (E, never the ref as it stands)
5094
5263
  taskBases.set(t.id, taskBase);
5095
- const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
5096
- const priorWt = worktreePath(repoRoot, taskBranch);
5097
- const recreating = existsSync(priorWt);
5098
- // Carry the interrupted worker's snapshot, never incidental files created by later gates.
5099
- const preserved = recreating ? reapedWorktreeRef : undefined;
5264
+ // Queue row 240 (D-2138): read a missing checkout like a present one: its recorded removal first,
5265
+ // with its preserved snapshot carried as a reaped worker's is; else a task branch proven by its
5266
+ // journaled dispatch base or the run-start base; else nothing. Never reset a branch holding commits unproven.
5267
+ // Queue row 240 (D-2138): like recheck, a dispatch after a dirt refusal carries committed work only;
5268
+ // the refused bytes remain recoverable from their ref, as when the prior checkout was present.
5269
+ const dirtRefusal = [...journaledSoFar].reverse()
5270
+ .find((e) => e.taskId === t.id && (e.event === "gate-result" || e.event === "task-dispatch"))?.data.dirtyWorktree === true;
5271
+ const preserved = recreating ? reapedWorktreeRef : dirtRefusal ? undefined : work.snapshot;
5100
5272
  reapedWorktreeRef = undefined;
5101
- const commitsToCarry = recreating ? await commitsAheadOf(taskBase, priorWt) : [];
5273
+ const commitsToCarry = [...work.commits];
5102
5274
  if (preserved)
5103
5275
  commitsToCarry.push(preserved);
5104
5276
  const wt = await recreateTaskWorktree(taskBranch, taskBase, priorWt);
@@ -5107,8 +5279,9 @@ export async function runDaemon(repoRoot, opts = {}) {
5107
5279
  // orphan work a consult already verified as landed.
5108
5280
  const { carried: carriedCommits, accounted: accountedCommits } = commitsToCarry.length > 0
5109
5281
  ? await cherryPickCommits(wt, commitsToCarry) : { carried: [], accounted: [] };
5110
- if (recreating) {
5282
+ if (recreating || work.rebuiltFrom) {
5111
5283
  journal.append("worktree-recreation", t.id, {
5284
+ ...(work.rebuiltFrom ? { rebuiltFrom: work.rebuiltFrom } : {}),
5112
5285
  attempted: commitsToCarry, carried: carriedCommits, ...(accountedCommits.length > 0 ? { accounted: accountedCommits } : {}),
5113
5286
  });
5114
5287
  }
@@ -7478,8 +7651,10 @@ export async function runDaemon(repoRoot, opts = {}) {
7478
7651
  summary.tipProof = { kind: "incomplete" };
7479
7652
  }
7480
7653
  else {
7481
- appendIdentityFailure(journal, { started: false, branch, ...opened, cmdHash: commandsHash(commands), capacity: resolvedCapacity() });
7482
- tipClose = { failed: true, identityFailed: true, deferred: [] };
7654
+ const capacity = resolvedCapacity();
7655
+ const tipEnvironment = measureTipEnvironment(repoRoot, capacity);
7656
+ appendIdentityFailure(journal, { started: false, branch, ...opened, cmdHash: commandsHash(commands), capacity, tipEnvironment });
7657
+ tipClose = { failed: true, identityFailed: true, deferred: [], tipEnvironment };
7483
7658
  summary.tipVerify = "failed";
7484
7659
  summary.tipProof = runEndTipProof(journal.read());
7485
7660
  }
@@ -7514,8 +7689,10 @@ export async function runDaemon(repoRoot, opts = {}) {
7514
7689
  if (!tipClose.identityFailed && tipClose.expected !== undefined) {
7515
7690
  const closing = await checkIntegrationIdentity(repoRoot, branch, tipClose.expected);
7516
7691
  if (!closing.ok) {
7692
+ const capacity = resolvedCapacity();
7693
+ const tipEnvironment = tipStarted ? tipClose.tipEnvironment : measureTipEnvironment(repoRoot, capacity);
7517
7694
  appendIdentityFailure(journal, { started: tipStarted, branch, expected: tipClose.expected, leg: closing.leg, state: closing.state,
7518
- details: identityDetails(closing.leg, closing.state, branch, runId, tipClose.expected), cmdHash: commandsHash(commands), capacity: resolvedCapacity() });
7695
+ details: identityDetails(closing.leg, closing.state, branch, runId, tipClose.expected), cmdHash: commandsHash(commands), capacity, tipEnvironment });
7519
7696
  summary.tipVerify = "failed";
7520
7697
  summary.tipProof = runEndTipProof(journal.read());
7521
7698
  }
@@ -7527,9 +7704,11 @@ export async function runDaemon(repoRoot, opts = {}) {
7527
7704
  }
7528
7705
  journal.append("run-end", undefined, { ...summary });
7529
7706
  await reconcile(); // run-end boundary: nothing in flight — full sweep (empty desired set)
7530
- // OBS-28: lingering worktrees starve CLI probes; keepPanes:forever is the debug override.
7707
+ // Queue row 240 (D-2138): every task checkout of this run is removed once its recovery ref is recorded,
7708
+ // unless keepPanes is forever. A dead run's leftovers go at the next daemon start in the same checkout.
7709
+ // Only journaled task checkouts are touched; no directory listing or in-use check exists.
7531
7710
  if (!keepForever)
7532
- await cleanupRunWorktrees(repoRoot, branch, { removeTaskIds: summary.done });
7711
+ await removeJournaledTaskWorktrees(repoRoot, journal, journal, branch);
7533
7712
  // VIS-02: name each blocked subtree by its nearest parked/failed root, e.g. "3 blocked behind P40-02".
7534
7713
  const attribution = [...attributeBlocked(graph).entries()]
7535
7714
  .sort(([a], [b]) => a.localeCompare(b))
package/dist/run/git.d.ts CHANGED
@@ -234,11 +234,16 @@ export declare const sanitizeBranch: (branch: string) => string;
234
234
  export declare const WORKTREES_DIR = "worktrees.noindex";
235
235
  export declare const worktreePath: (repo: string, branch: string) => string;
236
236
  /**
237
- * OBS-28: remove this run's recorded task worktrees; tolerates already-gone paths. 2.8.1 T2: a run keeps no
238
- * integration checkout, and a 2.8.0 run's legacy one is never removed here (it may hold the only view of a merge).
237
+ * Queue row 240 (D-2138): remove only journaled task checkouts, after preserving their recovery tree.
238
+ * There is no in-use check. Branches and recovery refs remain; a failed operation keeps the checkout.
239
239
  */
240
240
  export declare function cleanupRunWorktrees(repo: string, branch: string, opts: {
241
241
  removeTaskIds: string[];
242
+ run: string;
243
+ by: string;
244
+ producer: (task: string) => PreserveProducer;
245
+ append: (event: string, task: string | undefined, data: Record<string, unknown>) => void;
246
+ failed: (data: Record<string, unknown>) => void;
242
247
  }): Promise<void>;
243
248
  export declare function resolveIntegrationBranch(_repo: string, branch: string): Promise<string>;
244
249
  /**
package/dist/run/git.js CHANGED
@@ -817,12 +817,45 @@ const sanitize = sanitizeBranch;
817
817
  export const WORKTREES_DIR = "worktrees.noindex";
818
818
  export const worktreePath = (repo, branch) => join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
819
819
  /**
820
- * OBS-28: remove this run's recorded task worktrees; tolerates already-gone paths. 2.8.1 T2: a run keeps no
821
- * integration checkout, and a 2.8.0 run's legacy one is never removed here (it may hold the only view of a merge).
820
+ * Queue row 240 (D-2138): remove only journaled task checkouts, after preserving their recovery tree.
821
+ * There is no in-use check. Branches and recovery refs remain; a failed operation keeps the checkout.
822
822
  */
823
823
  export async function cleanupRunWorktrees(repo, branch, opts) {
824
- for (const id of opts.removeTaskIds)
825
- await removeWorktree(repo, worktreePath(repo, `${branch}--${id}`));
824
+ for (const task of new Set(opts.removeTaskIds)) {
825
+ const dir = worktreePath(repo, `${branch}--${task}`);
826
+ if (!existsSync(dir))
827
+ continue;
828
+ try {
829
+ const head = await gitHead(dir);
830
+ const producer = opts.producer(task);
831
+ let ref = await preserveWorktree(dir, producer);
832
+ if (!ref) {
833
+ const merged = await shGit(`git merge-base --is-ancestor ${shq(head)} ${shq(`refs/heads/${branch}`)}`, repo);
834
+ if (merged.code !== 0 && merged.code !== 1)
835
+ throw new Error(merged.stderr || merged.stdout || "ancestry comparison failed");
836
+ if (merged.code === 1) {
837
+ ref = `refs/tickmarkr/preserved/${head}`;
838
+ await shGitOk(`git update-ref ${shq(ref)} ${shq(head)}`, repo);
839
+ }
840
+ }
841
+ // Queue row 240 (D-2138 add.4): a disposable preservation index never sees staged-only bytes.
842
+ const staged = await shGit(`git diff-index --cached --name-only --diff-filter=ACMRT -z ${shq(ref ?? head)} --`, dir);
843
+ if (staged.code !== 0 || staged.stdout.length)
844
+ throw new Error(staged.stderr || "staged bytes are absent from the recovery tree");
845
+ if (ref)
846
+ opts.append("worktree-preserved", task, { ref, ...producerFields(producer), by: opts.by });
847
+ if (ref)
848
+ opts.append("worktree-removed", undefined, { task, dir, head, ref, by: opts.by });
849
+ // Queue row 240 (D-2138): unlike rollback, a refused deletion must never fall through to rm -rf.
850
+ await withWorktreeAdmin(() => shGitOk(`git worktree remove --force ${shq(dir)}`, repo));
851
+ }
852
+ catch (error) {
853
+ try {
854
+ opts.failed({ run: opts.run, task, dir, error: error instanceof Error ? error.message : String(error) });
855
+ }
856
+ catch { /* Queue row 240 (D-2138): cleanup never replaces the run's outcome, even with an unwritable journal. */ }
857
+ }
858
+ }
826
859
  }
827
860
  export async function resolveIntegrationBranch(_repo, branch) {
828
861
  return branch;
@@ -2,7 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmdirSync } from "node:fs";
5
- import { join } from "node:path";
5
+ import { dirname, isAbsolute, join } from "node:path";
6
6
  import { z } from "zod";
7
7
  import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
8
8
  import { graphDefinitionHash, stateDirName, taskContentDigest, taskDefinitionFingerprint, tickmarkrDir } from "../graph/graph.js";
@@ -2744,7 +2744,16 @@ function dischargeProblem(row, o, events, cwd, memo) {
2744
2744
  return "discharge range does not bind the obligation";
2745
2745
  let artifact;
2746
2746
  try {
2747
- const bytes = readFileSync(String(d.artifactPath));
2747
+ let artifactPath = String(d.artifactPath);
2748
+ if (!isAbsolute(artifactPath)) {
2749
+ // Queue row 31 / D-2134: resolve within the obligation's own run, with common-root fallback
2750
+ // only when that run is absent locally; legacy absolute paths keep their original meaning.
2751
+ const id = parseRunId(o.runId);
2752
+ const localRun = join(runsDir(cwd), id);
2753
+ const runDir = existsSync(localRun) ? localRun : join(runsDir(dirname(trustedCommonDir(cwd))), id);
2754
+ artifactPath = join(runDir, artifactPath);
2755
+ }
2756
+ const bytes = readFileSync(artifactPath);
2748
2757
  if (createHash("sha256").update(bytes).digest("hex") !== d.artifactSha256)
2749
2758
  return "artifact hash mismatch";
2750
2759
  artifact = JSON.parse(bytes.toString("utf8")) ?? {};
@@ -4,7 +4,7 @@ import type { GateResult } from "../gates/types.js";
4
4
  export type RepairDispositionKind = "fund-repair" | "scope-request" | "authoring" | "none";
5
5
  export interface RepairDisposition {
6
6
  kind: RepairDispositionKind;
7
- /** validated inventory paths only — the sole paths a caller may put in an executable command */
7
+ /** Queue row 235 (D-2134): validated inventory paths or explicitly declared oracle landings. */
8
8
  paths: string[];
9
9
  reason: string;
10
10
  /** which evidence decided: a gate name, "worker" for a refusal, absent for the ordinary disposition */