tickmarkr 2.8.6 → 2.8.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 (40) hide show
  1. package/dist/adapters/grok.js +5 -9
  2. package/dist/adapters/prompt.js +9 -1
  3. package/dist/adapters/types.d.ts +1 -0
  4. package/dist/adapters/types.js +12 -0
  5. package/dist/cli/commands/compile.js +1 -1
  6. package/dist/cli/commands/init.d.ts +10 -0
  7. package/dist/cli/commands/init.js +13 -3
  8. package/dist/cli/commands/report.js +21 -12
  9. package/dist/cli/commands/status.js +14 -2
  10. package/dist/cli/commands/verify.js +29 -5
  11. package/dist/cli/commands/version.d.ts +1 -1
  12. package/dist/cli/commands/version.js +36 -2
  13. package/dist/cli/help.d.ts +1 -0
  14. package/dist/cli/help.js +6 -3
  15. package/dist/compile/collateral.d.ts +3 -1
  16. package/dist/compile/collateral.js +8 -16
  17. package/dist/compile/native.js +7 -2
  18. package/dist/drivers/subprocess.js +9 -4
  19. package/dist/gates/acceptance.d.ts +8 -0
  20. package/dist/gates/acceptance.js +54 -37
  21. package/dist/gates/baseline.js +47 -7
  22. package/dist/gates/run-gates.js +31 -22
  23. package/dist/report/bundle.d.ts +5 -2
  24. package/dist/report/bundle.js +11 -6
  25. package/dist/report/compare.d.ts +9 -0
  26. package/dist/report/compare.js +58 -40
  27. package/dist/report/operator-record.js +4 -3
  28. package/dist/run/daemon.d.ts +7 -3
  29. package/dist/run/daemon.js +157 -58
  30. package/dist/run/git.d.ts +5 -0
  31. package/dist/run/git.js +51 -1
  32. package/dist/run/journal.js +11 -2
  33. package/dist/run/repair-disposition.d.ts +2 -2
  34. package/dist/run/repair-disposition.js +35 -4
  35. package/dist/run/stall.d.ts +2 -0
  36. package/dist/run/stall.js +12 -4
  37. package/package.json +1 -1
  38. package/skills/tickmarkr-auto/SKILL.md +10 -8
  39. package/skills/tickmarkr-loop/SKILL.md +10 -8
  40. package/skills/tickmarkr-overseer/SKILL.md +10 -8
@@ -1,7 +1,7 @@
1
1
  import { HOST_PROBE_SAMPLE_MS, HOST_PROBE_SAMPLES, HostDegradedError, hostDegraded, observeHost } from "./host-health.js";
2
2
  import { runWithInterruptionSink, VITEST_CACHE_ENV, worktreeVitestCache } from "../gates/test-manifest.js";
3
3
  import { COMMAND_LEASE_TOKEN_ENV, commandLeaseEnvironment, CommandLeases, currentCommandLeaseToken, isRunnerCommand, runWithCommandLease, withCommandLease } from "./lease.js";
4
- import { execFileSync, spawn } from "node:child_process";
4
+ import { execFile, execFileSync, spawn } from "node:child_process";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
6
  import { shq } from "../adapters/types.js";
