tickmarkr 2.5.5 → 2.5.6
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/README.md +3 -1
- package/dist/cli/commands/approve.js +64 -3
- package/dist/cli/commands/doctor.d.ts +10 -0
- package/dist/cli/commands/fleet.js +4 -0
- package/dist/cli/commands/plan.js +20 -4
- package/dist/cli/commands/verify.js +102 -81
- package/dist/config/config.d.ts +11 -0
- package/dist/config/config.js +21 -12
- package/dist/config/fleet-overlay.js +55 -17
- package/dist/drivers/index.js +2 -1
- package/dist/drivers/orca.d.ts +21 -1
- package/dist/drivers/orca.js +209 -27
- package/dist/gates/baseline.d.ts +14 -3
- package/dist/gates/baseline.js +61 -14
- package/dist/gates/cache.d.ts +14 -13
- package/dist/gates/cache.js +17 -5
- package/dist/gates/llm.d.ts +3 -0
- package/dist/gates/llm.js +11 -0
- package/dist/gates/review.d.ts +28 -3
- package/dist/gates/review.js +106 -14
- package/dist/gates/run-gates.d.ts +6 -1
- package/dist/gates/run-gates.js +299 -101
- package/dist/gates/test-manifest.d.ts +30 -1
- package/dist/gates/test-manifest.js +113 -39
- package/dist/gates/test-reporter.js +14 -6
- package/dist/graph/graph.d.ts +2 -0
- package/dist/graph/graph.js +45 -1
- package/dist/run/consult.js +5 -4
- package/dist/run/daemon.d.ts +2 -0
- package/dist/run/daemon.js +480 -99
- package/dist/run/execution-budget.d.ts +25 -0
- package/dist/run/execution-budget.js +142 -0
- package/dist/run/git.d.ts +40 -0
- package/dist/run/git.js +89 -4
- package/dist/run/journal.d.ts +3 -1
- package/dist/run/journal.js +38 -16
- package/dist/run/lease.d.ts +44 -0
- package/dist/run/lease.js +226 -3
- package/dist/run/recovery.d.ts +8 -0
- package/dist/run/recovery.js +25 -0
- package/dist/run/repair-selection.d.ts +12 -0
- package/dist/run/repair-selection.js +56 -0
- package/dist/run/stall.d.ts +6 -1
- package/dist/run/stall.js +60 -3
- package/dist/tui/ink/fleet-app.d.ts +10 -2
- package/dist/tui/ink/fleet-app.js +33 -15
- package/package.json +2 -2
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ prefer, every example below works with either.
|
|
|
52
52
|
|
|
53
53
|
- **macOS or Linux.** Every command shells out through `bash`, so native Windows is not
|
|
54
54
|
supported — use WSL (untested).
|
|
55
|
-
- Node ≥ 20 and `git` on PATH.
|
|
55
|
+
- Node ≥ 20.3 and `git` on PATH.
|
|
56
56
|
- At least one agent CLI on PATH (`claude`, `codex`, `cursor-agent`, `opencode`, `grok`, `pi`,
|
|
57
57
|
or `kimi`), authenticated through its own login. tickmarkr never handles vendor API keys itself.
|
|
58
58
|
|
|
@@ -476,6 +476,8 @@ accepted contributions are credited via `Co-authored-by:` on the release commit.
|
|
|
476
476
|
|
|
477
477
|
## Documentation
|
|
478
478
|
|
|
479
|
+
- [Experimental execution policy](docs/experimental-execution-policy.md) — bounded recovery, cumulative task time, and repair selection.
|
|
480
|
+
|
|
479
481
|
- **[FLEET.md](https://github.com/alzahrani-khalid/tickmarkr/blob/main/FLEET.md)** — routing modes, steering syntax, tier provenance, and run flags (advanced reference)
|
|
480
482
|
- **[LICENSE](LICENSE)** — MIT license
|
|
481
483
|
- **[CONTRIBUTING.md](CONTRIBUTING.md)** — development setup and contribution guidelines
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { graphDefinitionHash, loadGraph, saveGraph } from "../../graph/graph.js";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { userInfo } from "node:os";
|
|
4
|
+
import { loadConfig } from "../../config/config.js";
|
|
5
|
+
import { integrationBranch } from "../../run/merge.js";
|
|
3
6
|
import { separabilityErrors } from "../../compile/collateral.js";
|
|
4
7
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
5
8
|
import { applyScopeAmendments, engagementComparable, ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
|
|
@@ -85,6 +88,8 @@ export function permittedDecisionVerbs(park) {
|
|
|
85
88
|
}
|
|
86
89
|
if (park.kind === "infra")
|
|
87
90
|
return ["approve", "recheck"];
|
|
91
|
+
if (park.kind === "diff-cap")
|
|
92
|
+
return ["recheck"]; // OBS-1007: a cap trip is re-gated under a raised cap, never re-bought
|
|
88
93
|
return ["approve"];
|
|
89
94
|
}
|
|
90
95
|
/** The release marker this command appends for a verb on a park — the fact a read-back must match. */
|
|
@@ -138,6 +143,21 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
138
143
|
if (status === undefined) {
|
|
139
144
|
throw new Error(`task ${taskId} has no events in run ${runId} — unknown task or never dispatched`);
|
|
140
145
|
}
|
|
146
|
+
// OBS-1022: a task-failed task whose preserved ref (or task branch) carries commits ahead of the
|
|
147
|
+
// integration base owns verified-able work; --recheck re-gates that tree with no worker. Any other
|
|
148
|
+
// verb on a failed task, or a recheck over nothing landed, is refused naming why.
|
|
149
|
+
if (status === "failed" && recheck) {
|
|
150
|
+
const ahead = failedTaskCommitsAhead(cwd, journal, runId, taskId);
|
|
151
|
+
if (ahead.count === 0) {
|
|
152
|
+
throw new Error(`task ${taskId} is failed and ${ahead.ref} carries no commits ahead of ${ahead.base} — nothing to re-gate; use resume --retry-failed to fund a worker`);
|
|
153
|
+
}
|
|
154
|
+
journal.append("task-approved", taskId, {
|
|
155
|
+
by, ...(reason ? { reason } : {}), via: "cli", release: RECHECK_RELEASE,
|
|
156
|
+
recheckedRef: ahead.ref, commitsAhead: ahead.count,
|
|
157
|
+
...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
|
|
158
|
+
});
|
|
159
|
+
return disposition(cwd, runId, "re-dispatch", `re-checking failed ${taskId} in ${runId} — by ${by}; ${ahead.count} commit(s) on ${ahead.ref} ahead of ${ahead.base}; no worker funded`, serialization.contended);
|
|
160
|
+
}
|
|
141
161
|
if (status !== "human") {
|
|
142
162
|
// a silent no-op would be worse than a loud refusal — name the actual status (D-05)
|
|
143
163
|
throw new Error(`task ${taskId} is ${status}, not a parked human gate — refusing (a silent no-op would be worse)`);
|
|
@@ -149,7 +169,7 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
149
169
|
const lastHuman = park === undefined ? undefined : events[park.index];
|
|
150
170
|
const capPark = park?.kind === ATTEMPT_CAP_RELEASE;
|
|
151
171
|
const gateFailPark = park?.kind === "gate-fail";
|
|
152
|
-
const infraPark = park?.kind === "infra";
|
|
172
|
+
const infraPark = park?.kind === "infra" || park?.kind === "diff-cap";
|
|
153
173
|
const failedGate = gateFailPark ? park?.failedGate : undefined;
|
|
154
174
|
if ((park?.kind === "scope-request" && !decisions) || files !== undefined) {
|
|
155
175
|
if (park?.kind !== "scope-request")
|
|
@@ -205,7 +225,7 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
205
225
|
}
|
|
206
226
|
if (recheck) {
|
|
207
227
|
if ((!gateFailPark || !failedGate) && !infraPark) {
|
|
208
|
-
throw new Error(`--recheck applies to a gate-fail or
|
|
228
|
+
throw new Error(`--recheck applies to a gate-fail, infra or diff-cap park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
|
|
209
229
|
}
|
|
210
230
|
journal.append("task-approved", taskId, {
|
|
211
231
|
by,
|
|
@@ -214,7 +234,7 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
214
234
|
release: RECHECK_RELEASE,
|
|
215
235
|
...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
|
|
216
236
|
});
|
|
217
|
-
return disposition(cwd, runId, "re-dispatch", `re-checking ${taskId} in ${runId} — by ${by}; ${failedGate ? `failed gate ${failedGate}` :
|
|
237
|
+
return disposition(cwd, runId, "re-dispatch", `re-checking ${taskId} in ${runId} — by ${by}; ${failedGate ? `failed gate ${failedGate}` : `${park.kind} park`}; no gate marked satisfied`, serialization.contended);
|
|
218
238
|
}
|
|
219
239
|
if (waive) {
|
|
220
240
|
if (!gateFailPark || !failedGate) {
|
|
@@ -242,6 +262,11 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
242
262
|
if (park?.tombstone) {
|
|
243
263
|
throw new Error(`task ${taskId}'s newest park is a tombstone (${park.reason ?? "no reason"}) — permanent by design; no verb releases it`);
|
|
244
264
|
}
|
|
265
|
+
// OBS-1007: a cap trip is re-gated under a raised cap, never re-bought — plain approve would fund a
|
|
266
|
+
// worker the production oracle (permittedDecisionVerbs) says this park cannot buy.
|
|
267
|
+
if (park?.kind === "diff-cap") {
|
|
268
|
+
throw new Error(`task ${taskId} is parked on a diff cap; plain approve would fund another worker — raise gates.diffCap and pass --recheck (disposition re-dispatch)`);
|
|
269
|
+
}
|
|
245
270
|
journal.append("task-approved", taskId, {
|
|
246
271
|
by,
|
|
247
272
|
...(reason ? { reason } : {}),
|
|
@@ -256,6 +281,42 @@ export async function approve(argv, cwd = process.cwd()) {
|
|
|
256
281
|
serialization.release();
|
|
257
282
|
}
|
|
258
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* OBS-1022: what a failed task has landed. A preserved snapshot is authoritative only when it contains
|
|
286
|
+
* the current task-branch tip; an older preservation can remain in the journal after a later retry has
|
|
287
|
+
* advanced/rebuilt the branch. Missing task branches may still recover from a preserved ref. The selected
|
|
288
|
+
* ref is counted against the run's integration branch; unreadable refs count as zero and fail closed.
|
|
289
|
+
*/
|
|
290
|
+
function failedTaskCommitsAhead(cwd, journal, runId, taskId) {
|
|
291
|
+
const cfg = loadConfig(cwd);
|
|
292
|
+
const base = integrationBranch(cfg, runId);
|
|
293
|
+
const taskRef = `${base}--${taskId}`;
|
|
294
|
+
const preserved = [...journal.read()].reverse().find((e) => e.taskId === taskId && e.event === "worktree-preserved" && typeof e.data.ref === "string");
|
|
295
|
+
const preservedRef = preserved?.data.ref;
|
|
296
|
+
let ref = taskRef;
|
|
297
|
+
if (preservedRef) {
|
|
298
|
+
try {
|
|
299
|
+
execFileSync("git", ["rev-parse", "--verify", taskRef], { cwd, stdio: "ignore" });
|
|
300
|
+
execFileSync("git", ["merge-base", "--is-ancestor", taskRef, preservedRef], { cwd, stdio: "ignore" });
|
|
301
|
+
ref = preservedRef;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
try {
|
|
305
|
+
execFileSync("git", ["rev-parse", "--verify", taskRef], { cwd, stdio: "ignore" });
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
ref = preservedRef;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const out = execFileSync("git", ["rev-list", "--count", `${base}..${ref}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
314
|
+
return { ref, base, count: Number.parseInt(out.trim(), 10) || 0 };
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return { ref, base, count: 0 };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
259
320
|
// The status is command OUTPUT, not a typed sibling result: the registered approve function returns
|
|
260
321
|
// one primitive string and the dispatcher prints those bytes unchanged. A compact sentinel followed
|
|
261
322
|
// by JSON makes the contract unambiguous to machines without widening the shared command result type.
|
|
@@ -203,6 +203,11 @@ export declare function doctorProbePreflight(cwd?: string, cfg?: {
|
|
|
203
203
|
worker: "interactive" | "print";
|
|
204
204
|
workersPerTab: number;
|
|
205
205
|
};
|
|
206
|
+
executionPolicy?: {
|
|
207
|
+
boundedInfrastructure: boolean;
|
|
208
|
+
repairSelection: boolean;
|
|
209
|
+
taskExecutionLimitMs: number;
|
|
210
|
+
} | undefined;
|
|
206
211
|
setup?: string | undefined;
|
|
207
212
|
cost?: {
|
|
208
213
|
models?: Record<string, {
|
|
@@ -322,6 +327,11 @@ export declare function cachedDoctorDiagnostics(cwd?: string, adapters?: WorkerA
|
|
|
322
327
|
worker: "interactive" | "print";
|
|
323
328
|
workersPerTab: number;
|
|
324
329
|
};
|
|
330
|
+
executionPolicy?: {
|
|
331
|
+
boundedInfrastructure: boolean;
|
|
332
|
+
repairSelection: boolean;
|
|
333
|
+
taskExecutionLimitMs: number;
|
|
334
|
+
} | undefined;
|
|
325
335
|
setup?: string | undefined;
|
|
326
336
|
cost?: {
|
|
327
337
|
models?: Record<string, {
|
|
@@ -324,6 +324,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
324
324
|
...structuredClone(initial),
|
|
325
325
|
denyAdapters: deny.adapters,
|
|
326
326
|
denyModels: deny.models,
|
|
327
|
+
// OBS-1046: the allow complement is staged beside the deny lists, never folded into them
|
|
328
|
+
allowOut: deny.allowOut ?? initial.allowOut,
|
|
327
329
|
denyWorkersAdapters: deny.workersAdapters,
|
|
328
330
|
denyWorkersModels: deny.workersModels,
|
|
329
331
|
map,
|
|
@@ -644,6 +646,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
644
646
|
const staged = structuredClone(initial);
|
|
645
647
|
staged.denyAdapters = state.denyAdapters;
|
|
646
648
|
staged.denyModels = state.denyModels;
|
|
649
|
+
staged.allowOut = state.allowOut ?? initial.allowOut;
|
|
647
650
|
// OBS-994/FL-1: the workers-only deny scope rides the same review/write funnel as the flat one.
|
|
648
651
|
staged.denyWorkersAdapters = state.denyWorkersAdapters;
|
|
649
652
|
staged.denyWorkersModels = state.denyWorkersModels;
|
|
@@ -699,6 +702,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
699
702
|
health,
|
|
700
703
|
initialDenyAdapters: editable.denyAdapters,
|
|
701
704
|
initialDenyModels: editable.denyModels,
|
|
705
|
+
initialAllowOut: editable.allowOut,
|
|
702
706
|
initialDenyWorkersAdapters: editable.denyWorkersAdapters,
|
|
703
707
|
initialDenyWorkersModels: editable.denyWorkersModels,
|
|
704
708
|
modelGroups,
|
|
@@ -7,7 +7,7 @@ import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
|
|
|
7
7
|
import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
|
|
8
8
|
import { classifyContextPath } from "../../compile/native.js";
|
|
9
9
|
import { DEFAULT_CONFIG, effectiveReviewPolicy, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
|
|
10
|
-
import { graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
|
|
10
|
+
import { chainDepth, dispatchWaves, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
|
|
11
11
|
import { renderAcceptanceItem } from "../../graph/schema.js";
|
|
12
12
|
import { resolveRunMode } from "../../run/daemon.js";
|
|
13
13
|
import { disallowedBy, excludedChannels, exclusionLine, routingEntrySeatLines } from "../../route/preference.js";
|
|
@@ -364,11 +364,27 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
364
364
|
: "";
|
|
365
365
|
return { line: `${channelKey(reviewer)}${rotationNote} — reviewPolicy full`, cost: seatCost(reviewer) };
|
|
366
366
|
};
|
|
367
|
+
// OBS-1018: depth and wave come from the same functions the daemon admits by, so the plan shows the
|
|
368
|
+
// critical path the run will actually follow at this concurrency.
|
|
369
|
+
const depths = chainDepth(g);
|
|
370
|
+
const waves = dispatchWaves(g, cfg.concurrency);
|
|
371
|
+
const byId = new Map(g.tasks.map((t) => [t.id, t]));
|
|
372
|
+
const chainLine = (t) => {
|
|
373
|
+
const wave = waves.get(t.id);
|
|
374
|
+
// Map absence never implies status: a pending task stays wave-less when a dependency is
|
|
375
|
+
// running/gated/failed/human, so render the real status and name the blocking deps.
|
|
376
|
+
const tail = wave !== undefined
|
|
377
|
+
? `wave ${wave} at concurrency ${cfg.concurrency}`
|
|
378
|
+
: t.status !== "pending"
|
|
379
|
+
? `no wave (${t.status})`
|
|
380
|
+
: `no wave (pending; blocked by ${t.deps.filter((d) => byId.get(d)?.status !== "done").map((d) => `${d}:${byId.get(d)?.status ?? "missing"}`).join(", ")})`;
|
|
381
|
+
return ` chain: depth ${depths.get(t.id)} · ${tail}`;
|
|
382
|
+
};
|
|
367
383
|
const routed = [];
|
|
368
384
|
for (const t of g.tasks) {
|
|
369
385
|
const refusals = [...(oracleRefusals.get(t.id) ?? []), ...(inputRefusals.get(t.id) ?? [])];
|
|
370
386
|
if (refusals.length) {
|
|
371
|
-
lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! pre-dispatch refusal — ${refusals.join("; ")}
|
|
387
|
+
lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! pre-dispatch refusal — ${refusals.join("; ")}`, chainLine(t));
|
|
372
388
|
continue;
|
|
373
389
|
}
|
|
374
390
|
try {
|
|
@@ -382,7 +398,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
382
398
|
const judge = judgeSeat(t);
|
|
383
399
|
const review = reviewSeat(t, r.assignment);
|
|
384
400
|
cost += est + judge.cost + review.cost;
|
|
385
|
-
lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`, ` judge: ${judge.line}${judge.cost ? ` ~$${judge.cost.toFixed(2)}` : ""}`, ` review: ${review.line}${review.cost ? ` ~$${review.cost.toFixed(2)}` : ""}
|
|
401
|
+
lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`, ` judge: ${judge.line}${judge.cost ? ` ~$${judge.cost.toFixed(2)}` : ""}`, ` review: ${review.line}${review.cost ? ` ~$${review.cost.toFixed(2)}` : ""}`, chainLine(t));
|
|
386
402
|
const d = derivation(t.shape);
|
|
387
403
|
if (d)
|
|
388
404
|
lines.push(d);
|
|
@@ -406,7 +422,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
406
422
|
if (!(e instanceof RoutingError))
|
|
407
423
|
throw e;
|
|
408
424
|
const msg = `${e.message}${exclusionReason(e.message)}`;
|
|
409
|
-
lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! ${msg}
|
|
425
|
+
lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! ${msg}`, chainLine(t));
|
|
410
426
|
const d = derivation(t.shape);
|
|
411
427
|
if (d)
|
|
412
428
|
lines.push(d);
|
|
@@ -6,13 +6,15 @@ import { parseArgs } from "node:util";
|
|
|
6
6
|
import { allAdapters, probeAll, readDoctor, rolePools } from "../../adapters/registry.js";
|
|
7
7
|
import { channelKey } from "../../adapters/types.js";
|
|
8
8
|
import { loadConfig } from "../../config/config.js";
|
|
9
|
-
import { captureBaseline, detectGateCommands } from "../../gates/baseline.js";
|
|
9
|
+
import { captureBaseline, detectGateCommands, staleFileCountCommands } from "../../gates/baseline.js";
|
|
10
10
|
import { modelProvider } from "../../gates/review.js";
|
|
11
11
|
import { runGates } from "../../gates/run-gates.js";
|
|
12
12
|
import { getTask, loadGraph } from "../../graph/graph.js";
|
|
13
13
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
14
|
+
import { executionSignal } from "../../run/execution-budget.js";
|
|
14
15
|
import { linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.js";
|
|
15
16
|
import { Journal } from "../../run/journal.js";
|
|
17
|
+
import { withRepositoryLease } from "../../run/lease.js";
|
|
16
18
|
/**
|
|
17
19
|
* tickmarkr verify — the gate battery as a standalone command (OPERATING-MODEL-2026-08-11 item 3).
|
|
18
20
|
*
|
|
@@ -249,95 +251,114 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
249
251
|
// ponytail: tmpdir means the baseline cache dies on reboot/cleanup — worst case is one re-capture.
|
|
250
252
|
const stateDir = verifyStateDir(cwd);
|
|
251
253
|
mkdirSync(stateDir, { recursive: true });
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
if (cached) {
|
|
271
|
-
console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
|
|
272
|
-
baseline = cached;
|
|
254
|
+
const recordJournal = values.record ? Journal.open(stateRoot, values.record) : undefined;
|
|
255
|
+
// OBS-1042: one runner root per repository across every linked worktree — the capture and the
|
|
256
|
+
// battery below run under a reservation keyed on the common git dir, and a waiter says who holds it.
|
|
257
|
+
return withRepositoryLease(cwd, () => leasedVerify(), {
|
|
258
|
+
signal: executionSignal(),
|
|
259
|
+
onWait: (holder) => {
|
|
260
|
+
console.error(`verify: waiting for the repository's runner lease held by pid ${holder.pid} in ${holder.cwd}`);
|
|
261
|
+
recordJournal?.append("suite-wait", values.task ?? "VERIFY", { holderPid: holder.pid, holderCwd: holder.cwd });
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
async function leasedVerify() {
|
|
265
|
+
let baseline;
|
|
266
|
+
const cachePath = baselineCachePath(cwd, mergeBase, commands);
|
|
267
|
+
const verdictlessMarker = `${cachePath}.verdictless`;
|
|
268
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
269
|
+
if (values.baseline) {
|
|
270
|
+
baseline = JSON.parse(readFileSync(values.baseline, "utf8"));
|
|
273
271
|
}
|
|
274
272
|
else {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
|
|
280
|
-
}
|
|
281
|
-
const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
|
|
282
|
-
await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
|
|
283
|
-
await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
|
|
284
|
-
try {
|
|
285
|
-
linkNodeModules(cwd, baseDir, { force: true });
|
|
286
|
-
baseline = await captureBaseline(baseDir, commands);
|
|
287
|
-
const missing = verdictlessCommands(baseline, commands);
|
|
273
|
+
let cached;
|
|
274
|
+
if (existsSync(cachePath)) {
|
|
275
|
+
cached = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
276
|
+
const missing = verdictlessCommands(cached, commands);
|
|
288
277
|
if (missing.length) {
|
|
289
|
-
|
|
278
|
+
console.error(`verify: cached baseline recorded no verdict for ${missing.join(", ")}; it was not reusable and will be recaptured`);
|
|
290
279
|
rmSync(cachePath, { force: true });
|
|
280
|
+
cached = undefined;
|
|
291
281
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
282
|
+
// OBS-1044: a cached vitest count that is a stdout sum may be inflated by nested runner echo;
|
|
283
|
+
// applying it would manufacture a deficit. Only a manifest-derived count is a floor.
|
|
284
|
+
const stale = cached ? staleFileCountCommands(cached, commands, cwd) : [];
|
|
285
|
+
if (stale.length) {
|
|
286
|
+
console.error(`verify: cached baseline's file count for ${stale.join(", ")} is not manifest-derived; it will be recaptured`);
|
|
287
|
+
rmSync(cachePath, { force: true });
|
|
288
|
+
cached = undefined;
|
|
295
289
|
}
|
|
296
290
|
}
|
|
297
|
-
|
|
298
|
-
|
|
291
|
+
if (cached) {
|
|
292
|
+
console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
|
|
293
|
+
baseline = cached;
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
if (existsSync(verdictlessMarker)) {
|
|
297
|
+
console.error(`verify: prior baseline recorded no verdict and was not cached; recapturing merge-base ${mergeBase.slice(0, 12)}`);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
|
|
301
|
+
}
|
|
302
|
+
const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
|
|
303
|
+
await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
|
|
304
|
+
await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
|
|
305
|
+
try {
|
|
306
|
+
linkNodeModules(cwd, baseDir, { force: true });
|
|
307
|
+
baseline = await captureBaseline(baseDir, commands);
|
|
308
|
+
const missing = verdictlessCommands(baseline, commands);
|
|
309
|
+
if (missing.length) {
|
|
310
|
+
writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
|
|
311
|
+
rmSync(cachePath, { force: true });
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
|
|
315
|
+
rmSync(verdictlessMarker, { force: true });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
await removeWorktree(cwd, baseDir);
|
|
320
|
+
}
|
|
299
321
|
}
|
|
300
322
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
|
|
317
|
-
},
|
|
318
|
-
});
|
|
319
|
-
const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
|
|
320
|
-
const reviewRows = results.filter((result) => result.gate === "review");
|
|
321
|
-
const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
|
|
322
|
-
const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
|
|
323
|
-
return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
|
|
324
|
-
}));
|
|
325
|
-
const artifactPath = join(artifactDir, "verify-results.json");
|
|
326
|
-
writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
|
|
327
|
-
console.error(`verify: artifacts written to ${artifactPath}`);
|
|
328
|
-
const review = reviewRows.at(-1);
|
|
329
|
-
if (recordJournal && review) {
|
|
330
|
-
recordJournal.append("review-leg2", values.task ?? "VERIFY", {
|
|
331
|
-
base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
|
|
332
|
-
...review,
|
|
323
|
+
const artifactDir = join(stateDir, new Date().toISOString().replace(/[:.]/g, "-"));
|
|
324
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
325
|
+
const { results } = await runGates(task, {
|
|
326
|
+
worktree: cwd, baseRef: mergeBase,
|
|
327
|
+
result: { ok: true, summary: "standalone verify — no worker claims to trust", deviations: [], raw: "" },
|
|
328
|
+
author, commands, baseline, channels, ...(judgeChannels ? { judgeChannels } : {}), adapters, cfg,
|
|
329
|
+
verificationScope: "standalone", artifactDir, stateDir: join(stateRoot, ".tickmarkr"),
|
|
330
|
+
onGate: (e) => {
|
|
331
|
+
if (e.phase === "start")
|
|
332
|
+
console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
|
|
333
|
+
else if (e.phase === "note")
|
|
334
|
+
console.error(`verify: note ${e.gate} ${e.name} ${JSON.stringify(e.payload)}`);
|
|
335
|
+
else
|
|
336
|
+
console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
|
|
337
|
+
},
|
|
333
338
|
});
|
|
339
|
+
const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
|
|
340
|
+
const reviewRows = results.filter((result) => result.gate === "review");
|
|
341
|
+
const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
|
|
342
|
+
const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
|
|
343
|
+
return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
|
|
344
|
+
}));
|
|
345
|
+
const artifactPath = join(artifactDir, "verify-results.json");
|
|
346
|
+
writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
|
|
347
|
+
console.error(`verify: artifacts written to ${artifactPath}`);
|
|
348
|
+
const review = reviewRows.at(-1);
|
|
349
|
+
if (recordJournal && review) {
|
|
350
|
+
recordJournal.append("review-leg2", values.task ?? "VERIFY", {
|
|
351
|
+
base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
|
|
352
|
+
...review,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
if (values.json) {
|
|
356
|
+
return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
|
|
357
|
+
}
|
|
358
|
+
const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
|
|
359
|
+
const verdict = green
|
|
360
|
+
? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
|
|
361
|
+
: `verify RED — first failure decides; artifacts: ${artifactPath}`;
|
|
362
|
+
return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
|
|
334
363
|
}
|
|
335
|
-
if (values.json) {
|
|
336
|
-
return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
|
|
337
|
-
}
|
|
338
|
-
const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
|
|
339
|
-
const verdict = green
|
|
340
|
-
? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
|
|
341
|
-
: `verify RED — first failure decides; artifacts: ${artifactPath}`;
|
|
342
|
-
return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
|
|
343
364
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -132,7 +132,17 @@ export declare function effectiveReviewPolicy(files: ReadonlyArray<string>, revi
|
|
|
132
132
|
* under-flagged one skips the only gate that exists for it.
|
|
133
133
|
*/
|
|
134
134
|
export declare function criticalPathHits(files: ReadonlyArray<string>, criticalPaths?: ReadonlyArray<string>): string[];
|
|
135
|
+
export declare const ExecutionPolicySchema: z.ZodObject<{
|
|
136
|
+
boundedInfrastructure: z.ZodDefault<z.ZodBoolean>;
|
|
137
|
+
repairSelection: z.ZodDefault<z.ZodBoolean>;
|
|
138
|
+
taskExecutionLimitMs: z.ZodNumber;
|
|
139
|
+
}, z.core.$strict>;
|
|
135
140
|
export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
141
|
+
executionPolicy: z.ZodOptional<z.ZodObject<{
|
|
142
|
+
boundedInfrastructure: z.ZodDefault<z.ZodBoolean>;
|
|
143
|
+
repairSelection: z.ZodDefault<z.ZodBoolean>;
|
|
144
|
+
taskExecutionLimitMs: z.ZodNumber;
|
|
145
|
+
}, z.core.$strict>>;
|
|
136
146
|
concurrency: z.ZodNumber;
|
|
137
147
|
driver: z.ZodEnum<{
|
|
138
148
|
auto: "auto";
|
|
@@ -366,6 +376,7 @@ export type FleetEditable = {
|
|
|
366
376
|
denyModels: string[];
|
|
367
377
|
denyWorkersAdapters?: string[];
|
|
368
378
|
denyWorkersModels?: string[];
|
|
379
|
+
allowOut?: string[];
|
|
369
380
|
tiers: Record<string, Record<string, FleetTierAssignment | null>>;
|
|
370
381
|
map: Record<string, MapEntry>;
|
|
371
382
|
floors: Record<string, Tier>;
|
package/dist/config/config.js
CHANGED
|
@@ -264,7 +264,14 @@ const ShapeGateParticipationSchema = z
|
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
266
|
});
|
|
267
|
+
export const ExecutionPolicySchema = z.object({
|
|
268
|
+
boundedInfrastructure: z.boolean().default(false),
|
|
269
|
+
repairSelection: z.boolean().default(false),
|
|
270
|
+
taskExecutionLimitMs: z.number().int().positive(),
|
|
271
|
+
}).strict();
|
|
267
272
|
export const TickmarkrConfigSchema = z.object({
|
|
273
|
+
// Opt-in recovery experiment. The original run snapshot owns this across resumes.
|
|
274
|
+
executionPolicy: ExecutionPolicySchema.optional(),
|
|
268
275
|
concurrency: z.number().int().positive(),
|
|
269
276
|
driver: z.enum(["auto", "herdr", "subprocess", "orca"]),
|
|
270
277
|
integrationBranchPrefix: z
|
|
@@ -902,12 +909,13 @@ export function universeEntryMatches(row, model, entry) {
|
|
|
902
909
|
export function universeCovers(universe, entry) {
|
|
903
910
|
return universe.some((row) => entry === row.adapter || row.models.some((m) => entry === m || entry === `${row.adapter}:${m}`));
|
|
904
911
|
}
|
|
905
|
-
// v1.92 fleet membership: with a discovered `universe` (classified models only) the
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
//
|
|
912
|
+
// v1.92 fleet membership: with a discovered `universe` (classified models only) the routing.allow
|
|
913
|
+
// complement (fail-closed) rides as `allowOut` — adapter ids and adapter:model keys — BESIDE the
|
|
914
|
+
// authored routing.deny adapters/models entries, which ride VERBATIM in their own lists (never
|
|
915
|
+
// workers). LEG2-T3 round 2 finding 2: authored entries are never collapsed into a membership key;
|
|
916
|
+
// OBS-1046: nor is an allow exclusion folded into a deny list — the two reasons stay distinct so
|
|
917
|
+
// one Space press clears one; finding 3: allow admission honours recorded identity.
|
|
918
|
+
// Absent universe ⇒ deny arrays verbatim and no allowOut, so non-UI callers stay byte-identical.
|
|
911
919
|
export function fleetEditableFromConfig(cfg, universe) {
|
|
912
920
|
const tiers = {};
|
|
913
921
|
for (const [adapter, entry] of Object.entries(cfg.tiers)) {
|
|
@@ -916,8 +924,9 @@ export function fleetEditableFromConfig(cfg, universe) {
|
|
|
916
924
|
tiers[adapter][model] = { tier };
|
|
917
925
|
}
|
|
918
926
|
}
|
|
919
|
-
|
|
920
|
-
|
|
927
|
+
const denyAdapters = [...(cfg.routing.deny?.adapters ?? [])].sort();
|
|
928
|
+
const denyModels = [...(cfg.routing.deny?.models ?? [])].sort();
|
|
929
|
+
let allowOut;
|
|
921
930
|
if (universe !== undefined) {
|
|
922
931
|
const { allow } = cfg.routing;
|
|
923
932
|
const allowEntries = allow ? [...(allow.adapters ?? []), ...(allow.models ?? [])] : null;
|
|
@@ -933,14 +942,14 @@ export function fleetEditableFromConfig(cfg, universe) {
|
|
|
933
942
|
modelsOut.push(...out.map((m) => `${row.adapter}:${m}`));
|
|
934
943
|
}
|
|
935
944
|
}
|
|
936
|
-
// OBS-517: authored deny entries — covered by the probe universe or not — ride verbatim,
|
|
937
|
-
// transient probe failure can never un-deny one on the next write (fail-open on the ban list).
|
|
938
|
-
|
|
939
|
-
denyModels = [...new Set([...modelsOut, ...denyModels])].sort();
|
|
945
|
+
// OBS-517: authored deny entries — covered by the probe universe or not — ride verbatim (above),
|
|
946
|
+
// so a transient probe failure can never un-deny one on the next write (fail-open on the ban list).
|
|
947
|
+
allowOut = [...new Set([...adaptersOut, ...modelsOut])].sort();
|
|
940
948
|
}
|
|
941
949
|
return {
|
|
942
950
|
denyAdapters,
|
|
943
951
|
denyModels,
|
|
952
|
+
...(allowOut !== undefined ? { allowOut } : {}),
|
|
944
953
|
// OBS-994: workers-only deny is never folded into the all-seats complement above — it is
|
|
945
954
|
// returned verbatim beside the flat scopes so the fleet browser can show and toggle it.
|
|
946
955
|
denyWorkersAdapters: [...(cfg.routing.deny?.workers?.adapters ?? [])].sort(),
|