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.
- package/dist/adapters/codex.js +3 -2
- package/dist/cli/commands/approve.js +33 -8
- package/dist/cli/commands/verify.js +23 -11
- package/dist/gates/baseline.js +3 -3
- package/dist/gates/run-gates.js +61 -1
- package/dist/run/daemon.d.ts +77 -13
- package/dist/run/daemon.js +350 -95
- package/dist/run/git.d.ts +16 -3
- package/dist/run/git.js +19 -6
- package/dist/run/merge.d.ts +114 -7
- package/dist/run/merge.js +127 -34
- package/dist/run/wall-budget.js +6 -1
- package/package.json +1 -2
- package/skills/tickmarkr-overseer/SKILL.md +42 -20
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +162 -166
- package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +0 -133
package/dist/run/daemon.js
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
1119
|
-
*
|
|
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
|
|
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
|
|
1140
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
const
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
.
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
gate: r.gate,
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3849
|
-
|
|
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 (
|
|
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
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
}
|
|
3866
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 —
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
7355
|
+
summary.tipVerify = tipClose.failed ? "failed" : "passed";
|
|
7124
7356
|
summary.tipProof = runEndTipProof(journal.read());
|
|
7125
|
-
if (
|
|
7357
|
+
if (tipClose.failed && lastMergedTask)
|
|
7126
7358
|
summary.lastMergedTask = lastMergedTask;
|
|
7127
7359
|
}
|
|
7128
7360
|
else {
|
|
7129
|
-
//
|
|
7130
|
-
|
|
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
|
-
|
|
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))
|