tickmarkr 2.8.0 → 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 { isInfraResult } from "../gates/cache.js";
26
+ import { 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,14 +34,14 @@ 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, deriveForkCap, FORK_CAP_ENV, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER, preserveWorktree, producerFields, 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, 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";
38
38
  import { MASK } from "./redact.js";
39
39
  import { runInteractiveSeed } from "./interactive-seed.js";
40
40
  import { classifyRepairDisposition, 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";
44
- import { ensureIntegration, integrationBranch, integrationHead, mergeTask, reusedTipEvidence, verifyIntegrationTip } from "./merge.js";
44
+ import { checkIntegrationIdentity, ensureIntegration, integrationBranch, integrationRepairText, mergeTask, readIntegrationRef, reusedTipEvidence, verifyIntegrationTip } from "./merge.js";
45
45
  import { channelExclusion, climbChannel, inTaskPool, marginalCostRank, nextChannel, poolExhaustion, route, taskPool } from "../route/router.js";
46
46
  import { desiredPanes } from "./reconcile.js";
47
47
  import { readTierLiveness, readWatchBoard, supervisionPresencePath } from "./supervision.js";
@@ -1000,7 +1000,7 @@ export function formatTipProof(p) {
1000
1000
  }
1001
1001
  // Isolate the battery so cancellation can stop its control flow as well as its shell children.
1002
1002
  // The child uses the same verifier and capacity; only the daemon writes lifecycle verdict rows.
1003
- async function cancellableTipBattery(intWt, commands, runDir, baseline, signal, evidence = {}) {
1003
+ async function cancellableTipBattery(intWt, commands, runDir, baseline, signal, evidence = {}, deferVerdicts = false) {
1004
1004
  signal.throwIfAborted();
1005
1005
  const extension = import.meta.url.endsWith(".ts") ? "ts" : "js";
1006
1006
  const script = `
@@ -1049,9 +1049,9 @@ async function cancellableTipBattery(intWt, commands, runDir, baseline, signal,
1049
1049
  finally { process.send({ type: 'lease-release', id }); }
1050
1050
  };
1051
1051
  let input = ''; for await (const chunk of process.stdin) input += chunk;
1052
- const { intWt, commands, runDir, baseline, capacity, evidence } = JSON.parse(input);
1052
+ const { intWt, commands, runDir, baseline, capacity, evidence, deferVerdicts } = JSON.parse(input);
1053
1053
  try {
1054
- const result = await runWithVerificationBudget(capacity, () => runWithCommandLease(lease, () => verifyIntegrationTip(intWt, commands, runDir, baseline, evidence)));
1054
+ const result = await runWithVerificationBudget(capacity, () => runWithCommandLease(lease, () => verifyIntegrationTip(intWt, commands, runDir, baseline, evidence, { deferVerdicts })));
1055
1055
  if (!cancelled) process.stdout.write(JSON.stringify(result.map(row => ({ ...row, capacity: [...commandCapacities].reverse().find(([command]) => command === row.cmd || command.startsWith(row.cmd + " "))?.[1] ?? capacity }))));
1056
1056
  } catch (error) { if (!cancelled) throw error; }
1057
1057
  finally { process.disconnect(); }
@@ -1102,7 +1102,7 @@ async function cancellableTipBattery(intWt, commands, runDir, baseline, signal,
1102
1102
  });
1103
1103
  signal.addEventListener("abort", cancel, { once: true });
1104
1104
  child.stdin.on("error", () => { });
1105
- child.stdin.end(JSON.stringify({ intWt, commands, runDir, baseline, capacity: resolvedCapacity(), evidence }));
1105
+ child.stdin.end(JSON.stringify({ intWt, commands, runDir, baseline, capacity: resolvedCapacity(), evidence, deferVerdicts }));
1106
1106
  try {
1107
1107
  if (signal.aborted)
1108
1108
  cancel();
@@ -1114,21 +1114,117 @@ async function cancellableTipBattery(intWt, commands, runDir, baseline, signal,
1114
1114
  await Promise.allSettled(leases);
1115
1115
  }
1116
1116
  }
1117
+ const short = (commit) => commit ? commit.slice(0, 12) : "none";
1118
+ /** In 7.1 alone a failed opening check has no expected value of its own: the newest journaled identity stands in. */
1119
+ function journaledTip(events) {
1120
+ for (const e of [...events].reverse()) {
1121
+ if (e.event === "merge" && typeof e.data.commit === "string")
1122
+ return e.data.commit;
1123
+ if ((e.event === "run-start" || e.event === "run-resume") && typeof e.data.baseRef === "string")
1124
+ return e.data.baseRef;
1125
+ }
1126
+ return undefined;
1127
+ }
1128
+ /**
1129
+ * 2.8.1 T3 (ANL-023 §7.3): anchor A of a run whose run-start carries `integrationAnchor: "journal"` — the newest merge
1130
+ * row's commit (that row must carry a `from`), else the run-start base; any of them not an object id is unreadable.
1131
+ * No marker: undefined (legacy-ref). Resume and approve both read it.
1132
+ */
1133
+ export function integrationAnchor(events) {
1134
+ const start = events.find((e) => e.event === "run-start");
1135
+ if (start?.data.integrationAnchor !== "journal")
1136
+ return undefined;
1137
+ const oid = (v) => typeof v === "string" && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(v);
1138
+ const show = (v) => v === undefined ? "absent" : JSON.stringify(v);
1139
+ const merge = [...events].reverse().find((e) => e.event === "merge");
1140
+ if (!merge)
1141
+ return oid(start.data.baseRef) ? { anchor: start.data.baseRef } : { unreadable: `run-start baseRef is ${show(start.data.baseRef)}, not an object id` };
1142
+ if (!oid(merge.data.commit))
1143
+ return { unreadable: `the newest merge row's commit is ${show(merge.data.commit)}, not an object id` };
1144
+ if (!oid(merge.data.from))
1145
+ return { unreadable: `the newest merge row's from is ${show(merge.data.from)}, not an object id` };
1146
+ return { anchor: merge.data.commit };
1147
+ }
1148
+ /** The ref legs' details: the leg, both commits, the symbolic target and the repair text. */
1149
+ export function identityDetails(leg, state, branch, runId, expected) {
1150
+ const found = state.kind === "regular" || state.kind === "symbolic" ? state.commit : undefined;
1151
+ const target = state.kind === "symbolic" ? ` (symbolic to ${state.target})` : "";
1152
+ return `integration identity moved (${leg}): expected ${short(expected)}, found ${short(found)}${target} — repair: `
1153
+ + integrationRepairText(state, branch, runId, expected ?? "<expected commit>");
1154
+ }
1117
1155
  /**
1118
- * OBS-34's strict tip verify, but it stops re-paying for an unmoved tip (~334m corpus-wide; 69.5m in
1119
- * one park-heavy run whose 18 resume cycles merged nothing new). The verify journals the SHA it
1120
- * verified and the hash of the command set, so a later run-end can recognize the same verified state:
1121
- * head equals the LAST green verified SHA, commands unchanged, tree clean → journal
1122
- * `tip-verify-cached` and skip. ANY doubt — moved head, changed commands, dirty tree, a gate missing
1123
- * from that cycle's green set, a failure recorded in it, a cycle older than the last one — runs the
1124
- * full verify. The tip-verify-before-green law is untouched: a cached green is a verified green OF
1125
- * THAT EXACT COMMIT, established by the most recent real run of the same commands.
1126
- * Returns whether the tip is failing.
1156
+ * The one closed outcome row for every identity leg: an integration `tip-verify-failed` (preceded by a start row
1157
+ * with no gates when this close appended none), which runEndTipProof and every consumer already read as failure.
1127
1158
  */
1159
+ export function appendIdentityFailure(journal, f) {
1160
+ if (!f.started)
1161
+ journal.append("tip-verify-start", undefined, { tip: f.expected, cmdHash: f.cmdHash, capacity: f.capacity, gates: [], cached: false });
1162
+ const found = f.state?.kind === "regular" || f.state?.kind === "symbolic" ? f.state.commit : undefined;
1163
+ journal.append("tip-verify-failed", undefined, {
1164
+ gate: "integration", cmd: "", exitCode: 1, fingerprints: [], details: f.details, ...(f.infra ? { infra: true } : {}),
1165
+ integration: { branch: f.branch, expected: f.expected ?? null, found: found ?? null, leg: f.leg,
1166
+ ...(f.state?.kind === "symbolic" ? { target: f.state.target } : {}) },
1167
+ tip: f.expected, cmdHash: f.cmdHash, capacity: f.capacity,
1168
+ });
1169
+ }
1170
+ /** O: I(expected) — or, with no expected value, the ref must be regular and its commit becomes the expected. */
1171
+ export async function openingIdentity(identity, journal) {
1172
+ const { repo, branch } = identity;
1173
+ const fail = (leg, state) => {
1174
+ const expected = identity.expected ?? journaledTip(journal.read());
1175
+ return { ok: false, leg, state, expected, details: identityDetails(leg, state, branch, journal.runId, expected) };
1176
+ };
1177
+ // One R(ref) read; with `expected` (2.8.1 T2: the daemon's E) it is I(expected) — regular at E, else the leg.
1178
+ const state = await readIntegrationRef(repo, branch);
1179
+ if (identity.expected === undefined)
1180
+ return state.kind === "regular" ? { ok: true, expected: state.commit } : fail(state.kind, state);
1181
+ if (state.kind === "regular" && state.commit === identity.expected)
1182
+ return { ok: true, expected: identity.expected };
1183
+ return fail(state.kind === "regular" ? "value" : state.kind, state);
1184
+ }
1185
+ /** C: a fresh detached checkout of `expected` whose HEAD reflog is established and validated before the battery. */
1186
+ async function openVerificationCheckout(repo, expected, created) {
1187
+ const parent = join(tickmarkrDir(repo), WORKTREES_DIR);
1188
+ mkdirSync(parent, { recursive: true });
1189
+ // A killed daemon never reaches the caller's finally; sweep checkouts whose owner is dead.
1190
+ for (const entry of readdirSync(parent)) {
1191
+ const owner = /^tip-verify-(\d+)-\w{6}$/.exec(entry)?.[1];
1192
+ if (owner && !isPidLive(Number(owner)))
1193
+ await removeWorktree(repo, join(parent, entry));
1194
+ }
1195
+ const dir = mkdtempSync(join(parent, `tip-verify-${process.pid}-`));
1196
+ created(dir);
1197
+ const added = await addDetachedWorktree(repo, dir, expected, undefined, { logHead: true });
1198
+ if (added.code !== 0)
1199
+ return { ok: false, leg: "setup", infra: true, details: `verification checkout setup failed (add: ${(added.stderr || added.stdout).trim()})` };
1200
+ if (!linkNodeModules(repo, dir, { force: true }))
1201
+ return { ok: false, leg: "setup", infra: true, details: "verification checkout setup failed (node_modules link returned false)" };
1202
+ const h0 = await reflogWitness(dir, "HEAD");
1203
+ if (h0.code !== 0 || h0.lines[0] !== expected) {
1204
+ const why = h0.code !== 0 ? `exit ${h0.code}` : h0.lines.length === 0 ? "no reflog" : `newest is ${short(h0.lines[0])}`;
1205
+ return { ok: false, leg: "witness", details: `verification checkout witness unavailable: ${why}` };
1206
+ }
1207
+ return { ok: true, dir, h0: h0.lines };
1208
+ }
1209
+ /** K: C's HEAD still detached at `expected` under the same reflog, and its index and tracked files equal expected's tree. */
1210
+ async function checkoutChange(dir, expected, h0) {
1211
+ if ((await shGit("git symbolic-ref -q HEAD", dir)).code !== 1)
1212
+ return "HEAD symbolic";
1213
+ const head = await shGit("git rev-parse HEAD", dir);
1214
+ if (head.code !== 0 || head.stdout.trim() !== expected)
1215
+ return `HEAD moved to ${short(head.code === 0 ? head.stdout.trim() : undefined)}`;
1216
+ const witness = await reflogWitness(dir, "HEAD");
1217
+ if (witness.code !== 0 || witness.lines.join("\n") !== h0.join("\n"))
1218
+ return "HEAD reflog changed";
1219
+ const status = await shGit("git status --porcelain=v1 --untracked-files=no --ignore-submodules=none", dir);
1220
+ if (status.code !== 0 || status.stdout.trim() !== "")
1221
+ return `tracked content: ${(status.stdout || status.stderr).trimEnd() || `exit ${status.code}`}`;
1222
+ return undefined;
1223
+ }
1128
1224
  export async function verifyIntegrationTipCached(intWt, commands, journal, opts = {}) {
1129
1225
  opts.signal?.throwIfAborted();
1130
1226
  const cmdHash = commandsHash(commands);
1131
- const tip = await gitHead(intWt);
1227
+ const { identity } = opts;
1132
1228
  // T7: the capacity this session's verify children WOULD run under — the third thing a carried
1133
1229
  // green must match, beside the tip and the command set. A cached verdict is the one place a green
1134
1230
  // crosses a session boundary with nothing re-run, and a session resumed at a different capacity
@@ -1136,8 +1232,23 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1136
1232
  // not carried forward and the commands run again. A pre-T7 cycle records no capacity and keeps
1137
1233
  // exactly the behaviour it has today.
1138
1234
  const capacity = resolvedCapacity();
1139
- const porcelain = await shGit("git status --porcelain", intWt);
1140
- const clean = porcelain.code === 0 && porcelain.stdout.trim() === "";
1235
+ const identityFailure = (f) => {
1236
+ appendIdentityFailure(journal, { ...f, branch: identity.branch, cmdHash, capacity });
1237
+ return { failed: true, identityFailed: true, deferred: [] };
1238
+ };
1239
+ let tip;
1240
+ let clean = true; // with `identity` the battery runs in a fresh checkout and K checks tracked content
1241
+ if (identity) {
1242
+ const opened = await openingIdentity(identity, journal);
1243
+ if (!opened.ok)
1244
+ return identityFailure({ started: false, ...opened });
1245
+ tip = opened.expected;
1246
+ }
1247
+ else {
1248
+ tip = await gitHead(intWt);
1249
+ const porcelain = await shGit("git status --porcelain", intWt);
1250
+ clean = porcelain.code === 0 && porcelain.stdout.trim() === "";
1251
+ }
1141
1252
  const last = lastVerifyCycle(journal.read());
1142
1253
  const gates = Object.keys(commands).filter((g) => g !== "tipTest");
1143
1254
  if (!gates.includes("test") && commands.tipTest)
@@ -1160,44 +1271,67 @@ export async function verifyIntegrationTipCached(intWt, commands, journal, opts
1160
1271
  const cmd = gate === "test" && commands.tipTest ? commands.tipTest : commands[gate];
1161
1272
  journal.append("tip-verify", undefined, { ...reusedTipEvidence(last.evidenceByGate.get(gate) ?? {}), gate, cmd, pass: true, exitCode: 0, cached: true, tip, cmdHash, capacity });
1162
1273
  }
1163
- return false;
1274
+ return identity ? { failed: false, expected: tip, identityFailed: false, deferred: [] } : false;
1164
1275
  }
1165
- let tipFailed = false;
1166
- for (const r of await runWithInterruptionSink(journalInterruption(journal, undefined), () => opts.signal
1167
- ? cancellableTipBattery(intWt, commands, journal.dir, opts.baseline, opts.signal, opts.evidence)
1168
- : verifyIntegrationTip(intWt, commands, journal.dir, opts.baseline, opts.evidence))) {
1169
- const measuredCapacity = r.capacity ?? capacity;
1170
- const evidence = r.reused ? reusedTipEvidence(r) : {
1171
- ...(r.originRunRoot ? { originRunRoot: r.originRunRoot } : {}),
1172
- ...(r.cause ? { cause: r.cause } : {}),
1173
- ...(r.evidenceReceipt ? { evidenceReceipt: r.evidenceReceipt } : {}),
1174
- ...(r.evidenceReceipts ? { evidenceReceipts: r.evidenceReceipts } : {}),
1175
- ...(r.evidenceAbsence ? { evidenceAbsence: r.evidenceAbsence } : {}),
1176
- ...Object.fromEntries(["nonce", "stdoutPath", "stderrPath"]
1177
- .filter(key => r[key] !== undefined).map(key => [key, r[key]])),
1178
- };
1179
- if (r.pass) {
1180
- // Q121s: a forgiven pass journals its fingerprints — honest about what was carried, never a silent green.
1181
- 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 });
1182
- }
1183
- else {
1184
- journal.append("tip-verify-failed", undefined, {
1185
- ...evidence,
1186
- gate: r.gate,
1187
- cmd: r.cmd,
1188
- exitCode: r.exitCode,
1189
- fingerprints: r.fingerprints,
1190
- artifact: r.artifact,
1191
- ...(r.details ? { details: r.details } : {}),
1192
- lastMergedTask: opts.lastMergedTask,
1193
- tip,
1194
- cmdHash,
1195
- capacity: measuredCapacity,
1196
- });
1197
- tipFailed = true;
1276
+ const battery = async (dir) => {
1277
+ let tipFailed = false;
1278
+ const deferred = [];
1279
+ const deferVerdicts = identity !== undefined;
1280
+ for (const r of await runWithInterruptionSink(journalInterruption(journal, undefined), () => opts.signal
1281
+ ? cancellableTipBattery(dir, commands, journal.dir, opts.baseline, opts.signal, opts.evidence, deferVerdicts)
1282
+ : verifyIntegrationTip(dir, commands, journal.dir, opts.baseline, opts.evidence, { deferVerdicts }))) {
1283
+ if (r.deferredVerdict)
1284
+ deferred.push(r.deferredVerdict);
1285
+ const measuredCapacity = r.capacity ?? capacity;
1286
+ const evidence = r.reused ? reusedTipEvidence(r) : {
1287
+ ...(r.originRunRoot ? { originRunRoot: r.originRunRoot } : {}),
1288
+ ...(r.cause ? { cause: r.cause } : {}),
1289
+ ...(r.evidenceReceipt ? { evidenceReceipt: r.evidenceReceipt } : {}),
1290
+ ...(r.evidenceReceipts ? { evidenceReceipts: r.evidenceReceipts } : {}),
1291
+ ...(r.evidenceAbsence ? { evidenceAbsence: r.evidenceAbsence } : {}),
1292
+ ...Object.fromEntries(["nonce", "stdoutPath", "stderrPath"]
1293
+ .filter(key => r[key] !== undefined).map(key => [key, r[key]])),
1294
+ };
1295
+ if (r.pass) {
1296
+ // Q121s: a forgiven pass journals its fingerprints — honest about what was carried, never a silent green.
1297
+ 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 });
1298
+ }
1299
+ else {
1300
+ journal.append("tip-verify-failed", undefined, {
1301
+ ...evidence,
1302
+ gate: r.gate,
1303
+ cmd: r.cmd,
1304
+ exitCode: r.exitCode,
1305
+ fingerprints: r.fingerprints,
1306
+ artifact: r.artifact,
1307
+ ...(r.details ? { details: r.details } : {}),
1308
+ lastMergedTask: opts.lastMergedTask,
1309
+ tip,
1310
+ cmdHash,
1311
+ capacity: measuredCapacity,
1312
+ });
1313
+ tipFailed = true;
1314
+ }
1198
1315
  }
1316
+ return { tipFailed, deferred };
1317
+ };
1318
+ if (!identity)
1319
+ return (await battery(intWt)).tipFailed;
1320
+ let checkout;
1321
+ try {
1322
+ const opened = await openVerificationCheckout(identity.repo, tip, (dir) => { checkout = dir; });
1323
+ if (!opened.ok)
1324
+ return identityFailure({ started: true, expected: tip, ...opened });
1325
+ const { tipFailed, deferred } = await battery(opened.dir);
1326
+ const changed = await checkoutChange(opened.dir, tip, opened.h0);
1327
+ if (changed)
1328
+ return identityFailure({ started: true, expected: tip, leg: "checkout", details: `verification checkout changed during the battery: ${changed}` });
1329
+ return { failed: tipFailed, expected: tip, identityFailed: false, deferred };
1330
+ }
1331
+ finally {
1332
+ if (checkout)
1333
+ await removeWorktree(identity.repo, checkout);
1199
1334
  }
1200
- return tipFailed;
1201
1335
  }