7
7
  import { appendFileSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -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,10 +34,10 @@ 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, 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
- import { classifyRepairDisposition, resolveScopeHints } from "./repair-disposition.js";
40
+ import { classifyRepairDisposition, namedPaths, resolveScopeHints } from "./repair-disposition.js";
41
41
  import { applyScopeAmendments, activeRetryBan, interruptedAttempt, RESUME_HARVEST_SOURCE, resumeHarvestAuthor, approvalAction, APPROVAL_REFUSED, bindingToken, effectiveEvents, foldDecisions, physicalLine, staleApprovals, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, readableExcerpt, receiptOrigin, outstandingReviewFindings, pendingApprovalActions, pendingRechecks, pendingRepairFindings, phaseForGate, priorJudgments, readPriorRunEvidence, recordedTaskFailureKind, reraisedReviewChains, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, standingRulings, structuredFindings, upheldFeedbackByTask } from "./journal.js";
42
42
  import { carriedAuthorVendors, gateReviewerFloor, isDiffCapPark, isGarbageReview, pickReviewer } from "../gates/review.js";
43
43
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
@@ -855,7 +855,11 @@ export function resetDeadChannelFastKillMsForTests() {
855
855
  // monotonic-tracker silence — CONCLUDES the wait. Conclude, never kill: the pane is harvested by
856
856
  // the same tail a window expiry uses, and the carried worktree goes straight to gates. Set at the
857
857
  // fast-kill's window on purpose (the OBS-264 arithmetic is "a ~36m stall + ~15m redo becomes a
858
- // ~5m gate pass"); a worker that is merely thinking still burns CPU and is never concluded here.
858
+ // ~5m gate pass"). Queue row 231 (D-2119): a headless worker waiting on its remote provider can be
859
+ // silent with flat CPU. Print mode also requires a fresh reading of no ESTABLISHED TCP connection
860
+ // in its owned process group; connected or unreadable holds the harvest. Interactive workers keep
861
+ // the triad: an idle pane holds connections too. Residual (D-2125): a truly hung headless worker
862
+ // holding a connection rides the stall window instead.
859
863
  const HARVEST_SILENT_MS = 5 * 60_000;
860
864
  let harvestSilentMs = HARVEST_SILENT_MS;
861
865
  /** Test seam — shrink the harvest silence gate without minute-long sleeps. */
@@ -879,6 +883,32 @@ export const HARVESTED_RESULT_SUMMARY = "harvested: the worktree carries committ
879
883
  export function commandsHash(commands) {
880
884
  return createHash("sha256").update(JSON.stringify(Object.entries(commands).sort())).digest("hex").slice(0, 12);
881
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
+ }
882
912
  /**
883
913
  * T4 (OBS-266): the journal's LAST verification cycle — the (tip, cmdHash) pair the most recent run
884
914
  * of the verify commands spoke for, the gates it got a pass from, and whether anything failed in it.
@@ -900,7 +930,7 @@ function lastVerifyCycle(events) {
900
930
  if (e.event === "tip-verify-start") {
901
931
  const { tip, cmdHash } = e.data;
902
932
  cur = typeof tip === "string" && typeof cmdHash === "string"
903
- ? { 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() }
904
934
  : undefined;
905
935
  afterRunEnd = false;
906
936
  continue;
@@ -923,7 +953,7 @@ function lastVerifyCycle(events) {
923
953
  continue;
924
954
  }
925
955
  if (!cur || afterRunEnd || cur.tip !== tip || cur.cmdHash !== cmdHash) {
926
- 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() };
927
957
  }
928
958
  afterRunEnd = false;
929
959
  // T7: EVERY verdict row's own capacity, not just the start row's. The start row is a statement of
@@ -931,6 +961,7 @@ function lastVerifyCycle(events) {
931
961
  // these rows, so a row whose capacity differs from the session's — or which is malformed — has to
932
962
  // be able to sink the cycle by itself.
933
963
  cur.capacities.push(e.data.capacity);
964
+ cur.tipEnvironments.push(e.data.tipEnvironment);
934
965
  if (e.event === "tip-verify-failed")
935
966
  cur.failed = true;
936
967
  else {
@@ -1200,13 +1231,13 @@ export function identityDetails(leg, state, branch, runId, expected) {
1200
1231
  */
1201
1232
  export function appendIdentityFailure(journal, f) {
1202
1233
  if (!f.started)
1203
- 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 });
1204
1235
  const found = f.state?.kind === "regular" || f.state?.kind === "symbolic" ? f.state.commit : undefined;
1205
1236
  journal.append("tip-verify-failed", undefined, {
1206
1237
  gate: "integration", cmd: "", exitCode: 1, fingerprints: [], details: f.details, ...(f.infra ? { infra: true } : {}),
1207
1238
  integration: { branch: f.branch, expected: f.expected ?? null, found: found ?? null, leg: f.leg,
1208
1239
  ...(f.state?.kind === "symbolic" ? { target: f.state.target } : {}) },
1209
- tip: f.expected, cmdHash: f.cmdHash, capacity: f.capacity,
1240
+ tip: f.expected, cmdHash: f.cmdHash, capacity: f.capacity, tipEnvironment: f.tipEnvironment,
1210
1241
  });
1211
1242
  }
1212
1243
  /** O: I(expected) — or, with no expected value, the ref must be regular and its commit becomes the expected. */
@@ -1267,16 +1298,12 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1267
1298
  opts.signal?.throwIfAborted();
1268
1299
  const cmdHash = commandsHash(commands);
1269
1300
  const { identity } = opts;
1270
- // T7: the capacity this session's verify children WOULD run under — the third thing a carried
1271
- // green must match, beside the tip and the command set. A cached verdict is the one place a green
1272
- // crosses a session boundary with nothing re-run, and a session resumed at a different capacity
1273
- // divides the machine by a different number: that green was established in another world, so it is
1274
- // not carried forward and the commands run again. A pre-T7 cycle records no capacity and keeps
1275
- // exactly the behaviour it has today.
1301
+ // Queue row 24 (D-2134): measure once against the repository whose installed packages the verifier receives.
1276
1302
  const capacity = resolvedCapacity();
1303
+ const tipEnvironment = measureTipEnvironment(identity?.repo ?? intWt, capacity);
1277
1304
  const identityFailure = (f) => {
1278
- appendIdentityFailure(journal, { ...f, branch: identity.branch, cmdHash, capacity });
1279
- return { failed: true, identityFailed: true, deferred: [] };
1305
+ appendIdentityFailure(journal, { ...f, branch: identity.branch, cmdHash, capacity, tipEnvironment });
1306
+ return { failed: true, identityFailed: true, deferred: [], tipEnvironment };
1280
1307
  };
1281
1308
  let tip;
1282
1309
  let clean = true; // with `identity` the battery runs in a fresh checkout and K checks tracked content
@@ -1295,25 +1322,30 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1295
1322
  const gates = Object.keys(commands).filter((g) => g !== "tipTest");
1296
1323
  if (!gates.includes("test") && commands.tipTest)
1297
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.
1298
1328
  const cached = last !== undefined && !last.failed && !last.forgiven && last.tip === tip && last.cmdHash === cmdHash
