tickmarkr 2.8.0 → 2.8.1
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/cli/commands/approve.js +33 -8
- package/dist/cli/commands/verify.js +23 -11
- package/dist/gates/run-gates.js +61 -1
- package/dist/run/daemon.d.ts +77 -13
- package/dist/run/daemon.js +348 -94
- 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/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +8 -0
package/dist/run/git.d.ts
CHANGED
|
@@ -198,16 +198,29 @@ export declare function shGitOk(cmd: string, cwd: string, own?: OwnGitOptions):
|
|
|
198
198
|
* A step raced by a commondir change is refused by name before the next one runs, and what the add created is rolled
|
|
199
199
|
* back through the trusted common directory derived here once (dropWorktree), even while that commondir stays hostile.
|
|
200
200
|
*/
|
|
201
|
-
export declare function addDetachedWorktree(cwd: string, dir: string, commitish: string, timeoutMs?: number
|
|
201
|
+
export declare function addDetachedWorktree(cwd: string, dir: string, commitish: string, timeoutMs?: number, { logHead }?: {
|
|
202
|
+
logHead?: boolean | undefined;
|
|
203
|
+
}): Promise<ShResult>;
|
|
204
|
+
/**
|
|
205
|
+
* 2.8.1 T1 (ANL-023 §7.1): the reflog witness W(dir, ref) — read-only, the newest entry first. It is valid for a commit
|
|
206
|
+
* x only when the exit is 0, at least one line came back and the first is x: exit 0 with no lines is git's answer for
|
|
207
|
+
* a missing or unreadable log, so an empty witness is a refusal, never an accepted empty.
|
|
208
|
+
*/
|
|
209
|
+
export declare function reflogWitness(dir: string, ref: string): Promise<{
|
|
210
|
+
code: number;
|
|
211
|
+
lines: string[];
|
|
212
|
+
}>;
|
|
202
213
|
export declare function npmDependencyManifestChanged(cwd: string, baselineRef: string): Promise<boolean>;
|
|
203
214
|
export declare function npmDependencyInstallCommand(cwd: string): string;
|
|
204
215
|
export declare function gitHead(cwd: string): Promise<string>;
|
|
205
216
|
export declare const sanitizeBranch: (branch: string) => string;
|
|
206
217
|
export declare const WORKTREES_DIR = "worktrees.noindex";
|
|
207
218
|
export declare const worktreePath: (repo: string, branch: string) => string;
|
|
208
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* OBS-28: remove this run's recorded task worktrees; tolerates already-gone paths. 2.8.1 T2: a run keeps no
|
|
221
|
+
* integration checkout, and a 2.8.0 run's legacy one is never removed here (it may hold the only view of a merge).
|
|
222
|
+
*/
|
|
209
223
|
export declare function cleanupRunWorktrees(repo: string, branch: string, opts: {
|
|
210
|
-
removeIntegration: boolean;
|
|
211
224
|
removeTaskIds: string[];
|
|
212
225
|
}): Promise<void>;
|
|
213
226
|
export declare function resolveIntegrationBranch(_repo: string, branch: string): Promise<string>;
|
package/dist/run/git.js
CHANGED
|
@@ -607,7 +607,10 @@ export async function shGitOk(cmd, cwd, own = {}) {
|
|
|
607
607
|
* A step raced by a commondir change is refused by name before the next one runs, and what the add created is rolled
|
|
608
608
|
* back through the trusted common directory derived here once (dropWorktree), even while that commondir stays hostile.
|
|
609
609
|
*/
|
|
610
|
-
export async function addDetachedWorktree(cwd, dir, commitish, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
|
|
610
|
+
export async function addDetachedWorktree(cwd, dir, commitish, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS, { logHead = false } = {}) {
|
|
611
|
+
// 2.8.1 T1 (ANL-023 §7.1): `logHead` writes the new checkout's HEAD reflog whatever the repository's
|
|
612
|
+
// core.logAllRefUpdates says, so a later HEAD move by any command is appended to an established witness.
|
|
613
|
+
const git = logHead ? "git -c core.logAllRefUpdates=always" : "git";
|
|
611
614
|
// the authority this operation keeps, rollback included: derived once from the trusted layout, never a commondir
|
|
612
615
|
const store = trustedCommonDir(cwd);
|
|
613
616
|
const resolved = await shGit(`git rev-parse --verify --end-of-options ${shq(`${commitish}^{commit}`)}`, cwd, timeoutMs);
|
|
@@ -616,12 +619,12 @@ export async function addDetachedWorktree(cwd, dir, commitish, timeoutMs = DEFAU
|
|
|
616
619
|
const oid = resolved.stdout.trim();
|
|
617
620
|
// under a ref payload git also resolves its new-HEAD stub into a shared refs/heads/.invalid; it is deleted again
|
|
618
621
|
const rollback = { dir, branch: { name: ".invalid" } };
|
|
619
|
-
const added = await shellEntry(
|
|
622
|
+
const added = await shellEntry(`${git} worktree add --no-checkout --detach ${shq(dir)} ${shq(oid)}`, cwd, timeoutMs, false, {}, { createsWorktree: "detached", rollback });
|
|
620
623
|
if (added.code !== 0)
|
|
621
624
|
return added;
|
|
622
625
|
let repaired;
|
|
623
626
|
try {
|
|
624
|
-
repaired = await shGit(
|
|
627
|
+
repaired = await shGit(`${git} update-ref --no-deref HEAD ${shq(oid)} && ${git} update-ref -d refs/heads/.invalid && ${git} reset -q --hard --no-recurse-submodules`, dir, timeoutMs);
|
|
625
628
|
}
|
|
626
629
|
catch (error) {
|
|
627
630
|
await dropWorktree(store, rollback, timeoutMs).catch((failure) => { throw rollbackFailed(error, failure); });
|
|
@@ -631,6 +634,15 @@ export async function addDetachedWorktree(cwd, dir, commitish, timeoutMs = DEFAU
|
|
|
631
634
|
await dropWorktree(store, rollback, timeoutMs);
|
|
632
635
|
return repaired;
|
|
633
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* 2.8.1 T1 (ANL-023 §7.1): the reflog witness W(dir, ref) — read-only, the newest entry first. It is valid for a commit
|
|
639
|
+
* x only when the exit is 0, at least one line came back and the first is x: exit 0 with no lines is git's answer for
|
|
640
|
+
* a missing or unreadable log, so an empty witness is a refusal, never an accepted empty.
|
|
641
|
+
*/
|
|
642
|
+
export async function reflogWitness(dir, ref) {
|
|
643
|
+
const r = await shGit(`git reflog show --no-abbrev --format=%H ${shq(ref)}`, dir);
|
|
644
|
+
return { code: r.code, lines: r.stdout.split("\n").map((line) => line.trim()).filter(Boolean) };
|
|
645
|
+
}
|
|
634
646
|
/** A failed rollback is never swallowed: it rides on the error the operation already throws (a refusal stays one). */
|
|
635
647
|
function rollbackFailed(error, failure) {
|
|
636
648
|
const why = `; its rollback failed, so what it created may remain: ${failure instanceof Error ? failure.message : String(failure)}`;
|
|
@@ -738,10 +750,11 @@ const sanitize = sanitizeBranch;
|
|
|
738
750
|
// once after this rename — the same one-time per-install cost as the v1.38 state-dir rename.
|
|
739
751
|
export const WORKTREES_DIR = "worktrees.noindex";
|
|
740
752
|
export const worktreePath = (repo, branch) => join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
|
|
741
|
-
/**
|
|
753
|
+
/**
|
|
754
|
+
* OBS-28: remove this run's recorded task worktrees; tolerates already-gone paths. 2.8.1 T2: a run keeps no
|
|
755
|
+
* integration checkout, and a 2.8.0 run's legacy one is never removed here (it may hold the only view of a merge).
|
|
756
|
+
*/
|
|
742
757
|
export async function cleanupRunWorktrees(repo, branch, opts) {
|
|
743
|
-
if (opts.removeIntegration)
|
|
744
|
-
await removeWorktree(repo, worktreePath(repo, branch));
|
|
745
758
|
for (const id of opts.removeTaskIds)
|
|
746
759
|
await removeWorktree(repo, worktreePath(repo, `${branch}--${id}`));
|
|
747
760
|
}
|
package/dist/run/merge.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TickmarkrConfig } from "../config/config.js";
|
|
2
2
|
import { type Baseline, type BaselineProvenance, type GateEvidenceOptions, type FailureClassification } from "../gates/baseline.js";
|
|
3
|
+
import { type VerificationIdentity } from "../gates/cache.js";
|
|
3
4
|
import type { GateEvidenceReceipt } from "./protocol.js";
|
|
4
5
|
export interface TipVerifyResult {
|
|
5
6
|
evidenceReceipt?: GateEvidenceReceipt;
|
|
@@ -30,18 +31,124 @@ export interface TipVerifyResult {
|
|
|
30
31
|
* for a kill, the shared runner classifier for everything else. `infra` means nothing was verified.
|
|
31
32
|
*/
|
|
32
33
|
cause?: FailureClassification;
|
|
34
|
+
/** 2.8.1 T1: with `deferVerdicts`, the verdict-store write this result would have made, left to the caller. */
|
|
35
|
+
deferredVerdict?: {
|
|
36
|
+
id: VerificationIdentity;
|
|
37
|
+
verdict: TipVerifyResult & {
|
|
38
|
+
meta: Record<string, unknown>;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
33
41
|
}
|
|
34
42
|
/** Carry execution evidence without minting an invocation or rebasing its artifact paths. */
|
|
35
43
|
export declare function reusedTipEvidence(source: Partial<TipVerifyResult>): Partial<TipVerifyResult>;
|
|
36
44
|
export declare function integrationBranch(cfg: TickmarkrConfig, runId: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* 2.8.1 T2 (ANL-023 §7.2): creates the integration ref create-only at `baseRef` (the empty old value means "must not
|
|
47
|
+
* exist") and returns the published tip E. The run keeps no checkout of the branch: every later publication is a
|
|
48
|
+
* compare-and-swap of the ref from `repo` (mergeTask). An existing or uncreatable ref throws — the fatal path's text.
|
|
49
|
+
*/
|
|
37
50
|
export declare function ensureIntegration(repo: string, branch: string, baseRef: string): Promise<string>;
|
|
38
|
-
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
/** 2.8.1 T1 (ANL-023 §7.1): the closed states of the integration ref, as R(ref) reads them. */
|
|
52
|
+
export type IntegrationRefState = {
|
|
53
|
+
kind: "regular";
|
|
54
|
+
commit: string;
|
|
55
|
+
} | {
|
|
56
|
+
kind: "missing";
|
|
57
|
+
} | {
|
|
58
|
+
kind: "symbolic";
|
|
59
|
+
target: string;
|
|
60
|
+
commit?: string;
|
|
61
|
+
} | {
|
|
62
|
+
kind: "unreadable";
|
|
63
|
+
read: string;
|
|
64
|
+
};
|
|
65
|
+
export type IntegrationLeg = "symbolic" | "missing" | "value" | "unreadable";
|
|
66
|
+
/**
|
|
67
|
+
* R(ref): the integration ref read from `repo` through the own-git runner, never its reflog. The exact ref only —
|
|
68
|
+
* a descendant ref (`refs/heads/<branch>/x`) is not this ref, and a ref that exists but names no commit is unreadable.
|
|
69
|
+
*/
|
|
70
|
+
export declare function readIntegrationRef(repo: string, branch: string): Promise<IntegrationRefState>;
|
|
71
|
+
/** I(x): holds iff the ref is regular at x; otherwise the leg, with the state the repair text is chosen by. */
|
|
72
|
+
export declare function checkIntegrationIdentity(repo: string, branch: string, expected: string): Promise<{
|
|
73
|
+
ok: true;
|
|
74
|
+
state: IntegrationRefState;
|
|
75
|
+
} | {
|
|
76
|
+
ok: false;
|
|
77
|
+
leg: IntegrationLeg;
|
|
78
|
+
state: IntegrationRefState;
|
|
79
|
+
}>;
|
|
80
|
+
/**
|
|
81
|
+
* The repair text (shared by run end's failure row, the merge park and the resume refusal): backticked commands run
|
|
82
|
+
* from the repository root in order, stopping at the first that fails. A found commit is preserved create-only under
|
|
83
|
+
* refs/tickmarkr/found/<runId>/<F> before a no-deref compare-and-swap restores the ref; a symbolic found ref or one
|
|
84
|
+
* already holding another commit stops the sequence before the restore. Dangling and unreadable print no command.
|
|
85
|
+
*/
|
|
86
|
+
export declare function integrationRepairText(state: IntegrationRefState, branch: string, runId: string, expected: string): string;
|
|
87
|
+
type GitResult = {
|
|
88
|
+
code: number;
|
|
89
|
+
stdout: string;
|
|
90
|
+
stderr: string;
|
|
91
|
+
};
|
|
92
|
+
/** The merge's git steps after the identity check, in order; `publish` is the compare-and-swap. */
|
|
93
|
+
export type MergeStep = "ancestry" | "merge-tree" | "commit-tree" | "publish";
|
|
94
|
+
/** Test-only: runs before each step; a returned result replaces that step's git result (never `publish`'s). */
|
|
95
|
+
export type MergeSeam = (step: MergeStep) => Promise<GitResult | void> | GitResult | void;
|
|
96
|
+
export interface IntegrationMove {
|
|
97
|
+
branch: string;
|
|
98
|
+
expected: string;
|
|
99
|
+
found: string | null;
|
|
100
|
+
leg: IntegrationLeg;
|
|
101
|
+
target?: string;
|
|
102
|
+
stage: "check" | "publish";
|
|
103
|
+
}
|
|
104
|
+
export type MergeOutcome = {
|
|
105
|
+
ok: true;
|
|
106
|
+
commit: string;
|
|
107
|
+
from: string;
|
|
108
|
+
} | {
|
|
109
|
+
ok: true;
|
|
110
|
+
alreadyMerged: string;
|
|
111
|
+
} | {
|
|
112
|
+
ok: false;
|
|
113
|
+
tipMoved: {
|
|
43
114
|
gatedCommit: string;
|
|
44
115
|
branchTip: string;
|
|
45
116
|
};
|
|
46
|
-
}
|
|
47
|
-
|
|
117
|
+
} | {
|
|
118
|
+
ok: false;
|
|
119
|
+
conflict: string;
|
|
120
|
+
} | {
|
|
121
|
+
ok: false;
|
|
122
|
+
integrationMoved: IntegrationMove;
|
|
123
|
+
state: IntegrationRefState;
|
|
124
|
+
} | {
|
|
125
|
+
ok: false;
|
|
126
|
+
alreadyIntegrated: {
|
|
127
|
+
gated: string;
|
|
128
|
+
tip: string;
|
|
129
|
+
};
|
|
130
|
+
} | {
|
|
131
|
+
ok: false;
|
|
132
|
+
infra: {
|
|
133
|
+
step: MergeStep;
|
|
134
|
+
message: string;
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* 2.8.1 T2 (ANL-023 §7.2): publishes `gated` onto the integration ref by compare-and-swap from the stored tip
|
|
139
|
+
* `expected`, all git from `repo` — no checkout. (1) the task-tip check; (2) I(expected); (3) ancestry; (4) merge-tree;
|
|
140
|
+
* (5) commit-tree; (6) `update-ref --no-deref` from `expected`; (7) `onPublished(M)` in the same synchronous
|
|
141
|
+
* continuation as (6)'s result, so the caller's rows land in publication order. `merged` is the task's recorded merge
|
|
142
|
+
* commits, newest first: a gated commit already in the history is done only through its own recorded merge.
|
|
143
|
+
* `ancestorOnly` (a replay that ran no gates): a gated commit not in E is refused `infra`, never published.
|
|
144
|
+
*/
|
|
145
|
+
export declare function mergeTask(repo: string, { branch, expected, merged, ancestorOnly }: {
|
|
146
|
+
branch: string;
|
|
147
|
+
expected: string;
|
|
148
|
+
merged?: readonly string[];
|
|
149
|
+
ancestorOnly?: boolean;
|
|
150
|
+
}, taskBranch: string, message: string, gated: string, onPublished: (commit: string) => void, seam?: MergeSeam): Promise<MergeOutcome>;
|
|
151
|
+
export declare function verifyIntegrationTip(intWt: string, commands: Record<string, string>, runDir: string, baseline?: Baseline, evidenceOptions?: GateEvidenceOptions, { deferVerdicts }?: {
|
|
152
|
+
deferVerdicts?: boolean;
|
|
153
|
+
}): Promise<TipVerifyResult[]>;
|
|
154
|
+
export {};
|
package/dist/run/merge.js
CHANGED
|
@@ -4,8 +4,7 @@ import { shq } from "../adapters/types.js";
|
|
|
4
4
|
import { baselineEntryRefusal, beginGateEvidence, ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, classifiedFileDurations, fingerprint, freshFailures, } from "../gates/baseline.js";
|
|
5
5
|
import { evaluateManifestedTest, isVitestTestCommand } from "../gates/test-manifest.js";
|
|
6
6
|
import { computeVerificationIdentity, formatReusedDetails, getVerdictStore, getWorktreeTree, resolveStateDir, isInfraResult, } from "../gates/cache.js";
|
|
7
|
-
import {
|
|
8
|
-
import { dependencyLinkRefusal, describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
|
|
7
|
+
import { dependencyLinkRefusal, describeCapacity, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit } from "./git.js";
|
|
9
8
|
import { executionSignal } from "./execution-budget.js";
|
|
10
9
|
// Diagnostic convenience logs are observational, just like receipt persistence.
|
|
11
10
|
function writeTipLog(path, text) {
|
|
@@ -32,42 +31,131 @@ export function reusedTipEvidence(source) {
|
|
|
32
31
|
export function integrationBranch(cfg, runId) {
|
|
33
32
|
return `${cfg.integrationBranchPrefix}${runId}`;
|
|
34
33
|
}
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* 2.8.1 T2 (ANL-023 §7.2): creates the integration ref create-only at `baseRef` (the empty old value means "must not
|
|
36
|
+
* exist") and returns the published tip E. The run keeps no checkout of the branch: every later publication is a
|
|
37
|
+
* compare-and-swap of the ref from `repo` (mergeTask). An existing or uncreatable ref throws — the fatal path's text.
|
|
38
|
+
*/
|
|
36
39
|
export async function ensureIntegration(repo, branch, baseRef) {
|
|
37
40
|
branch = await resolveIntegrationBranch(repo, branch);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
// ponytail: no --create-reflog (ANL-023 §7.2 names it): the repository's own core.logAllRefUpdates decides, because
|
|
42
|
+
// nothing reads this reflog (X10) and run-end-integration's E-P10 builds its no-reflog legacy branch through a fresh
|
|
43
|
+
// run with logging disabled. Add the flag back once that fixture builds its legacy branch by hand.
|
|
44
|
+
const r = await shGit(`git update-ref --no-deref -m ${shq("tickmarkr: run start")} ${shq(`refs/heads/${branch}`)} ${shq(baseRef)} ""`, repo);
|
|
45
|
+
if (r.code !== 0)
|
|
46
|
+
throw new Error(`integration branch ${branch} already exists or cannot be created: ${(r.stderr || r.stdout).trim()}`);
|
|
47
|
+
return baseRef;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* R(ref): the integration ref read from `repo` through the own-git runner, never its reflog. The exact ref only —
|
|
51
|
+
* a descendant ref (`refs/heads/<branch>/x`) is not this ref, and a ref that exists but names no commit is unreadable.
|
|
52
|
+
*/
|
|
53
|
+
export async function readIntegrationRef(repo, branch) {
|
|
54
|
+
const ref = `refs/heads/${branch}`;
|
|
55
|
+
const unreadable = (cmd, r) => ({ kind: "unreadable", read: `${cmd} exited ${r.code}${(r.stderr || r.stdout).trim() ? `: ${(r.stderr || r.stdout).trim()}` : ""}` });
|
|
56
|
+
const symbolic = await shGit(`git symbolic-ref -q ${shq(ref)}`, repo);
|
|
57
|
+
if (symbolic.code !== 0 && symbolic.code !== 1)
|
|
58
|
+
return unreadable("git symbolic-ref", symbolic);
|
|
59
|
+
const commit = await shGit(`git rev-parse --verify -q ${shq(`${ref}^{commit}`)}`, repo);
|
|
60
|
+
if (commit.code !== 0 && commit.code !== 1)
|
|
61
|
+
return unreadable("git rev-parse", commit);
|
|
62
|
+
const found = commit.code === 0 ? commit.stdout.trim() : undefined;
|
|
63
|
+
if (symbolic.code === 0)
|
|
64
|
+
return { kind: "symbolic", target: symbolic.stdout.trim(), ...(found ? { commit: found } : {}) };
|
|
65
|
+
if (found)
|
|
66
|
+
return { kind: "regular", commit: found };
|
|
67
|
+
const exists = await shGit(`git show-ref --verify -q ${shq(ref)}`, repo);
|
|
68
|
+
return exists.code === 1 ? { kind: "missing" } : unreadable("git show-ref", exists);
|
|
50
69
|
}
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
/** I(x): holds iff the ref is regular at x; otherwise the leg, with the state the repair text is chosen by. */
|
|
71
|
+
export async function checkIntegrationIdentity(repo, branch, expected) {
|
|
72
|
+
const state = await readIntegrationRef(repo, branch);
|
|
73
|
+
if (state.kind === "regular" && state.commit === expected)
|
|
74
|
+
return { ok: true, state };
|
|
75
|
+
return { ok: false, leg: state.kind === "regular" ? "value" : state.kind, state };
|
|
53
76
|
}
|
|
54
|
-
|
|
55
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The repair text (shared by run end's failure row, the merge park and the resume refusal): backticked commands run
|
|
79
|
+
* from the repository root in order, stopping at the first that fails. A found commit is preserved create-only under
|
|
80
|
+
* refs/tickmarkr/found/<runId>/<F> before a no-deref compare-and-swap restores the ref; a symbolic found ref or one
|
|
81
|
+
* already holding another commit stops the sequence before the restore. Dangling and unreadable print no command.
|
|
82
|
+
*/
|
|
83
|
+
export function integrationRepairText(state, branch, runId, expected) {
|
|
84
|
+
const ref = `refs/heads/${branch}`;
|
|
85
|
+
const byHand = `inspect ${ref} and restore it to ${expected} by hand`;
|
|
86
|
+
const found = state.kind === "regular" ? state.commit : state.kind === "symbolic" ? state.commit : undefined;
|
|
87
|
+
if (found) {
|
|
88
|
+
const keep = `refs/tickmarkr/found/${runId}/${found}`;
|
|
89
|
+
return `\`test -z "$(git symbolic-ref -q ${keep})"\` then \`test "$(git rev-parse -q --verify ${keep})" = ${found} || git update-ref --no-deref ${keep} ${found} ""\` then \`git update-ref --no-deref ${ref} ${expected} ${found}\``;
|
|
90
|
+
}
|
|
91
|
+
if (state.kind === "missing")
|
|
92
|
+
return `\`git update-ref --no-deref ${ref} ${expected} ""\``;
|
|
93
|
+
if (state.kind === "symbolic")
|
|
94
|
+
return `symbolic to ${state.target}, which names no commit; ${byHand}`;
|
|
95
|
+
return state.kind === "unreadable" ? `${ref} is unreadable (${state.read}); ${byHand}` : byHand;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 2.8.1 T2 (ANL-023 §7.2): publishes `gated` onto the integration ref by compare-and-swap from the stored tip
|
|
99
|
+
* `expected`, all git from `repo` — no checkout. (1) the task-tip check; (2) I(expected); (3) ancestry; (4) merge-tree;
|
|
100
|
+
* (5) commit-tree; (6) `update-ref --no-deref` from `expected`; (7) `onPublished(M)` in the same synchronous
|
|
101
|
+
* continuation as (6)'s result, so the caller's rows land in publication order. `merged` is the task's recorded merge
|
|
102
|
+
* commits, newest first: a gated commit already in the history is done only through its own recorded merge.
|
|
103
|
+
* `ancestorOnly` (a replay that ran no gates): a gated commit not in E is refused `infra`, never published.
|
|
104
|
+
*/
|
|
105
|
+
export async function mergeTask(repo, { branch, expected, merged = [], ancestorOnly = false }, taskBranch, message, gated, onPublished, seam) {
|
|
106
|
+
const tip = await shGit(`git rev-parse --verify ${shq(`refs/heads/${taskBranch}`)}`, repo);
|
|
56
107
|
if (tip.code !== 0)
|
|
57
108
|
return { ok: false, conflict: tip.stderr || tip.stdout };
|
|
58
109
|
const branchTip = tip.stdout.trim();
|
|
59
|
-
if (branchTip !==
|
|
60
|
-
return { ok: false, tipMoved: { gatedCommit, branchTip } };
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
110
|
+
if (branchTip !== gated)
|
|
111
|
+
return { ok: false, tipMoved: { gatedCommit: gated, branchTip } };
|
|
112
|
+
const moved = (leg, state, stage) => ({
|
|
113
|
+
ok: false, state, integrationMoved: { branch, expected, leg, stage,
|
|
114
|
+
found: (state.kind === "regular" || state.kind === "symbolic" ? state.commit : undefined) ?? null,
|
|
115
|
+
...(state.kind === "symbolic" ? { target: state.target } : {}) },
|
|
116
|
+
});
|
|
117
|
+
const infra = (step, r) => ({ ok: false, infra: { step, message: (r.stderr || r.stdout).trim() || `exit ${r.code}` } });
|
|
118
|
+
const run = async (step, cmd) => (await seam?.(step)) || shGit(cmd, repo);
|
|
119
|
+
const held = await checkIntegrationIdentity(repo, branch, expected);
|
|
120
|
+
if (!held.ok)
|
|
121
|
+
return moved(held.leg, held.state, "check");
|
|
122
|
+
const ancestry = await run("ancestry", `git merge-base --is-ancestor ${shq(gated)} ${shq(expected)}`);
|
|
123
|
+
if (ancestry.code === 0) {
|
|
124
|
+
// Ancestor or equal tip: only the task's own recorded merge (second parent = gated) makes it done. A resume replay
|
|
125
|
+
// hands the task's own gated commit here, never a tip recreated on E (runDaemon's resume gate replay).
|
|
126
|
+
for (const commit of merged) {
|
|
127
|
+
const second = await shGit(`git rev-parse --verify -q ${shq(`${commit}^2`)}`, repo);
|
|
128
|
+
if (second.code !== 0)
|
|
129
|
+
break; // a failed read (no second parent, no such object) is never passed over: fail closed
|
|
130
|
+
if (second.stdout.trim() === gated)
|
|
131
|
+
return { ok: true, alreadyMerged: commit };
|
|
132
|
+
}
|
|
133
|
+
return { ok: false, alreadyIntegrated: { gated, tip: expected } };
|
|
134
|
+
}
|
|
135
|
+
if (ancestry.code !== 1)
|
|
136
|
+
return infra("ancestry", ancestry);
|
|
137
|
+
// A caller that skipped the gates on its own ancestry proof (a resume replay) never reaches construction.
|
|
138
|
+
if (ancestorOnly)
|
|
139
|
+
return infra("ancestry", { code: 1, stdout: "", stderr: `${gated} is not in ${expected}; an ungated replay publishes nothing` });
|
|
140
|
+
const tree = await run("merge-tree", `git merge-tree --write-tree ${shq(expected)} ${shq(gated)}`);
|
|
141
|
+
if (tree.code === 1)
|
|
142
|
+
return { ok: false, conflict: (tree.stdout || tree.stderr).trim() };
|
|
143
|
+
if (tree.code !== 0)
|
|
144
|
+
return infra("merge-tree", tree);
|
|
145
|
+
const made = await run("commit-tree", `git commit-tree ${shq(tree.stdout.split("\n")[0].trim())} -p ${shq(expected)} -p ${shq(gated)} -m ${shq(message)}`);
|
|
146
|
+
if (made.code !== 0)
|
|
147
|
+
return infra("commit-tree", made);
|
|
148
|
+
const commit = made.stdout.trim();
|
|
149
|
+
await seam?.("publish");
|
|
150
|
+
const published = await shGit(`git update-ref --no-deref -m ${shq(message)} ${shq(`refs/heads/${branch}`)} ${shq(commit)} ${shq(expected)}`, repo);
|
|
151
|
+
if (published.code === 0) {
|
|
152
|
+
onPublished(commit); // no await between the compare-and-swap's result and the caller's rows
|
|
153
|
+
return { ok: true, commit, from: expected };
|
|
154
|
+
}
|
|
155
|
+
const state = await readIntegrationRef(repo, branch);
|
|
156
|
+
if (state.kind === "regular" && state.commit === expected)
|
|
157
|
+
return infra("publish", published);
|
|
158
|
+
return moved(state.kind === "regular" ? "value" : state.kind, state, "publish");
|
|
71
159
|
}
|
|
72
160
|
// OBS-34 ruled strict exit-code verify; Q121s (TRIAL T-OBS-1) narrows it: a red whose failure
|
|
73
161
|
// fingerprints are ALL recorded in the run's baseline is forgiven with the battery's own math
|
|
@@ -75,7 +163,7 @@ export async function mergeTask(intWt, taskBranch, message, gatedCommit) {
|
|
|
75
163
|
// terminus unreachable by construction and misattributed the red to the last merged task.
|
|
76
164
|
// Fail-closed edges kept: no baseline → strict; baseline green for that gate → strict; any fresh
|
|
77
165
|
// fingerprint, or output with no recognizable failure shape, → failed.
|
|
78
|
-
export async function verifyIntegrationTip(intWt, commands, runDir, baseline, evidenceOptions = {}) {
|
|
166
|
+
export async function verifyIntegrationTip(intWt, commands, runDir, baseline, evidenceOptions = {}, { deferVerdicts = false } = {}) {
|
|
79
167
|
const results = [];
|
|
80
168
|
let runStartCommands;
|
|
81
169
|
let journalFound = false;
|
|
@@ -414,7 +502,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
|
|
|
414
502
|
}
|
|
415
503
|
const id = pending.get(result.gate);
|
|
416
504
|
if (id && id.tree === completedTree && !isInfraResult(result)) {
|
|
417
|
-
|
|
505
|
+
const verdict = { ...result, meta: {
|
|
418
506
|
fingerprints: result.fingerprints, forgiven: result.forgiven, baselineProvenance: result.baselineProvenance,
|
|
419
507
|
...(result.forgiven && result.gate === "test" ? { killTextRead: true } : {}),
|
|
420
508
|
reportPath: result.reportPath, spawnedCommand: result.spawnedCommand,
|
|
@@ -422,7 +510,12 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
|
|
|
422
510
|
evidenceReceipt: result.evidenceReceipt, evidenceReceipts: result.evidenceReceipts,
|
|
423
511
|
evidenceAbsence: result.evidenceAbsence, originRunRoot: result.originRunRoot,
|
|
424
512
|
nonce: result.nonce, stdoutPath: result.stdoutPath, stderrPath: result.stderrPath,
|
|
425
|
-
} }
|
|
513
|
+
} };
|
|
514
|
+
// 2.8.1 T1: a deferring caller (run end) writes the verdict itself, only after its closing identity check.
|
|
515
|
+
if (deferVerdicts)
|
|
516
|
+
result.deferredVerdict = { id, verdict };
|
|
517
|
+
else
|
|
518
|
+
store.set(id, verdict);
|
|
426
519
|
}
|
|
427
520
|
}
|
|
428
521
|
return results;
|
package/package.json
CHANGED
|
@@ -503,6 +503,14 @@ journal tail to decide what happens next, or sweeping orphans — you have taken
|
|
|
503
503
|
- **the INTEGRATION subject** — the integration-branch commit that worktree was created from, named by
|
|
504
504
|
sha from the journal's `task-dispatch` row or `tickmarkr status`; never "the run", and never the
|
|
505
505
|
branch name alone, which moves while you are reading it;
|
|
506
|
+
- **the operator view of that branch** — a live run has NO integration checkout: the daemon publishes
|
|
507
|
+
each merge onto `tickmarkr/<runId>` by compare-and-swap from the repository root. View it through a
|
|
508
|
+
detached snapshot (`git worktree add --detach <dir> tickmarkr/<runId>`), refreshed by removing it
|
|
509
|
+
(`git worktree remove <dir>`) and re-adding it; check the branch out attached only with the daemon
|
|
510
|
+
stopped (no live `graph.lock`) — an attached checkout taken while the run is live goes stale at the next
|
|
511
|
+
publication, and any write to the ref parks the next merge infra. Run `tickmarkr verify` and `report`
|
|
512
|
+
from the main checkout or a snapshot;
|
|
513
|
+
a legacy attached view from a 2.8.0 run is stale from the first publication;
|
|
506
514
|
- **and, where the fix is a FILE the tree must hold** — a new test, a fixture, a schema — its presence
|
|
507
515
|
at that commit: `git -C <task-worktree> cat-file -e <task-head>:<path>`. A fix that lives only in the
|
|
508
516
|
daemon's `dist` cannot make a worker's missing oracle appear, and a worker red on that missing file is
|