1202
1336
  /** OBS-953: a detected wall-over-monotonic discontinuity journals `host-suspend` (its offset was
1203
1337
  * subtracted from active test time); an overdue poll whose clocks advanced together journals
@@ -2288,6 +2422,52 @@ export async function runDaemon(repoRoot, opts = {}) {
2288
2422
  branch = recordedBranch
2289
2423
  ? branchEvent.event === "merge" ? recordedBranch.slice(0, recordedBranch.lastIndexOf("--")) : recordedBranch
2290
2424
  : integrationBranch(cfg, runId);
2425
+ // 2.8.1 T3 (ANL-023 §7.3): the resume anchor. ONLY the run-start integration anchor marker
2426
+ // (`integrationAnchor: "journal"`) selects the journal anchor A; the shape of merge rows never chooses. A marked
2427
+ // resume refuses any ref not regular at the newest merge row's commit, or at the run-start base when no merge exists
2428
+ // (moved, symbolic, deleted, reset to an older published tip, unreadable), and any unreadable anchor: unlike the
2429
+ // superseded resume, it never adopts whatever the ref holds. An unmarked run resumes legacy-ref: E = the regular
2430
+ // commit the ref holds; a missing ref whose journal holds no merge row is created create-only at the run-start base
2431
+ // where a fresh run creates it (below).
2432
+ // Every refusal throws HERE, before this resume journals anything (no exit-cause, lock-reclaimed or approval-refused
2433
+ // row), and leaves the ref as found — after the superseded check and the run-start read, which refuse first.
2434
+ let resumedTip;
2435
+ let resumeAnchor;
2436
+ if (opts.resume) {
2437
+ // v1.53 T5: a superseded run is dead — resuming it beside its successor is the exact
2438
+ // two-concurrent-runs hazard supersession exists to prevent. Fail closed, naming the successor.
2439
+ const superseded = [...journal.read()].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
2440
+ if (superseded)
2441
+ throw new Error(`refusing to resume ${runId}: superseded by ${superseded.data.by}`);
2442
+ if (!journal.read().some((e) => e.event === "run-start"))
2443
+ throw new Error(`journal for ${runId} has no run-start event`);
2444
+ const refuse = (why) => new Error(`refusing to resume ${runId}: ${why}`);
2445
+ const anchor = integrationAnchor(journal.read());
2446
+ if (anchor && "unreadable" in anchor)
2447
+ throw refuse(`its journal names no readable published tip (${anchor.unreadable})`);
2448
+ const found = await readIntegrationRef(repoRoot, branch);
2449
+ if (anchor) {
2450
+ if (found.kind !== "regular" || found.commit !== anchor.anchor) {
2451
+ const at = found.kind === "regular" || found.kind === "symbolic" ? found.commit : undefined;
2452
+ throw refuse(`integration branch ${branch} does not hold its last published tip (${found.kind === "regular" ? "value" : found.kind}): `
2453
+ + `expected ${short(anchor.anchor)}, found ${short(at)}${found.kind === "symbolic" ? ` (symbolic to ${found.target})` : ""}`
2454
+ + ` — repair: ${integrationRepairText(found, branch, runId, anchor.anchor)}, then resume`);
2455
+ }
2456
+ resumedTip = anchor.anchor;
2457
+ resumeAnchor = "journal";
2458
+ }
2459
+ else if (found.kind === "regular" || (found.kind === "missing" && !journal.read().some((e) => e.event === "merge"))) {
2460
+ resumedTip = found.kind === "regular" ? found.commit : undefined;
2461
+ resumeAnchor = "legacy-ref";
2462
+ }
2463
+ else {
2464
+ // A symbolic ref resolving to F is made regular at its own value; anything else has no trusted tip to print.
2465
+ const repair = found.kind === "symbolic" && found.commit ? integrationRepairText(found, branch, runId, found.commit) : "restore the branch to the tip you trust";
2466
+ throw refuse(`integration branch ${branch} is ${found.kind === "missing" ? "missing while the journal records merges"
2467
+ : found.kind === "symbolic" ? `symbolic to ${found.target}${found.commit ? ` at ${short(found.commit)}` : ", which names no commit"}`
2468
+ : `unreadable: ${found.read}`} (${found.kind}) — repair: ${repair}, then resume`);
2469
+ }
2470
+ }
2291
2471
  if (lock.reclaimed) {
2292
2472
  // T15: SIGKILL/the kernel/power loss cannot ask the dead daemon to append a cause. The next live
2293
2473
  // observer owns that classification: a reclaimed durable lock plus an open journal lifecycle is
@@ -2672,14 +2852,8 @@ export async function runDaemon(repoRoot, opts = {}) {
2672
2852
  modeSource: rm.source,
2673
2853
  };
2674
2854
  if (opts.resume) {
2675
- // v1.53 T5: a superseded run is dead — resuming it beside its successor is the exact
2676
- // two-concurrent-runs hazard supersession exists to prevent. Fail closed, naming the successor.
2677
- const superseded = [...journal.read()].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
2678
- if (superseded)
2679
- throw new Error(`refusing to resume ${runId}: superseded by ${superseded.data.by}`);
2855
+ // The superseded check and the run-start read refused at the resume anchor (above), before any append.
2680
2856
  const start = journal.read().find((e) => e.event === "run-start");
2681
- if (!start)
2682
- throw new Error(`journal for ${runId} has no run-start event`);
2683
2857
  baseRef = start.data.baseRef;
2684
2858
  // T3 (Sol #2 / Fable F2): refuse to replay this journal's task states onto a graph it does not
2685
2859
  // belong to — overlapping ids would inherit foreign done/human/approval state, missing ids throw.
@@ -2765,6 +2939,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2765
2939
  journal.append("run-resume", undefined, {
2766
2940
  ...sessionMetadata,
2767
2941
  pid: process.pid, // v1.13 (VIS-11): record the live daemon pid for status liveness
2942
+ integrationAnchor: resumeAnchor, // 2.8.1 T3: "journal" (E = A) or "legacy-ref" (E = the ref)
2768
2943
  ...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
2769
2944
  ...(opts.retryFailed ? { retryFailed: true } : {}),
2770
2945
  ...(opts.graphChanged ? { graphChanged: true } : {}), // OBS-1073: the release is the engagement's
@@ -2857,6 +3032,7 @@ export async function runDaemon(repoRoot, opts = {}) {
2857
3032
  driver: driver.id,
2858
3033
  driverEvidence: driverEvidence(cfg, driver, opts.driverOverride),
2859
3034
  branch, graphDefinitionHash: graphDefinitionHash(graph),
3035
+ integrationAnchor: "journal", // 2.8.1 T2: this run creates its branch at baseRef and journals every publication with `from`
2860
3036
  ...(prior ? { supersedes: prior.runId } : {}),
2861
3037
  }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2; supersedes: v1.53 T5
2862
3038
  runStarted = true;
@@ -2877,7 +3053,9 @@ export async function runDaemon(repoRoot, opts = {}) {
2877
3053
  const collateral = pinnedCollateral ?? collateralHits(graph.tasks, repoRoot);
2878
3054
  if (!pinnedCollateral)
2879
3055
  writeFileSync(collateralPath, JSON.stringify(Object.fromEntries(collateral), null, 2));
2880
- const intWt = await ensureIntegration(repoRoot, branch, baseRef);
3056
+ // 2.8.1 T2 (ANL-023 §7.2): the published tip E — a daemon-lifetime value, advanced only by this daemon's own
3057
+ // compare-and-swap publications. No checkout of the integration branch exists.
3058
+ let published = resumedTip ?? await ensureIntegration(repoRoot, branch, baseRef);
2881
3059
  // v1.1 visibility: role-named slots; panes persist per keepPanes (attempt = v1 close-after-harvest)
2882
3060
  const keepOpen = cfg.visibility.keepPanes !== "attempt";
2883
3061
  // D-07 (HYG-09) fleet hygiene — ephemeral panes self-clean + done means gone. keepLlm decouples the
@@ -2968,14 +3146,31 @@ export async function runDaemon(repoRoot, opts = {}) {
2968
3146
  // as its OWN completion — silently LYING about a worker's outcome. Pinned by the stale-trailer oracle
2969
3147
  // in tests/run/daemon.test.ts ("a retained prior-attempt trailer cannot complete a retry"); a future
2970
3148
  // hoist back to run scope reddens it.
2971
- // merges are serialized — two concurrent `git merge`s in one worktree would corrupt each other
3149
+ // merges are serialized: each is a compare-and-swap from E, and its rows land in publication order (task-done
3150
+ // immediately before its merge row, which names its own commit and the E it was published from).
2972
3151
  let mergeChain = Promise.resolve();
2973
- const mergeSerial = (taskBranch, t, gated) => {
2974
- const next = mergeChain.then(() => {
3152
+ const mergeSerial = (taskBranch, t, gated, done, ancestorOnly) => {
3153
+ const next = mergeChain.then(async () => {
2975
3154
  fatalStop.signal.throwIfAborted();
2976
3155
  executionSignal()?.throwIfAborted();
2977
3156
  journal.phaseStart(t.id, "merge");
2978
- return mergeTask(intWt, taskBranch, `tickmarkr: merge ${t.id} ${t.title}`, gated);
3157
+ const setDone = () => {
3158
+ graph = setStatus(graph, t.id, "done");
3159
+ saveGraph(repoRoot, graph);
3160
+ journal.append("task-done", t.id, done);
3161
+ };
3162
+ const merged = journal.read().filter((e) => e.taskId === t.id && e.event === "merge" && typeof e.data.commit === "string")
3163
+ .map((e) => e.data.commit).reverse();
3164
+ const from = published;
3165
+ const m = await mergeTask(repoRoot, { branch, expected: from, merged, ...(ancestorOnly ? { ancestorOnly } : {}) }, taskBranch, `tickmarkr: merge ${t.id} ${t.title}`, gated, (commit) => {
3166
+ published = commit;
3167
+ setDone();
3168
+ journal.append("merge", t.id, { branch: taskBranch, commit, from });
3169
+ });
3170
+ // Already merged through its own recorded merge: done, with nothing published and no new merge row.
3171
+ if (m.ok && "alreadyMerged" in m)
3172
+ setDone();
3173
+ return m;
2979
3174
  });
2980
3175
  mergeChain = next.catch(() => undefined);
2981
3176
  return next;
@@ -3842,28 +4037,39 @@ export async function runDaemon(repoRoot, opts = {}) {
3842
4037
  // normalizes one. Preparation, replay, conflict handling, repair funding, feedback and cleanup stay
3843
4038
  // in the callers.
3844
4039
  const settleResults = async (results, p) => {
3845
- const parkAs = (reason, kind) => park(t, reason, kind, p.author, p.attempts, startMs, gateFails, consults, tokens, metered, retryMode);
4040
+ const parkAs = (reason, kind, details) => park(t, reason, kind, p.author, p.attempts, startMs, gateFails, consults, tokens, metered, retryMode, details);
3846
4041
  if (results.every(gateSatisfied)) {
3847
4042
  p.beforeMerge?.();
3848
- const m = await mergeSerial(p.taskBranch, t, p.gated);
3849
- if (m.tipMoved) {
4043
+ // §7.2: the author fold is taken BEFORE the serializer; a throw here propagates before any publication.
4044
+ const authors = mergedAuthors(await subjectAuthors(journal.read(), t.id, p.wt, p.taskBase));
4045
+ const m = await mergeSerial(p.taskBranch, t, p.gated, { attempts: p.attempts, assignment: p.author, taskContentDigest: contentDigest, authors }, p.ancestorOnly);
4046
+ if ("tipMoved" in m) {
3850
4047
  journal.append("tip-moved", t.id, m.tipMoved);
3851
4048
  if (tipMoves++ === 0)
3852
4049
  return { kind: "regate" };
3853
4050
  await parkAs("task branch tip moved twice after gating", "tip-moved");
3854
4051
  return { kind: "parked" };
3855
4052
  }
3856
- if (!m.ok) {
4053
+ if ("conflict" in m) {
3857
4054
  journal.append("merge-conflict", t.id, { conflict: m.conflict });
3858
4055
  return { kind: "conflict", conflict: m.conflict };
3859
4056
  }
3860
- graph = setStatus(graph, t.id, "done");
3861
- saveGraph(repoRoot, graph);
3862
- journal.append("task-done", t.id, {
3863
- attempts: p.attempts, assignment: p.author, taskContentDigest: contentDigest,
3864
- authors: mergedAuthors(await subjectAuthors(journal.read(), t.id, p.wt, p.taskBase)),
3865
- });
3866
- journal.append("merge", t.id, { branch: p.taskBranch, commit: await integrationHead(intWt) });
4057
+ if ("integrationMoved" in m) {
4058
+ const { leg, expected, found, target } = m.integrationMoved;
4059
+ await parkAs(`integration branch ${branch} moved (${leg}): expected ${short(expected)}, found ${short(found ?? undefined)}`
4060
+ + `${target ? ` (symbolic to ${target})` : ""} — repair: ${integrationRepairText(m.state, branch, runId, expected)}, then approve a recheck`, "infra", { integrationMoved: m.integrationMoved });
4061
+ return { kind: "parked" };
4062
+ }
4063
+ if ("alreadyIntegrated" in m) {
4064
+ await parkAs(`gated commit ${short(m.alreadyIntegrated.gated)} is already in the integration history at ${short(m.alreadyIntegrated.tip)}`
4065
+ + " without this task's recorded merge — nothing was published; inspect the task branch, then approve a recheck", "infra");
4066
+ return { kind: "parked" };
4067
+ }
4068
+ if ("infra" in m) {
4069
+ await parkAs(`merge publication failed (${m.infra.step}): ${m.infra.message}`, "infra");
4070
+ return { kind: "parked" };
4071
+ }
4072
+ // Published (or already merged through its own recorded merge): the serializer set the task done.
3867
4073
  await trackedDriver.project?.(t.id, "completed");
3868
4074
  // firstAttemptOk/gateFails/consults are recorded FACTS, not policy — a parkKind:"stall" row is
3869
4075
  // recorded but NOT quality-negative in v1.6; Phase 12 owns reward policy, so flipping it later needs zero data migration.
@@ -4077,7 +4283,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4077
4283
  if (uncertain)
4078
4284
  return decline(uncertain);
4079
4285
  }
4080
- const commits = existsSync(wt) ? await commitsAheadOf(await integrationHead(intWt), wt) : [];
4286
+ const commits = existsSync(wt) ? await commitsAheadOf(published, wt) : [];
4081
4287
  if (!found.result.finished && commits.length === 0)
4082
4288
  return decline("unfinished");
4083
4289
  recordHarvest(commits, found.result.finished, found.result.summary);
@@ -4118,7 +4324,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4118
4324
  .some(([, other]) => other !== nonce && adapter.parse(pane, other).cause === undefined);
4119
4325
  if (foreign)
4120
4326
  return decline("foreign-nonce");
4121
- const taskBase = await integrationHead(intWt);
4327
+ const taskBase = published;
4122
4328
  if (!finished && (await commitsAheadOf(taskBase, wt)).length === 0)
4123
4329
  return decline("unfinished");
4124
4330
  const uncertain = await reapOwned(ownedSlot, launch);
@@ -4176,7 +4382,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4176
4382
  }
4177
4383
  }
4178
4384
  resumeGateReplay: if (satisfiedGate || replayedGates || recheck || harvestAuthor) {
4179
- const taskBase = await integrationHead(intWt);
4385
+ const taskBase = published;
4180
4386
  taskBases.set(t.id, taskBase);
4181
4387
  const taskBranch = `${branch}--${t.id}`;
4182
4388
  const priorWt = worktreePath(repoRoot, taskBranch);
@@ -4204,6 +4410,27 @@ export async function runDaemon(repoRoot, opts = {}) {
4204
4410
  const priorTaskTip = preservedRef ? (await shGit(`git rev-parse ${shq(preservedRef)}`, repoRoot)).stdout.trim() : await gitHead(priorWt);
4205
4411
  const priorTaskSubject = await gateCommitSubject(taskBase, priorTaskTip, preservedRef ? repoRoot : priorWt);
4206
4412
  const commitsToCarry = preservedRef ? await commitsAheadOfRef(taskBase, priorTaskTip, repoRoot) : await commitsAheadOf(taskBase, priorWt);
4413
+ // 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`
4414
+ // exit 0 — an empty or failed history read is no proof) keeps that tip as its gated subject — recreated on E it
4415
+ // would be gated AT E, and a sibling's publication since its merge would hide the recorded merge. Nothing is gated
4416
+ // or published (ancestorOnly): the serializer's already-merged rule decides (done through its own recorded merge,
4417
+ // else an alreadyIntegrated park). A moved task tip, or any failed proof, regates below.
4418
+ if (!preservedRef && journal.read().some((e) => e.taskId === t.id && e.event === "merge")
4419
+ && (await shGit(`git merge-base --is-ancestor ${shq(priorTaskTip)} ${shq(taskBase)}`, repoRoot)).code === 0) {
4420
+ const author = rs?.lastAssignment ?? assignment;
4421
+ const settled = await settleResults([], {
4422
+ wt: priorWt, taskBase, taskBranch, gated: priorTaskTip, commit: priorTaskSubject, outsideScopeForced: false,
4423
+ author, attempts: rs?.attempts ?? 0, infraReason: (g) => `${g.gate}: ${g.details}`,
4424
+ reobserve: { attempt: rs?.attempts ?? 0, replayed: true }, firstAttemptOk: false, metering: {},
4425
+ disposition: () => ({ author, attempt: rs?.attempts ?? 0 }), ancestorOnly: true,
4426
+ });
4427
+ if (settled.kind === "parked")
4428
+ return;
4429
+ if (settled.kind === "merged") {
4430
+ await reconcile({ spareLiveLlm: true });
4431
+ return;
4432
+ }
4433
+ }
4207
4434
  const wt = await recreateTaskWorktree(taskBranch, taskBase, priorWt);
4208
4435
  const { carried: carriedCommits, accounted: accountedCommits } = await cherryPickCommits(wt, commitsToCarry);
4209
4436
  // Reuse is about the tree the gates will actually inspect. The integration tip may have moved
@@ -4801,7 +5028,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4801
5028
  ...(carriedConsultGuidance ? { carriedConsultGuidance } : {}),
4802
5029
  });
4803
5030
  journal.phaseStart(t.id, "worker", { attempt, assignment });
4804
- const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
5031
+ const taskBase = published; // deps are merged → visible to this task (E, never the ref as it stands)
4805
5032
  taskBases.set(t.id, taskBase);
4806
5033
  const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
4807
5034
  const priorWt = worktreePath(repoRoot, taskBranch);
@@ -7079,9 +7306,15 @@ export async function runDaemon(repoRoot, opts = {}) {
7079
7306
  throw error;
7080
7307
  });
7081
7308
  fatalPhase = "tip-verify";
7082
- // OBS-34: post-merge integration-tip verify — strict exit codes, no baseline forgiveness.
7309
+ // OBS-34: post-merge integration-tip verify — the battery forgives only what its baseline entry recorded red
7310
+ // (src/run/merge.ts, the same predicate as the gate battery).
7083
7311
  const lastMergedTask = [...journal.read()].reverse().find((e) => e.event === "merge" && e.taskId)?.taskId;
7312
+ // 2.8.1 T1 (ANL-023 §7.1): every close opens on the integration identity and closes on it below.
7313
+ const identity = { repo: repoRoot, branch, expected: published }; // 2.8.1 T2: E
7314
+ let tipClose;
7315
+ let tipStarted = false;
7084
7316
  if (summary.done.length > 0 && Object.keys(commands).length > 0) {
7317
+ tipStarted = true;
7085
7318
  const controller = new AbortController();
7086
7319
  const cancellation = new Error("tip verify cancelled by approval");
7087
7320
  const checkApprovals = () => {
@@ -7104,11 +7337,10 @@ export async function runDaemon(repoRoot, opts = {}) {
7104
7337
  poll = setTimeout(pollApprovals, APPROVAL_POLL_MS);
7105
7338
  };
7106
7339
  poll = setTimeout(pollApprovals, APPROVAL_POLL_MS);
7107
- let tipFailed;
7108
7340
  try {
7109
- const verification = withCommandContext(undefined, () => verifyIntegrationTipCached(intWt, commands, journal, { lastMergedTask, baseline, signal: controller.signal, evidence: { quotaBytes: cfg.gates?.evidenceQuotaBytes } }), controller.signal);
7341
+ const verification = withCommandContext(undefined, () => verifyIntegrationTipCached(repoRoot, commands, journal, { lastMergedTask, baseline, signal: controller.signal, evidence: { quotaBytes: cfg.gates?.evidenceQuotaBytes }, identity }), controller.signal);
7110
7342
  activeTipVerify = { controller, settled: verification.then(() => { }, () => { }) };
7111
- tipFailed = await verification;
7343
+ tipClose = await verification;
7112
7344
  }
7113
7345
  catch (error) {
7114
7346
  if (error !== cancellation)
@@ -7120,14 +7352,25 @@ export async function runDaemon(repoRoot, opts = {}) {
7120
7352
  activeTipVerify = undefined;
7121
7353
  clearTimeout(poll);
7122
7354
  }
7123
- summary.tipVerify = tipFailed ? "failed" : "passed";
7355
+ summary.tipVerify = tipClose.failed ? "failed" : "passed";
7124
7356
  summary.tipProof = runEndTipProof(journal.read());
7125
- if (tipFailed && lastMergedTask)
7357
+ if (tipClose.failed && lastMergedTask)
7126
7358
  summary.lastMergedTask = lastMergedTask;
7127
7359
  }
7128
7360
  else {
7129
- // Exactly one proof per close: no cycle ran for THIS close, and an earlier one is not inherited.
7130
- summary.tipProof = { kind: "incomplete" };
7361
+ // The opening check runs on every close, also with nothing done or no command configured.
7362
+ const opened = await openingIdentity(identity, journal);
7363
+ if (opened.ok) {
7364
+ tipClose = { failed: false, expected: opened.expected, identityFailed: false, deferred: [] };
7365
+ // Exactly one proof per close: no cycle ran for THIS close, and an earlier one is not inherited.
7366
+ summary.tipProof = { kind: "incomplete" };
7367
+ }
7368
+ else {
7369
+ appendIdentityFailure(journal, { started: false, branch, ...opened, cmdHash: commandsHash(commands), capacity: resolvedCapacity() });
7370
+ tipClose = { failed: true, identityFailed: true, deferred: [] };
7371
+ summary.tipVerify = "failed";
7372
+ summary.tipProof = runEndTipProof(journal.read());
7373
+ }
7131
7374
  }
7132
7375
  // T14: read the journal, not the startup `approved` set — an approval appended DURING this run is
7133
7376
  // exactly the one the set cannot see, and it is the one the record has to name.
@@ -7154,15 +7397,27 @@ export async function runDaemon(repoRoot, opts = {}) {
7154
7397
  const forgiven = forgivenFingerprints(journal.read());
7155
7398
  if (forgiven.length > 0)
7156
7399
  summary.forgiven = forgiven;
7400
+ // Z: the closing identity check — after the serialization and the hold branch, the last await before the
7401
+ // run-end row. Deferred verdicts reach the store only when it holds; every other path discarded them.
7402
+ if (!tipClose.identityFailed && tipClose.expected !== undefined) {
7403
+ const closing = await checkIntegrationIdentity(repoRoot, branch, tipClose.expected);
7404
+ if (!closing.ok) {
7405
+ appendIdentityFailure(journal, { started: tipStarted, branch, expected: tipClose.expected, leg: closing.leg, state: closing.state,
7406
+ details: identityDetails(closing.leg, closing.state, branch, runId, tipClose.expected), cmdHash: commandsHash(commands), capacity: resolvedCapacity() });
7407
+ summary.tipVerify = "failed";
7408
+ summary.tipProof = runEndTipProof(journal.read());
7409
+ }
7410
+ else {
7411
+ const store = getVerdictStore(resolveStateDir(repoRoot, journal.dir));
7412
+ for (const { id, verdict } of tipClose.deferred)
7413
+ store.set(id, verdict);
7414
+ }
7415
+ }
7157
7416
  journal.append("run-end", undefined, { ...summary });
7158
7417
  await reconcile(); // run-end boundary: nothing in flight — full sweep (empty desired set)
7159
7418
  // OBS-28: lingering worktrees starve CLI probes; keepPanes:forever is the debug override.
7160
- if (!keepForever) {
7161
- const green = summary.failed.length === 0 && summary.human.length === 0
7162
- && summary.blocked.length === 0 && summary.pending.length === 0
7163
- && summary.tipVerify !== "failed";
7164
- await cleanupRunWorktrees(repoRoot, branch, { removeIntegration: green, removeTaskIds: summary.done });
7165
- }
7419
+ if (!keepForever)
7420
+ await cleanupRunWorktrees(repoRoot, branch, { removeTaskIds: summary.done });
7166
7421
  // VIS-02: name each blocked subtree by its nearest parked/failed root, e.g. "3 blocked behind P40-02".
7167
7422
  const attribution = [...attributeBlocked(graph).entries()]
7168
7423
  .sort(([a], [b]) => a.localeCompare(b))