1299
1329
  && gates.every((g) => last.gates.has(g))
1300
- && 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);
1301
1333
  // A pair can be verified red and then green without either SHA or command hash changing (for
1302
1334
  // example, an external service or ignored fixture recovers). Delimit attempts explicitly so that
1303
1335
  // the earlier red cannot remain latched into the later complete green cycle.
1304
- 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 });
1305
1337
  opts.signal?.throwIfAborted();
1306
1338
  if (clean && cached) {
1307
- journal.append("tip-verify-cached", undefined, { tip, cmdHash, capacity, gates });
1339
+ journal.append("tip-verify-cached", undefined, { tip, cmdHash, capacity, tipEnvironment, gates });
1308
1340
  // The skip must not read as a red. Every surface derives the tip's verdict from this cycle's
1309
1341
  // `tip-verify` events (cockpit derive.ts tipVerificationPassed: a run-end claiming "passed" with
1310
1342
  // ZERO events is fail-closed to FALSE), so a carried-forward green still journals its per-gate
1311
1343
  // pass — `cached: true` keeps it honest about not having re-run the command.
1312
1344
  for (const gate of gates) {
1313
1345
  const cmd = gate === "test" && commands.tipTest ? commands.tipTest : commands[gate];
1314
- 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 });
1315
1347
  }
1316
- return identity ? { failed: false, expected: tip, identityFailed: false, deferred: [] } : false;
1348
+ return identity ? { failed: false, expected: tip, identityFailed: false, deferred: [], tipEnvironment } : false;
1317
1349
  }
1318
1350
  const battery = async (dir) => {
1319
1351
  let tipFailed = false;
@@ -1336,7 +1368,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1336
1368
  };
1337
1369
  if (r.pass) {
1338
1370
  // Q121s: a forgiven pass journals its fingerprints — honest about what was carried, never a silent green.
1339
- 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 });
1340
1372
  }
1341
1373
  else {
1342
1374
  journal.append("tip-verify-failed", undefined, {
@@ -1351,6 +1383,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1351
1383
  tip,
1352
1384
  cmdHash,
1353
1385
  capacity: measuredCapacity,
1386
+ tipEnvironment,
1354
1387
  });
1355
1388
  tipFailed = true;
1356
1389
  }
@@ -1368,7 +1401,7 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1368
1401
  const changed = await checkoutChange(opened.dir, tip, opened.h0);
1369
1402
  if (changed)
1370
1403
  return identityFailure({ started: true, expected: tip, leg: "checkout", details: `verification checkout changed during the battery: ${changed}` });
1371
- return { failed: tipFailed, expected: tip, identityFailed: false, deferred };
1404
+ return { failed: tipFailed, expected: tip, identityFailed: false, deferred, tipEnvironment };
1372
1405
  }
1373
1406
  finally {
1374
1407
  if (checkout)
@@ -3235,11 +3268,6 @@ export async function runDaemon(repoRoot, opts = {}) {
3235
3268
  ];
3236
3269
  return `${identity} — release with ${commands.map((command) => `\`${command}\``).join(" or ")}`;
3237
3270
  };
3238
- // Lexing never starts in the middle of a word. Resolution is separate so missing hints
3239
- // remain visible evidence without becoming executable approval advice.
3240
- const namedPaths = (text) => [...text.matchAll(/(?:^|[\s`'"])((?:[A-Za-z0-9_@.()[\]-]+\/)+[A-Za-z0-9_@.[\]-]+|[A-Za-z0-9_@-]+(?:\.[A-Za-z0-9_-]+)+)(?=$|[\s`'"),:;.!?])/g)]
3241
- .map((match) => match[1].replace(/^\.\//, "").replace(/\.$/, ""))
3242
- .filter((path) => !path.split("/").includes(".."));
3243
3271
  const diffPaths = async (base, wt) => {
3244
3272
  const out = await shGit(`git diff --name-only -z ${shq(base)}..HEAD`, wt);
3245
3273
  return new Set(out.code === 0 ? out.stdout.split("\0").filter(Boolean) : []);
@@ -3381,7 +3409,11 @@ export async function runDaemon(repoRoot, opts = {}) {
3381
3409
  const worker = journal.read().reverse().find((e) => e.taskId === t.id && e.event === "worker-result");
3382
3410
  const disposition = classifyRepairDisposition({
3383
3411
  // OBS-1072 add.2: hints are lexed from the readable excerpt, never from terminal control bytes.
3384
- results: results.map((g) => ({ ...g, details: readableExcerpt(g.details) })), files: t.files, inventory: changed,
3412
+ results: results.map((g) => ({ ...g, details: readableExcerpt(g.details),
3413
+ // Queue row 235 (D-2134): oracle output has the same readable boundary as gate details.
3414
+ ...(Array.isArray(g.meta?.oracles) ? { meta: { ...g.meta, oracles: g.meta.oracles.map((oracle) => oracle && typeof oracle === "object" && typeof oracle.output === "string"
3415
+ ? { ...oracle, output: readableExcerpt(oracle.output) } : oracle) } } : {}),
3416
+ })), files: t.files, inventory: changed,
3385
3417
  refusalSummary: worker?.data.ok === false && typeof worker.data.summary === "string" ? worker.data.summary : undefined,
3386
3418
  });
3387
3419
  for (const diagnostic of disposition.diagnostics) {
@@ -3526,7 +3558,20 @@ export async function runDaemon(repoRoot, opts = {}) {
3526
3558
  const ref = await preserveWorktree(priorWt, producer);
3527
3559
  if (ref)
3528
3560
  journal.append("worktree-preserved", t.id, { ref, ...producerFields(producer) });
3529
- return driver.worktree(repoRoot, taskBranch, taskBase);
3561
+ // Queue row 223 (D-2118): only this typed add race buys retries, with three adds total.
3562
+ const addWorktree = () => {
3563
+ return driver.worktree(repoRoot, taskBranch, taskBase);
3564
+ };
3565
+ for (let attempt = 1;; attempt++) {
3566
+ try {
3567
+ return await addWorktree();
3568
+ }
3569
+ catch (error) {
3570
+ if (!(error instanceof WorktreeAdminRaceError) || attempt >= 3)
3571
+ throw error;
3572
+ journal.append("worktree-add-retry", t.id, { attempt, nextAttempt: attempt + 1, evidence: error.evidence });
3573
+ }
3574
+ }
3530
3575
  };
3531
3576
  // A dead worker with a clean checkout still needs a durable recovery handle: there may be no
3532
3577
  // pane left to identify even the commit it was dispatched from. preserveWorktree deliberately
@@ -3883,8 +3928,8 @@ export async function runDaemon(repoRoot, opts = {}) {
3883
3928
  ...(typeof g.meta.producerAttempt === "number" ? { producerAttempt: g.meta.producerAttempt } : {}),
3884
3929
  } : {}),
3885
3930
  ...((cfg.executionPolicy || repairSelection) && !g.pass ? { disposition: failureDisposition(g) } : {}),
3886
- ...Object.fromEntries(["runnerInfraRerun", "hostStarvedRerun", "recoveryBlocked", "failingFiles", "selectionDecision", "failureEvidence",
3887
- "forgivenFingerprints", "freshFingerprints", "baselineProvenance", "judgment", "adjudication", "judgeDisagreement", "interruptions"]
3931
+ ...Object.fromEntries(["runnerInfraRerun", "judgeIndependent", "hostStarvedRerun", "recoveryBlocked", "failingFiles", "selectionDecision", "failureEvidence",
3932
+ "forgivenFingerprints", "freshFingerprints", "baselineProvenance", "oracles", "judgment", "adjudication", "judgeDisagreement", "interruptions"]
3888
3933
  .filter((key) => g.meta?.[key] !== undefined).map((key) => [key, g.meta[key]])),
3889
3934
  // OBS-540: preserve terminal-vs-retryable infra exactly. normalizeGateOutcome deliberately
3890
3935
  // defaults a legacy infra row to retryable, so dropping an explicit false here reverses the
@@ -5310,17 +5355,19 @@ export async function runDaemon(repoRoot, opts = {}) {
5310
5355
  let cpuFlat;
5311
5356
  let cpuAccountant;
5312
5357
  let cpuGapCount = 0;
5313
- let unmeasurableNoted = false;
5314
- // One line per attempt, whichever way the CPU leg turns out to be unmeasurable. A triad that
5358
+ const unmeasurableNoted = new Set();
5359
+ // Queue row 231 (D-2119): one line per reading per attempt, including the connection leg.
5360
+ // One CPU line per attempt, whichever way that leg turns out to be unmeasurable. A triad that
5315
5361
  // can never conclude is this feature silently ABSENT — on a host whose `ps` the probe cannot
5316
5362
  // read, every stall would ride its whole window out again with nothing saying why. Named once
5317
5363
  // per attempt, not per slice: the condition is structural, and a per-slice line would bury it.
5318
- const noteUnmeasurable = (reason) => {
5319
- if (unmeasurableNoted)
5364
+ const noteUnmeasurable = (reason, reading = "cpu") => {
5365
+ if (unmeasurableNoted.has(reading))
5320
5366
  return;
5321
- unmeasurableNoted = true;
5367
+ unmeasurableNoted.add(reading);
5322
5368
  journal.append("worker-harvest-unmeasurable", t.id, { slot: slot.name, attempt, reason });
5323
5369
  };
5370
+ let harvestHeldNoted = false;
5324
5371
  // The CPU leg needs a marker in the worker's own argv, and every launch path puts this
5325
5372
  // attempt's dispatch script there EXCEPT interactiveSeed: runInteractiveSeed launches the
5326
5373
  // TUI directly, by a command the ADAPTER owns (seed.launch(model)) which tickmarkr cannot
@@ -5382,6 +5429,39 @@ export async function runDaemon(repoRoot, opts = {}) {
5382
5429
  const carried = await commitsAheadOf(taskBase, wt);
5383
5430
  if (carried.length === 0)
5384
5431
  return false; // nothing landed: not this branch's population
5432
+ if (!interactive) {
5433
+ // Queue row 231 (D-2119): query afresh only after the other legs hold. lsof ORs selections
5434
+ // without -a; -g includes connector children in the owned group, not just the CLI root.
5435
+ // No environment inspection. One async PATH lookup, bounded from invocation like the
5436
+ // reap census (15 s); SIGKILL also retires a probe that ignores SIGTERM.
5437
+ const group = readOwnedProcessGroup(groupFile);
5438
+ const connection = group === undefined ? "unreadable" : await new Promise((resolve) => {
5439
+ try {
5440
+ execFile("lsof", ["-nP", "-a", "-g", String(group), "-iTCP", "-sTCP:ESTABLISHED", "-Fp"], { encoding: "utf8", timeout: 15_000, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
5441
+ if (!error && stdout.trim())
5442
+ resolve("connected");
5443
+ else if (error && error.code === 1 && !stdout && !stderr)
5444
+ resolve("none");
5445
+ else
5446
+ resolve("unreadable");
5447
+ });
5448
+ }
5449
+ catch {
5450
+ resolve("unreadable");
5451
+ }
5452
+ });
5453
+ if (connection === "unreadable") {
5454
+ noteUnmeasurable("the worker ESTABLISHED TCP connection reading could not be read", "connection");
5455
+ return false;
5456
+ }
5457
+ if (connection === "connected") {
5458
+ if (!harvestHeldNoted) {
5459
+ harvestHeldNoted = true;
5460
+ journal.append("worker-harvest-held", t.id, { slot: slot.name, attempt, reason: "established-tcp-connection", processGroup: group });
5461
+ }
5462
+ return false;
5463
+ }
5464
+ }
5385
5465
  journal.append("worker-harvest", t.id, {
5386
5466
  slot: slot.name, attempt, commits: carried.length,
5387
5467
  silentMs, cpuMs: leg.cpu.ms, cpuResolutionMs: leg.cpu.resolutionMs,
@@ -5860,13 +5940,17 @@ export async function runDaemon(repoRoot, opts = {}) {
5860
5940
  lastStatus = st;
5861
5941
  journal.append("worker-status", t.id, { slot: slot.name, status: st, attempt });
5862
5942
  }
5863
- if (st === "blocked" || st === "idle") {
5943
+ // Queue row 215 (D-2118 add.1): an unhooked CLI can report unknown at its startup modal.
5944
+ // Bound that status to worker-launch's startup window, including time spent reading.
5945
+ const withinTrustStartup = () => workerLaunchedAt !== undefined
5946
+ && Date.now() - workerLaunchedAt <= workerStartupWindowMs;
5947
+ if (st === "blocked" || st === "idle" || (st === "unknown" && withinTrustStartup())) {
5864
5948
  // T5: once-per-slot auto-answer when the adapter declares a trust dialog and the pane
5865
5949
  // text matches. tickmarkr created the worktree from the operator's own repo — safe by construction.
5866
5950
  if (!trustAnswered && adapter.trustDialog && driver.sendKey) {
5867
5951
  try {
5868
- const paneText = await workerTransport.read(slot, 80);
5869
- if (matchesTrustDialog(paneText, adapter.trustDialog)) {
5952
+ const paneText = await (slot.readScreen ? slot.readScreen() : workerTransport.read(slot, 80));
5953
+ if ((st !== "unknown" || withinTrustStartup()) && matchesTrustDialog(paneText, adapter.trustDialog)) {
5870
5954
  trustAnswered = true;
5871
5955
  trustAnsweredSlots.add(slot.id);
5872
5956
  // v1.25 T1: audit trail for live runs — prove the dialog appeared and was answered.
@@ -6092,9 +6176,9 @@ export async function runDaemon(repoRoot, opts = {}) {
6092
6176
  }
6093
6177
  // OBS-548: the FOURTH leg, and the one the daemon already measured. The three legs above
6094
6178
  // are all channel-side: they say a pane stopped talking. The tree's CPU says whether
6095
- // anything is still WORKING, and the harvest triad forty lines up refuses exactly this
6096
- // conclusion without it — "a worker that is merely thinking still burns CPU and is never
6097
- // concluded here". A live CPU delta is a live worker, by construction; an UNMEASURABLE
6179
+ // anything is still WORKING, and the harvest triad forty lines up refuses this
6180
+ // conclusion without it. Queue row 231 (D-2119): print-mode harvest additionally checks
6181
+ // connections because a remote model turn can have flat CPU. An UNMEASURABLE
6098
6182
  // reading keeps the fail-open contract the probe already states (undefined is never
6099
6183
  // evidence a worker stopped), so on a host whose `ps` cannot be read the fast-kill stands
6100
6184
  // down and the fixed attempt ceiling bounds the hold.
@@ -6281,13 +6365,15 @@ export async function runDaemon(repoRoot, opts = {}) {
6281
6365
  }
6282
6366
  await noteLaunched();
6283
6367
  // OBS-54: headless workers have the same output-inactivity budget as visible panes.
6284
- // v1.76: same monotonic-progress measure as the interactive site; harvest stays raw.
6368
+ // Queue row 233 (D-2130): print progress is the driver's received-byte count, never a row
6369
+ // high-water over the bounded tail; a driver reporting no count keeps the row measure.
6285
6370
  const stallWindowMs = taskTimeoutMinutes * 60_000;
6371
+ const hardDeadline = attemptStart + (attemptHardTimeoutMs ?? stallWindowMs * 4);
6286
6372
  const initialPane = await workerTransport.read(slot, 500);
6287
6373
  startupFailure = startupFailureInWindow(initialPane, workerLaunchedAt);
6288
6374
  let everHadOutput = initialPane.length > 0;
6289
6375
  const stallProgress = new StallProgressTracker();
6290
- stallProgress.observe({ paneText: initialPane, seedSubmitted: true, contextTokens });
6376
+ stallProgress.observe({ paneText: initialPane, receivedBytes: (await workerTransport.transportState?.(slot))?.bytes, seedSubmitted: true, contextTokens });
6291
6377
  let lastProgressAt = Date.now();
6292
6378
  finished = false;
6293
6379
  // T2 review (material): the exit marker proves the PROCESS EXITED, never that the worker
@@ -6297,10 +6383,10 @@ export async function runDaemon(repoRoot, opts = {}) {
6297
6383
  // worker-result-harvested row. The interactive site has always kept them apart (`finished`
6298
6384
  // there is the trailer regex; the exit marker only sets exitCode), and the cause taxonomy
6299
6385
  // already names this shape "clean-exit-no-trailer" — unreachable in print mode until now.
6300
- while (!startupFailure && Date.now() - lastProgressAt < stallWindowMs) {
6386
+ while (!startupFailure && Date.now() - lastProgressAt < stallWindowMs && Date.now() < hardDeadline) {
6301
6387
  fatalStop.signal.throwIfAborted();
6302
6388
  executionSignal()?.throwIfAborted();
6303
- const remaining = stallWindowMs - (Date.now() - lastProgressAt);
6389
+ const remaining = Math.min(stallWindowMs - (Date.now() - lastProgressAt), hardDeadline - Date.now());
6304
6390
  let slice = Math.min(BLOCKED_POLL_MS, Math.max(100, Math.min(stallWindowMs / 2, remaining)));
6305
6391
  const startupLeft = workerLaunchedAt === undefined
6306
6392
  ? 0
@@ -6331,11 +6417,10 @@ export async function runDaemon(repoRoot, opts = {}) {
6331
6417
  break;
6332
6418
  }
6333
6419
  await sampleContext();
6334
- if (stallProgress.observe({ paneText, contextTokens }))
6420
+ if (stallProgress.observe({ paneText, receivedBytes: (await workerTransport.transportState?.(slot))?.bytes, contextTokens }))
6335
6421
  lastProgressAt = Date.now();
6336
- // T2 (OBS-264): the same liveness triad the interactive loop runs. A headless worker that
6337
- // committed and went quiet is finished work too, and before this it rode the entire
6338
- // window out before anything looked at its commits.
6422
+ // Queue row 231 (D-2119): print mode adds a fresh owned-group connection reading to the
6423
+ // silence/commits/CPU triad; a quiet remote model turn can hold a connection with flat CPU.
6339
6424
  // No nudge hold here, deliberately: print mode has no nudge surface at all (no pane to
6340
6425
  // steer, driver.nudge is never consulted on this path), so there is no pending daemon
6341
6426
  // action for the triad to preempt — the asymmetry with the interactive call site above is
@@ -6350,7 +6435,7 @@ export async function runDaemon(repoRoot, opts = {}) {
6350
6435
  // without one is finished:false with a non-null exitCode — the harvest synthesis then owns
6351
6436
  // it when the worktree carries work, and classifyWorkerResultCause names it otherwise.
6352
6437
  finished = new RegExp(trailerPattern(nonce)).test(output);
6353
- timedOut = !processExited && !finished && Date.now() - lastProgressAt >= stallWindowMs;
6438
+ timedOut = !processExited && !finished && (Date.now() - lastProgressAt >= stallWindowMs || Date.now() >= hardDeadline);
6354
6439
  }
6355
6440
  }
6356
6441
  finally {
@@ -7101,7 +7186,8 @@ export async function runDaemon(repoRoot, opts = {}) {
7101
7186
  // Budget spent means the FRESH LADDER owns this failure — including a review-only one, whose
7102
7187
  // same-channel fix retry is exactly the round the budget just declared too expensive to repeat.
7103
7188
  const repairExhausted = repairable && !repair;
7104
- if (repair && !capStep) {
7189
+ const fundedRepair = repair && !capStep;
7190
+ if (fundedRepair) {
7105
7191
  journal.append("repair-attempt", t.id, {
7106
7192
  repair: repairHistory.length + 1, charge: repairsDrawn + 1, of: MAX_REPAIRS,
7107
7193
  gates: failing.map((g) => g.gate),
@@ -7126,8 +7212,13 @@ export async function runDaemon(repoRoot, opts = {}) {
7126
7212
  });
7127
7213
  await driver.notify(`tickmarkr ${runId}: ${t.id} escalation: ${step}`, { tier: "attention" });
7128
7214
  }
7215
+ // Queue row 224(b) (D-2118): pinned tasks park on the first unfunded owned deterministic red
7216
+ // at or after the second, so this climb fires at most once. Unpinned tasks climb on exactly
7217
+ // the second owned deterministic red, even when it funds a repair.
7129
7218
  const climbCause = repairExhausted ? "repair-exhausted"
7130
- : deterministic && ownedGateFailures === 2 ? "gate-fail" : undefined;
7219
+ : deterministic && (t.routingHints?.pin
7220
+ ? ownedGateFailures >= 2 && !fundedRepair
7221
+ : ownedGateFailures === 2) ? "gate-fail" : undefined;
7131
7222
  if (climbCause) {
7132
7223
  const climbed = await climb(climbCause, deterministic ?? failing[0], attempt + 1);
7133
7224
  if (climbed === "parked")
@@ -7240,6 +7331,10 @@ export async function runDaemon(repoRoot, opts = {}) {
7240
7331
  if (fatalStop.signal.aborted)
7241
7332
  return;
7242
7333
  const cleanupEvidence = cleanupErrors.length ? { cleanupErrors } : {};
7334
+ if (err instanceof WorktreeAdminRaceError) {
7335
+ await park(t, err.message, "infra", null, 0, Date.now(), 0, 0, undefined, 0, "fresh", { evidence: err.evidence, ...cleanupEvidence });
7336
+ return;
7337
+ }
7243
7338
  if (err instanceof HostDegradedError) {
7244
7339
  await park(t, err.message, "infra", null, 0, Date.now(), 0, 0, undefined, 0, "fresh", { disposition: "host-degraded", ...cleanupEvidence });
7245
7340
  return;
@@ -7416,8 +7511,10 @@ export async function runDaemon(repoRoot, opts = {}) {
7416
7511
  summary.tipProof = { kind: "incomplete" };
7417
7512
  }
7418
7513
  else {
7419
- appendIdentityFailure(journal, { started: false, branch, ...opened, cmdHash: commandsHash(commands), capacity: resolvedCapacity() });
7420
- tipClose = { failed: true, identityFailed: true, deferred: [] };
7514
+ const capacity = resolvedCapacity();
7515
+ const tipEnvironment = measureTipEnvironment(repoRoot, capacity);
7516
+ appendIdentityFailure(journal, { started: false, branch, ...opened, cmdHash: commandsHash(commands), capacity, tipEnvironment });
7517
+ tipClose = { failed: true, identityFailed: true, deferred: [], tipEnvironment };
7421
7518
  summary.tipVerify = "failed";
7422
7519
  summary.tipProof = runEndTipProof(journal.read());
7423
7520
  }
@@ -7452,8 +7549,10 @@ export async function runDaemon(repoRoot, opts = {}) {
7452
7549
  if (!tipClose.identityFailed && tipClose.expected !== undefined) {
7453
7550
  const closing = await checkIntegrationIdentity(repoRoot, branch, tipClose.expected);
7454
7551
  if (!closing.ok) {
7552
+ const capacity = resolvedCapacity();
7553
+ const tipEnvironment = tipStarted ? tipClose.tipEnvironment : measureTipEnvironment(repoRoot, capacity);
7455
7554
  appendIdentityFailure(journal, { started: tipStarted, branch, expected: tipClose.expected, leg: closing.leg, state: closing.state,
7456
- details: identityDetails(closing.leg, closing.state, branch, runId, tipClose.expected), cmdHash: commandsHash(commands), capacity: resolvedCapacity() });
7555
+ details: identityDetails(closing.leg, closing.state, branch, runId, tipClose.expected), cmdHash: commandsHash(commands), capacity, tipEnvironment });
7457
7556
  summary.tipVerify = "failed";
7458
7557
  summary.tipProof = runEndTipProof(journal.read());
7459
7558
  }
package/dist/run/git.d.ts CHANGED
@@ -199,6 +199,11 @@ export declare function shGitOk(cmd: string, cwd: string, own?: OwnGitOptions):
199
199
  export declare function gitSync(dir: string, args: readonly string[], { env }?: {
200
200
  env?: NodeJS.ProcessEnv;
201
201
  }): SpawnSyncReturns<string>;
202
+ /** Queue row 223 (D-2118): git could not read a sibling's partially written admin file during add. */
203
+ export declare class WorktreeAdminRaceError extends Error {
204
+ readonly evidence: string;
205
+ constructor(evidence: string);
206
+ }
202
207
  /**
203
208
  * v2.6.8 T2: the DETACHED worktree capability, declared by calling it (the daemon's baseline recapture and standalone
204
209
  * verify's base worktree). Under the ref-store pin git stubs a detached add's new HEAD as `ref: refs/heads/.invalid`
package/dist/run/git.js CHANGED
@@ -621,6 +621,41 @@ export function gitSync(dir, args, { env = process.env } = {}) {
621
621
  pin?.release();
622
622
  }
623
623
  }
624
+ // Queue row 223 (D-2118): one process-wide administration queue; recreation and rollback retain
625
+ // their caller's hold, including trust rechecks and branch restoration, without queuing behind it.
626
+ let worktreeAdminTail = Promise.resolve();
627
+ const worktreeAdminHold = new AsyncLocalStorage();
628
+ async function withWorktreeAdmin(operation) {
629
+ if (worktreeAdminHold.getStore())
630
+ return operation();
631
+ const prior = worktreeAdminTail;
632
+ let release;
633
+ worktreeAdminTail = new Promise((done) => { release = done; });
634
+ await prior;
635
+ try {
636
+ return await worktreeAdminHold.run(true, operation);
637
+ }
638
+ finally {
639
+ release();
640
+ }
641
+ }
642
+ /** Queue row 223 (D-2118): git could not read a sibling's partially written admin file during add. */
643
+ export class WorktreeAdminRaceError extends Error {
644
+ evidence;
645
+ constructor(evidence) {
646
+ super(`worktree add encountered a sibling admin read race (queue row 223, D-2118): ${evidence}`);
647
+ this.evidence = evidence;
648
+ this.name = "WorktreeAdminRaceError";
649
+ }
650
+ }
651
+ const siblingAdminReadFailed = (stderr) => {
652
+ for (const line of stderr.split("\n")) {
653
+ const read = /^(?:fatal: |error: )?failed to read (?:'([^'\r\n]+)'|"([^"\r\n]+)"|([^:\r\n]+))(?::|$)/.exec(line);
654
+ if (read && /(?:^|\/)worktrees\/[^/\r\n]+\/(?:commondir|gitdir|HEAD)$/.test(read[1] ?? read[2] ?? read[3]))
655
+ return true;
656
+ }
657
+ return false;
658
+ };
624
659
  /**
625
660
  * v2.6.8 T2: the DETACHED worktree capability, declared by calling it (the daemon's baseline recapture and standalone
626
661
  * verify's base worktree). Under the ref-store pin git stubs a detached add's new HEAD as `ref: refs/heads/.invalid`
@@ -633,6 +668,9 @@ export function gitSync(dir, args, { env = process.env } = {}) {
633
668
  * back through the trusted common directory derived here once (dropWorktree), even while that commondir stays hostile.
634
669
  */
635
670
  export async function addDetachedWorktree(cwd, dir, commitish, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS, { logHead = false } = {}) {
671
+ return withWorktreeAdmin(() => addDetachedWorktreeHeld(cwd, dir, commitish, timeoutMs, logHead));
672
+ }
673
+ async function addDetachedWorktreeHeld(cwd, dir, commitish, timeoutMs, logHead) {
636
674
  // 2.8.1 T1 (ANL-023 §7.1): `logHead` writes the new checkout's HEAD reflog whatever the repository's
637
675
  // core.logAllRefUpdates says, so a later HEAD move by any command is appended to an established witness.
638
676
  const git = logHead ? "git -c core.logAllRefUpdates=always" : "git";
@@ -682,6 +720,9 @@ function rollbackFailed(error, failure) {
682
720
  * step bounded by `timeoutMs`; every step is tried, and any that failed is thrown, never swallowed.
683
721
  */
684
722
  async function dropWorktree(store, { dir, branch }, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
723
+ return withWorktreeAdmin(() => dropWorktreeHeld(store, { dir, branch }, timeoutMs));
724
+ }
725
+ async function dropWorktreeHeld(store, { dir, branch }, timeoutMs) {
685
726
  const failed = await withoutExecutionBudget(async () => {
686
727
  const run = async (cmd) => {
687
728
  try {
@@ -859,6 +900,9 @@ export function checkoutIncarnation(worktree) {
859
900
  }
860
901
  }
861
902
  export async function createWorktree(repo, branch, baseRef) {
903
+ return withWorktreeAdmin(() => createWorktreeHeld(repo, branch, baseRef));
904
+ }
905
+ async function createWorktreeHeld(repo, branch, baseRef) {
862
906
  branch = await resolveTaskBranch(repo, branch);
863
907
  const dir = join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
864
908
  if (existsSync(dir))
@@ -866,7 +910,13 @@ export async function createWorktree(repo, branch, baseRef) {
866
910
  // the worktree capability: a branch add, whose symbolic HEAD keeps the ref-store pin (see OwnGitCapability); a raced
867
911
  // add is rolled back, its branch restored to the value read here or deleted when it is new
868
912
  const prior = (await shGit(`git rev-parse --verify -q ${shq(`refs/heads/${branch}`)}`, repo)).stdout.trim() || undefined;
869
- await shGitOk(`git worktree add -B ${shq(branch)} ${shq(dir)} ${shq(baseRef)}`, repo, { createsWorktree: "branch", rollback: { dir, branch: { name: branch, prior } } });
913
+ const cmd = `git worktree add -B ${shq(branch)} ${shq(dir)} ${shq(baseRef)}`;
914
+ const added = await shGit(cmd, repo, DEFAULT_SHELL_TIMEOUT_MS, { createsWorktree: "branch", rollback: { dir, branch: { name: branch, prior } } });
915
+ if (added.code !== 0) {
916
+ if (siblingAdminReadFailed(added.stderr))
917
+ throw new WorktreeAdminRaceError(added.stderr.trim());
918
+ throw new Error(`command failed (${added.code}): ${cmd}\n${added.stderr || added.stdout}`);
919
+ }
870
920
  linkNodeModules(repo, dir);
871
921
  return dir;
872
922
  }
@@ -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")) ?? {};