tickmarkr 2.5.4 → 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/adapters/registry.js +6 -1
- package/dist/adapters/types.d.ts +3 -0
- package/dist/cli/commands/approve.d.ts +1 -0
- package/dist/cli/commands/approve.js +118 -9
- package/dist/cli/commands/doctor.d.ts +10 -0
- package/dist/cli/commands/doctor.js +44 -29
- package/dist/cli/commands/fleet.js +199 -33
- package/dist/cli/commands/init.js +196 -6
- package/dist/cli/commands/plan.js +20 -4
- package/dist/cli/commands/resume.js +4 -2
- package/dist/cli/commands/run.js +11 -2
- package/dist/cli/commands/verify.js +102 -81
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +3 -1
- package/dist/config/config.d.ts +25 -8
- package/dist/config/config.js +41 -29
- package/dist/config/fleet-overlay.d.ts +3 -9
- package/dist/config/fleet-overlay.js +114 -19
- package/dist/config/fleet-why.d.ts +7 -0
- package/dist/config/fleet-why.js +5 -0
- package/dist/drivers/index.d.ts +15 -1
- package/dist/drivers/index.js +39 -10
- package/dist/drivers/orca.d.ts +119 -10
- package/dist/drivers/orca.js +781 -110
- package/dist/gates/baseline.d.ts +17 -3
- package/dist/gates/baseline.js +63 -15
- package/dist/gates/cache.d.ts +101 -0
- package/dist/gates/cache.js +401 -0
- 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 +9 -1
- package/dist/gates/run-gates.js +407 -102
- package/dist/gates/test-manifest.d.ts +128 -0
- package/dist/gates/test-manifest.js +463 -0
- package/dist/gates/test-reporter.d.ts +4 -0
- package/dist/gates/test-reporter.js +57 -0
- package/dist/graph/graph.d.ts +2 -0
- package/dist/graph/graph.js +45 -1
- package/dist/route/preference.d.ts +22 -1
- package/dist/route/preference.js +123 -25
- package/dist/route/router.js +31 -6
- package/dist/run/consult.js +5 -4
- package/dist/run/daemon.d.ts +15 -0
- package/dist/run/daemon.js +638 -167
- package/dist/run/execution-budget.d.ts +25 -0
- package/dist/run/execution-budget.js +142 -0
- package/dist/run/git.d.ts +50 -1
- package/dist/run/git.js +131 -12
- package/dist/run/journal.d.ts +16 -2
- package/dist/run/journal.js +115 -25
- package/dist/run/lease.d.ts +58 -0
- package/dist/run/lease.js +310 -0
- package/dist/run/merge.d.ts +2 -0
- package/dist/run/merge.js +91 -3
- package/dist/run/operator-state.d.ts +11 -0
- package/dist/run/operator-state.js +17 -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/cockpit/board.d.ts +96 -0
- package/dist/tui/cockpit/board.js +346 -0
- package/dist/tui/cockpit/decision-actions.js +2 -0
- package/dist/tui/cockpit/layout.d.ts +5 -1
- package/dist/tui/cockpit/layout.js +8 -3
- package/dist/tui/cockpit/live-runtime.js +71 -21
- package/dist/tui/cockpit/run-view.d.ts +7 -5
- package/dist/tui/cockpit/run-view.js +12 -11
- package/dist/tui/ink/fleet-app.d.ts +49 -27
- package/dist/tui/ink/fleet-app.js +229 -38
- package/package.json +2 -2
- package/skills/tickmarkr-overseer/SKILL.md +173 -113
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
package/dist/run/journal.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
|
|
6
|
-
import { stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
|
|
7
|
+
import { graphDefinitionHash, stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
|
|
7
8
|
import { GATE_NAMES, TIERS } from "../graph/schema.js";
|
|
8
9
|
import { channelRouteIdentity } from "../route/preference.js";
|
|
9
10
|
import { buildProfile, classify } from "../route/profile.js";
|
|
@@ -60,8 +61,8 @@ export function formatJournalNarration({ event, taskId, data }) {
|
|
|
60
61
|
// replayResumeState(), consumed by the daemon on resume to re-seed demotedChannels.
|
|
61
62
|
export const CHANNEL_EXCLUSION_KINDS = ["dead-channel"];
|
|
62
63
|
// v1.24 OBS-18: explicit data on task-approved when the operator releases an attempt-cap park.
|
|
63
|
-
// Approve stamps this; replayResumeState
|
|
64
|
-
//
|
|
64
|
+
// Approve stamps this; replayResumeState resets both the attempt counter and tried seats for a fresh
|
|
65
|
+
// budget. Absent on pre-v1.24 events ⇒ inert (corpus outcome-identical).
|
|
65
66
|
export const ATTEMPT_CAP_RELEASE = "attempt-cap";
|
|
66
67
|
// OBS-130: task-approved carries the exact failed gate the operator satisfied. The release tag keeps
|
|
67
68
|
// ordinary humanGate and attempt-cap approvals byte-compatible while making this authority explicit.
|
|
@@ -74,8 +75,8 @@ export const REVIEW_UPHELD_RELEASE = "review-upheld";
|
|
|
74
75
|
// gate before it — daemon.ts remainingGates), which is wrong when the gate failed against a stale task
|
|
75
76
|
// DECLARATION rather than a bad diff: amend the spec's files[], recompile, and the gate now passes
|
|
76
77
|
// honestly. This release runs the whole gate suite over the parked commit before any worker and
|
|
77
|
-
// marks no gate satisfied: green merges directly, red returns to the ladder
|
|
78
|
-
//
|
|
78
|
+
// marks no gate satisfied: green merges directly, red returns to the existing ladder with attempts
|
|
79
|
+
// and tried seats intact.
|
|
79
80
|
export const RECHECK_RELEASE = "recheck";
|
|
80
81
|
// OBS-738: one authority for every recovery surface. The ref is accepted only from the row that
|
|
81
82
|
// preservation itself writes; task-human prose, branch heads and commit history are deliberately
|
|
@@ -217,9 +218,20 @@ function identifierIn(note) {
|
|
|
217
218
|
// code identity still has one stable identity of its own: its own words, with the volatile tokens
|
|
218
219
|
// swept out so line/path churn cannot mint a new symbol for the same finding. It is the reviewer's
|
|
219
220
|
// own bytes, never a guess, and it can never fuse two different findings into one.
|
|
221
|
+
// OBS-1013 add.3: the words are BOUNDED — the first sentence, or a short hash when even that is long.
|
|
222
|
+
// A whole-note symbol made a 600-byte fingerprint no reviewer could echo, so a real closure read as
|
|
223
|
+
// malformed and bought a worker. A sentence a reviewer can copy is the identity; the hash is the fallback.
|
|
224
|
+
const SYMBOL_SENTENCE_CAP = 160;
|
|
225
|
+
export function boundedSymbol(note) {
|
|
226
|
+
const words = normalizeGateFailure(note);
|
|
227
|
+
if (!words)
|
|
228
|
+
return "";
|
|
229
|
+
const sentence = words.split(/(?<=[.!?])\s+|\n/)[0].trim();
|
|
230
|
+
return sentence.length <= SYMBOL_SENTENCE_CAP ? sentence : createHash("sha256").update(words).digest("hex").slice(0, 12);
|
|
231
|
+
}
|
|
220
232
|
function toFinding(cls, note, path, symbol, rationale) {
|
|
221
233
|
const p = path || UNIDENTIFIED;
|
|
222
|
-
const s = symbol ||
|
|
234
|
+
const s = symbol || boundedSymbol(note) || UNIDENTIFIED;
|
|
223
235
|
return {
|
|
224
236
|
class: cls, path: p, symbol: s, note,
|
|
225
237
|
...(rationale !== undefined ? { rationale } : {}),
|
|
@@ -773,7 +785,7 @@ const DispatchAssignmentSchema = z.object({
|
|
|
773
785
|
// routing profile's QUALITY_FAIL_PARKS (route/profile.ts): the channel did nothing wrong.
|
|
774
786
|
export const PARK_KINDS = ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota",
|
|
775
787
|
"reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch",
|
|
776
|
-
"authoring"];
|
|
788
|
+
"authoring", "scope-request", "diff-cap"];
|
|
777
789
|
// v1.85 T3: "repair" is a third dispatch mode beside the v1.29 session pair — a fix-only attempt that
|
|
778
790
|
// carries the failing findings and the diff CONTENT of the work already landed, instead of re-buying
|
|
779
791
|
// ~20m of onboarding to rediscover them (62 of 68 measured re-dispatches were fresh).
|
|
@@ -971,6 +983,63 @@ export function recordedGraphDefinitionHash(events) {
|
|
|
971
983
|
}
|
|
972
984
|
return recorded ?? undefined;
|
|
973
985
|
}
|
|
986
|
+
/** An approval is the durable authority; graph.json is only its materialized projection. */
|
|
987
|
+
export const ScopeAmendmentSchema = z.object({
|
|
988
|
+
from: z.string(), to: z.string(), beforeFiles: z.array(z.string()), files: z.array(z.string()),
|
|
989
|
+
parkLine: z.number().int().positive(),
|
|
990
|
+
});
|
|
991
|
+
export function replayScopeAmendments(graph, events) {
|
|
992
|
+
const amendments = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
|
|
993
|
+
.map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
|
|
994
|
+
if (!amendments.length)
|
|
995
|
+
return graph;
|
|
996
|
+
let result = graph;
|
|
997
|
+
// Accept any materialized revision, including a fresh compilation of the original definition.
|
|
998
|
+
// Undo only recognized files[] values. Everything else must still hash to the approved definition.
|
|
999
|
+
const taskIds = new Set(amendments.map(({ event }) => event.taskId));
|
|
1000
|
+
for (const id of taskIds) {
|
|
1001
|
+
const history = amendments.filter(({ event }) => event.taskId === id);
|
|
1002
|
+
const task = result.tasks.find((t) => t.id === id);
|
|
1003
|
+
const original = history[0].amendment.beforeFiles;
|
|
1004
|
+
if (!task || ![original, ...history.map(({ amendment }) => amendment.files)]
|
|
1005
|
+
.some((files) => JSON.stringify(files) === JSON.stringify(task.files))) {
|
|
1006
|
+
throw new Error(`refusing scope amendment replay: ${id} files[] changed beyond the approved amendment`);
|
|
1007
|
+
}
|
|
1008
|
+
result = { ...result, tasks: result.tasks.map((t) => t.id === id ? { ...t, files: original } : t) };
|
|
1009
|
+
}
|
|
1010
|
+
for (const { event, amendment } of amendments) {
|
|
1011
|
+
if (graphDefinitionHash(result) !== amendment.from) {
|
|
1012
|
+
throw new Error(`refusing scope amendment replay: graph task definition changed beyond approval for ${event.taskId}`);
|
|
1013
|
+
}
|
|
1014
|
+
result = { ...result, tasks: result.tasks.map((t) => t.id === event.taskId ? { ...t, files: amendment.files } : t) };
|
|
1015
|
+
if (graphDefinitionHash(result) !== amendment.to)
|
|
1016
|
+
throw new Error("refusing invalid scope amendment hash");
|
|
1017
|
+
}
|
|
1018
|
+
return result;
|
|
1019
|
+
}
|
|
1020
|
+
/** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
|
|
1021
|
+
export function applyScopeAmendments(graph, journal, auditReplay = false) {
|
|
1022
|
+
const events = journal.read();
|
|
1023
|
+
const result = replayScopeAmendments(graph, events); // validate all before moving any identity
|
|
1024
|
+
const approvals = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
|
|
1025
|
+
for (const approval of approvals) {
|
|
1026
|
+
const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
|
|
1027
|
+
if (!events.some((e) => e.event === "graph-rehash" && e.data.approval === approval.ts && e.taskId === approval.taskId
|
|
1028
|
+
&& e.data.from === amendment.from && e.data.to === amendment.to)) {
|
|
1029
|
+
journal.append("graph-rehash", approval.taskId, {
|
|
1030
|
+
from: amendment.from, to: amendment.to, approval: approval.ts, by: approval.data.by, source: "approval",
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
if (auditReplay && graphDefinitionHash(graph) !== graphDefinitionHash(result) && approvals.length) {
|
|
1035
|
+
const recorded = recordedGraphDefinitionHash(journal.read());
|
|
1036
|
+
journal.append("graph-rehash", approvals.at(-1).taskId, {
|
|
1037
|
+
from: recorded, to: graphDefinitionHash(result), approval: approvals.at(-1).ts, source: "approval", replay: true,
|
|
1038
|
+
loaded: graphDefinitionHash(graph),
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
return result;
|
|
1042
|
+
}
|
|
974
1043
|
// THE shared comparator (criterion: status and resume decide through one comparator). status reads
|
|
975
1044
|
// .comparable; resume reads .comparable plus .reason/.recorded for its refusal message and the release.
|
|
976
1045
|
export function engagementComparable(events, loadedHash) {
|
|
@@ -1243,13 +1312,24 @@ export class Journal {
|
|
|
1243
1312
|
: { ts: new Date().toISOString(), event, ...(taskId ? { taskId } : {}), data: persistedData };
|
|
1244
1313
|
// T3 secret redaction: only the persisted bytes are masked — the caller's data stays untouched in
|
|
1245
1314
|
// memory. The narrator receives the persisted (masked) row so a pane sink never shows a credential.
|
|
1246
|
-
const
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1315
|
+
const rows = [row];
|
|
1316
|
+
if (event === "task-approved" && row.data.release === "scope-request") {
|
|
1317
|
+
const amendment = ScopeAmendmentSchema.parse(row.data.amendment);
|
|
1318
|
+
// Publish permission and its graph audit in one append. Otherwise a live sweep can read
|
|
1319
|
+
// permission between the two writes, race the CLI's rehash, and audit the revision twice.
|
|
1320
|
+
rows.push({ ts: row.ts, event: "graph-rehash", taskId: row.taskId, data: {
|
|
1321
|
+
from: amendment.from, to: amendment.to, approval: row.ts, by: row.data.by, source: "approval",
|
|
1322
|
+
} });
|
|
1250
1323
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1324
|
+
const lines = rows.map((entry) => redactSecrets(JSON.stringify(entry)));
|
|
1325
|
+
appendFileSync(this.journalPath, lines.join("\n") + "\n");
|
|
1326
|
+
for (const line of lines) {
|
|
1327
|
+
try {
|
|
1328
|
+
this.narrate?.(JSON.parse(line));
|
|
1329
|
+
}
|
|
1330
|
+
catch {
|
|
1331
|
+
// narration is observational; a broken sink must not affect the journal or run
|
|
1332
|
+
}
|
|
1253
1333
|
}
|
|
1254
1334
|
if (evidence && !evidence.written && (event === "judge-retry" || event === "gate-result")) {
|
|
1255
1335
|
evidence.written = true;
|
|
@@ -1337,7 +1417,7 @@ export class Journal {
|
|
|
1337
1417
|
outstanding = parsed.success ? { assignment: parsed.data } : {}; // malformed: closable, unattributable
|
|
1338
1418
|
classified = null;
|
|
1339
1419
|
}
|
|
1340
|
-
else if (e.event === "scope-authoring" && outstanding) {
|
|
1420
|
+
else if ((e.event === "scope-authoring" || e.event === "scope-request") && outstanding) {
|
|
1341
1421
|
classified = outstanding; // a duplicate classification closes nothing more (same rule as the replay)
|
|
1342
1422
|
outstanding = null;
|
|
1343
1423
|
}
|
|
@@ -1391,7 +1471,7 @@ export class Journal {
|
|
|
1391
1471
|
st.lastAssignment = undefined;
|
|
1392
1472
|
}
|
|
1393
1473
|
}
|
|
1394
|
-
else if (e.event === "scope-authoring") {
|
|
1474
|
+
else if (e.event === "scope-authoring" || e.event === "scope-request") {
|
|
1395
1475
|
// OBS-547: the dispatch this event closes was an authoring defect — the spec is missing a
|
|
1396
1476
|
// files[] line and the worker was never at fault. Rewind its accounting so a resume neither
|
|
1397
1477
|
// charges the attempt nor treats its channel as burned.
|
|
@@ -1415,24 +1495,32 @@ export class Journal {
|
|
|
1415
1495
|
// A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
|
|
1416
1496
|
pendingReroute.add(e.taskId);
|
|
1417
1497
|
}
|
|
1418
|
-
else if (e.event === "task-approved"
|
|
1419
|
-
&& (e.data.release === ATTEMPT_CAP_RELEASE || e.data.release === RECHECK_RELEASE)) {
|
|
1498
|
+
else if (e.event === "task-approved" && e.data.release === ATTEMPT_CAP_RELEASE) {
|
|
1420
1499
|
// v1.24 OBS-18: operator released an attempt-cap park. Pre-v1.24 task-approved events have no
|
|
1421
1500
|
// `release` key ⇒ this branch never fires (corpus criterion: identical statuses + resume state).
|
|
1422
|
-
// attempts
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1501
|
+
// attempts and tried reset so the daemon's attempt-cap check does not re-park in the same tick
|
|
1502
|
+
// and the newly funded engagement starts with a cleared seat list; lastAssignment clears too.
|
|
1503
|
+
// OBS-1028: a RECHECK release is deliberately NOT here — it marks no gate satisfied and funds no
|
|
1504
|
+
// attempt, so the ladder (attempts AND tried) survives it untouched. Only THIS fresh-budget
|
|
1505
|
+
// release resets the ladder, and it resets both halves: a fresh budget on a cleared seat list.
|
|
1426
1506
|
const st = m.get(e.taskId);
|
|
1427
1507
|
if (st) {
|
|
1428
1508
|
st.attempts = 0;
|
|
1509
|
+
st.tried = [];
|
|
1429
1510
|
st.lastAssignment = undefined;
|
|
1430
1511
|
}
|
|
1431
1512
|
}
|
|
1513
|
+
else if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE) {
|
|
1514
|
+
// OBS-1028: the ladder survives a recheck (attempts AND tried); only the seat memory is cleared
|
|
1515
|
+
// so a red battery's next dispatch is picked over the exclusions instead of restoring the parked seat.
|
|
1516
|
+
const st = m.get(e.taskId);
|
|
1517
|
+
if (st)
|
|
1518
|
+
st.lastAssignment = undefined;
|
|
1519
|
+
}
|
|
1432
1520
|
else if (e.event === "task-approved" && e.data.release === REVIEW_UPHELD_RELEASE) {
|
|
1433
|
-
// OBS-189: the operator upheld the reviewer.
|
|
1434
|
-
// (
|
|
1435
|
-
//
|
|
1521
|
+
// OBS-189: the operator upheld the reviewer. It funds fresh attempts without forgetting tried
|
|
1522
|
+
// seats (unlike an attempt-cap fresh-budget release), and carries the newest failed review's
|
|
1523
|
+
// details into the funded attempt as its retry brief.
|
|
1436
1524
|
const st = m.get(e.taskId);
|
|
1437
1525
|
if (st) {
|
|
1438
1526
|
st.attempts = 0;
|
|
@@ -1540,7 +1628,9 @@ export class Journal {
|
|
|
1540
1628
|
replayExcludedChannels() {
|
|
1541
1629
|
const excluded = new Set();
|
|
1542
1630
|
for (const e of this.read()) {
|
|
1543
|
-
|
|
1631
|
+
// OBS-1010: an in-run no-trailer demotion is a WORKER exclusion too; review-pool-demotion is
|
|
1632
|
+
// the reviewer seat's own fold (daemon demotedReviewers) and never benches the worker channel.
|
|
1633
|
+
if ((e.event === "channel-exclusion" || e.event === "channel-demotion") && typeof e.data.channel === "string") {
|
|
1544
1634
|
excluded.add(e.data.channel);
|
|
1545
1635
|
}
|
|
1546
1636
|
else if (e.event === "dead-channel-failover" && typeof e.data.from === "string") {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export declare function isRunnerCommand(command: string): boolean;
|
|
2
|
+
export declare const COMMAND_LEASE_TOKEN_ENV = "TICKMARKR_LEASE_TOKEN";
|
|
3
|
+
export type CommandLease = <T>(command: string, run: (token?: string) => Promise<T>) => Promise<T>;
|
|
4
|
+
/** Async descendants share the reservation; a shell's descendants inherit the same token. */
|
|
5
|
+
export declare function currentCommandLeaseToken(): string | undefined;
|
|
6
|
+
/** Never export a command's token through process.env: concurrent sibling commands must queue. */
|
|
7
|
+
export declare function commandLeaseEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
8
|
+
export declare const runWithCommandLease: <T>(lease: CommandLease, run: () => Promise<T>) => Promise<T>;
|
|
9
|
+
export declare const withCommandLease: <T>(command: string, run: () => Promise<T>) => Promise<T>;
|
|
10
|
+
/** Each reservation releases only itself, including an aborted waiter. */
|
|
11
|
+
export declare class CommandLeases {
|
|
12
|
+
private readonly owners;
|
|
13
|
+
run<T>(run: () => Promise<T>, waiting: (count: number) => void, pollMs: number, signal?: AbortSignal): Promise<T>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* OBS-1042: the standalone battery's cross-process lease. Two `tickmarkr verify` runs in two linked
|
|
17
|
+
* worktrees of one clone share NO daemon and NO state directory, so the in-process `CommandLeases`
|
|
18
|
+
* above never saw each other and both full suites ran on one host. This reservation is keyed on the
|
|
19
|
+
* repository's COMMON git dir (`git rev-parse --git-common-dir`), which every linked worktree of a
|
|
20
|
+
* clone shares and an unrelated repository never does. One file, created exclusively; the holder
|
|
21
|
+
* writes its pid and cwd so a waiter can journal who it waits on and reclaim a reservation whose
|
|
22
|
+
* holder is dead. Each reservation releases only itself: a waiter that never acquired removes nothing,
|
|
23
|
+
* so cancelling it never frees the holder. The daemon's adoption is deferred to 2.5.7.
|
|
24
|
+
*/
|
|
25
|
+
export interface RepositoryLeaseHolder {
|
|
26
|
+
pid: number;
|
|
27
|
+
cwd: string;
|
|
28
|
+
at: number;
|
|
29
|
+
token: string;
|
|
30
|
+
}
|
|
31
|
+
export interface RepositoryLeaseOptions {
|
|
32
|
+
pollMs?: number;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
/** Called once per distinct holder the waiter is queued behind. */
|
|
35
|
+
onWait?: (holder: RepositoryLeaseHolder) => void;
|
|
36
|
+
}
|
|
37
|
+
export declare function repositoryLeasePath(cwd: string): Promise<string>;
|
|
38
|
+
/** OBS-1057: dev+ino alone is not an identity across unlink/recreate — Linux hands the freed inode
|
|
39
|
+
* number straight to the next file, so a stale observation of a dead or corrupt reservation matched
|
|
40
|
+
* the fresh reservation that replaced it and reclaimDead removed the live winner (public CI, ubuntu).
|
|
41
|
+
* The bytes are the generation: a fragment never hashes like a complete record, and two complete
|
|
42
|
+
* records differ by token. Size and mtime are not enough (a same-length reservation written within
|
|
43
|
+
* the filesystem's timestamp resolution — Leg-2 review of the first cut). */
|
|
44
|
+
export declare const inodeIdentity: (st: {
|
|
45
|
+
dev: number | bigint;
|
|
46
|
+
ino: number | bigint;
|
|
47
|
+
}, bytes: string | Buffer) => string;
|
|
48
|
+
/** The identity a reclaimer would observe at a path right now, or undefined when nothing is there. */
|
|
49
|
+
export declare const observeIdentity: (path: string) => string | undefined;
|
|
50
|
+
export declare const readHolder: (path: string) => RepositoryLeaseHolder | undefined;
|
|
51
|
+
/** Publish `mine` at `path` atomically: the record is written whole to a private file and linked
|
|
52
|
+
* into place — `link` is exclusive (EEXIST when the path is taken) and a reader never sees a partial
|
|
53
|
+
* record, so a process killed at any point of its acquisition leaves either nothing or a complete
|
|
54
|
+
* record naming a pid a waiter can check. Returns false when the path is taken. */
|
|
55
|
+
export declare function tryReserve(path: string, mine: RepositoryLeaseHolder): boolean;
|
|
56
|
+
/** Exposed for recovery regressions; the standalone verify uses the combined critical section. */
|
|
57
|
+
export declare const reclaimDead: (path: string, identity: string, pollMs?: number) => Promise<boolean>;
|
|
58
|
+
export declare function withRepositoryLease<T>(cwd: string, run: () => Promise<T>, opts?: RepositoryLeaseOptions): Promise<T>;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { closeSync, constants, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
// The process census and command admission deliberately use the same command-head classifier.
|
|
8
|
+
export function isRunnerCommand(command) {
|
|
9
|
+
// Inspect executable positions only: inference prompts and shell script bodies may name runners.
|
|
10
|
+
let head = command.trim().replace(/['"]/g, "");
|
|
11
|
+
for (;;) {
|
|
12
|
+
const unwrapped = head
|
|
13
|
+
.replace(/^(?:\S*\/)?(?:ba|z)?sh\s+-[a-z]*c\s+/i, "")
|
|
14
|
+
.replace(/^cd\s+[^;&|]+\s*&&\s*/, "")
|
|
15
|
+
.replace(/^(?:env\s+)?(?:[A-Za-z_]\w*=\S+\s+)+/, "");
|
|
16
|
+
if (unwrapped === head)
|
|
17
|
+
break;
|
|
18
|
+
head = unwrapped;
|
|
19
|
+
}
|
|
20
|
+
const binary = "(?:\\S*/)?";
|
|
21
|
+
const flags = "(?:\\s+(?:--?[\\w-]+(?:=[^\\s]+)?|--))*";
|
|
22
|
+
const manager = `${binary}(?:npm|pnpm|yarn|bun)${flags}`;
|
|
23
|
+
const jsRunner = `${binary}(?:vitest(?:\\.mjs)?|jest|mocha)(?:\\s|$)`;
|
|
24
|
+
// Test scripts may be scoped (test:unit, test.integration); runner binaries may be launched
|
|
25
|
+
// through a manager. Keep each match anchored to that executable position so prompt/argument
|
|
26
|
+
// mentions stay out. OBS-1045: the scope is `test` followed by a `:` or `.` separator — the npm
|
|
27
|
+
// scoping convention — so a differently named script such as `test-lint` is never leased.
|
|
28
|
+
return new RegExp(`^${manager}\\s+(?:run${flags}\\s+)?test(?:[:.][\\w.-]+)?(?:\\s|$)`, "i").test(head)
|
|
29
|
+
|| new RegExp(`^${manager}\\s+(?:(?:exec|x|dlx)${flags}\\s+)?${jsRunner}`, "i").test(head)
|
|
30
|
+
|| new RegExp(`^${binary}npx${flags}\\s+${jsRunner}`, "i").test(head)
|
|
31
|
+
|| /^(?:\S*\/)?(?:go|make|cargo)\s+test(?:\s|$)/i.test(head)
|
|
32
|
+
|| /^(?:(?:\S*\/)?(?:node|npx)\s+)?(?:\S*\/)?(?:vitest(?:\.mjs)?|jest|mocha|pytest(?:\d+(?:\.\d+)*)?)(?:\s|$)/i.test(head)
|
|
33
|
+
|| /^(?:\S*\/)?python[\d.]*\s+-m\s+pytest(?:\s|$)/i.test(head);
|
|
34
|
+
}
|
|
35
|
+
export const COMMAND_LEASE_TOKEN_ENV = "TICKMARKR_LEASE_TOKEN";
|
|
36
|
+
const context = new AsyncLocalStorage();
|
|
37
|
+
const ownership = new AsyncLocalStorage();
|
|
38
|
+
/** Async descendants share the reservation; a shell's descendants inherit the same token. */
|
|
39
|
+
export function currentCommandLeaseToken() {
|
|
40
|
+
const owner = ownership.getStore();
|
|
41
|
+
return owner ? (owner.active ? owner.token : undefined) : process.env[COMMAND_LEASE_TOKEN_ENV] || undefined;
|
|
42
|
+
}
|
|
43
|
+
/** Never export a command's token through process.env: concurrent sibling commands must queue. */
|
|
44
|
+
export function commandLeaseEnvironment(env) {
|
|
45
|
+
const child = { ...env };
|
|
46
|
+
const token = currentCommandLeaseToken();
|
|
47
|
+
if (token)
|
|
48
|
+
child[COMMAND_LEASE_TOKEN_ENV] = token;
|
|
49
|
+
else
|
|
50
|
+
delete child[COMMAND_LEASE_TOKEN_ENV];
|
|
51
|
+
return child;
|
|
52
|
+
}
|
|
53
|
+
export const runWithCommandLease = (lease, run) => context.run(lease, run);
|
|
54
|
+
export const withCommandLease = (command, run) => {
|
|
55
|
+
const lease = context.getStore();
|
|
56
|
+
if (!lease || !isRunnerCommand(command) || currentCommandLeaseToken())
|
|
57
|
+
return run();
|
|
58
|
+
return lease(command, token => {
|
|
59
|
+
const owner = { token: token ?? randomUUID(), active: true };
|
|
60
|
+
return ownership.run(owner, async () => {
|
|
61
|
+
try {
|
|
62
|
+
return await run();
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
owner.active = false;
|
|
66
|
+
} // A continuation after release must acquire a new lease.
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
/** Each reservation releases only itself, including an aborted waiter. */
|
|
71
|
+
export class CommandLeases {
|
|
72
|
+
owners = new Set();
|
|
73
|
+
async run(run, waiting, pollMs, signal) {
|
|
74
|
+
const owner = Symbol("command");
|
|
75
|
+
this.owners.add(owner);
|
|
76
|
+
try {
|
|
77
|
+
let reported = false;
|
|
78
|
+
while (this.owners.values().next().value !== owner) {
|
|
79
|
+
signal?.throwIfAborted();
|
|
80
|
+
if (!reported) {
|
|
81
|
+
waiting(this.owners.size - 1);
|
|
82
|
+
reported = true;
|
|
83
|
+
}
|
|
84
|
+
await new Promise((wake) => setTimeout(wake, pollMs));
|
|
85
|
+
}
|
|
86
|
+
signal?.throwIfAborted();
|
|
87
|
+
return await run();
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
this.owners.delete(owner);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const LEASE_FILE = "tickmarkr-runner.lease";
|
|
95
|
+
export async function repositoryLeasePath(cwd) {
|
|
96
|
+
const commonDir = await new Promise((ok, fail) => execFile("git", ["rev-parse", "--git-common-dir"], { cwd, encoding: "utf8" }, (error, stdout) => error ? fail(error) : ok(stdout.trim())));
|
|
97
|
+
return join(resolve(cwd, commonDir), LEASE_FILE);
|
|
98
|
+
}
|
|
99
|
+
const alive = (pid) => {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, 0);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return error.code !== "ESRCH";
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const isCode = (error, code) => error.code === code;
|
|
109
|
+
/** OBS-1057: dev+ino alone is not an identity across unlink/recreate — Linux hands the freed inode
|
|
110
|
+
* number straight to the next file, so a stale observation of a dead or corrupt reservation matched
|
|
111
|
+
* the fresh reservation that replaced it and reclaimDead removed the live winner (public CI, ubuntu).
|
|
112
|
+
* The bytes are the generation: a fragment never hashes like a complete record, and two complete
|
|
113
|
+
* records differ by token. Size and mtime are not enough (a same-length reservation written within
|
|
114
|
+
* the filesystem's timestamp resolution — Leg-2 review of the first cut). */
|
|
115
|
+
export const inodeIdentity = (st, bytes) => `${st.dev}-${st.ino}-${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}`;
|
|
116
|
+
/** The identity a reclaimer would observe at a path right now, or undefined when nothing is there. */
|
|
117
|
+
export const observeIdentity = (path) => readOccupant(path)?.identity;
|
|
118
|
+
const readOccupant = (path) => {
|
|
119
|
+
let fd;
|
|
120
|
+
try {
|
|
121
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
122
|
+
const st = fstatSync(fd);
|
|
123
|
+
const bytes = readFileSync(fd, "utf8");
|
|
124
|
+
const identity = inodeIdentity(st, bytes);
|
|
125
|
+
try {
|
|
126
|
+
const holder = JSON.parse(bytes);
|
|
127
|
+
if (typeof holder.pid === "number" && typeof holder.cwd === "string" && typeof holder.token === "string")
|
|
128
|
+
return { holder, identity };
|
|
129
|
+
}
|
|
130
|
+
catch { /* incomplete/foreign bytes are reclaimable under the mutation lock */ }
|
|
131
|
+
return { holder: undefined, identity };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (isCode(error, "ENOENT"))
|
|
135
|
+
return undefined;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
if (fd !== undefined)
|
|
140
|
+
closeSync(fd);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
export const readHolder = (path) => readOccupant(path)?.holder;
|
|
144
|
+
/** Crash-recoverable serialization for lease-path mutations. The highest atomic owner-generation
|
|
145
|
+
* symlink is authoritative. A process killed in the critical section leaves its pid behind, so one
|
|
146
|
+
* contender can exclusively publish the next generation and finish the interrupted operation. */
|
|
147
|
+
const mutationLock = (path) => `${path}.lock`;
|
|
148
|
+
const lockGeneration = (entry) => {
|
|
149
|
+
const match = /^owner\.(\d+)$/.exec(entry);
|
|
150
|
+
return match ? Number(match[1]) : undefined;
|
|
151
|
+
};
|
|
152
|
+
const mutationHolder = (lock) => {
|
|
153
|
+
let names;
|
|
154
|
+
try {
|
|
155
|
+
names = readdirSync(lock);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (isCode(error, "ENOENT"))
|
|
159
|
+
return undefined;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
const current = names.flatMap((entry) => {
|
|
163
|
+
const gen = lockGeneration(entry);
|
|
164
|
+
return gen === undefined ? [] : [{ entry, gen }];
|
|
165
|
+
}).sort((a, b) => b.gen - a.gen)[0];
|
|
166
|
+
if (!current)
|
|
167
|
+
return undefined;
|
|
168
|
+
try {
|
|
169
|
+
const target = readlinkSync(join(lock, current.entry));
|
|
170
|
+
const pid = Number(/^([1-9]\d*):/.exec(target)?.[1]);
|
|
171
|
+
return Number.isInteger(pid) && pid > 0 ? { ...current, target, pid } : { ...current, target };
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return current;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const releaseMutationLock = (lock, entry, target, inherited) => {
|
|
178
|
+
const holder = mutationHolder(lock);
|
|
179
|
+
if (holder?.entry !== entry || holder.target !== target)
|
|
180
|
+
return;
|
|
181
|
+
// Remove our marker last. A successor published after that point is never in this snapshot.
|
|
182
|
+
for (const name of [...inherited, entry]) {
|
|
183
|
+
try {
|
|
184
|
+
unlinkSync(join(lock, name));
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (!isCode(error, "ENOENT"))
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
rmdirSync(lock);
|
|
193
|
+
}
|
|
194
|
+
catch { /* ENOTEMPTY: a successor owns the directory */ }
|
|
195
|
+
};
|
|
196
|
+
const withMutationLock = async (path, pollMs, signal, run) => {
|
|
197
|
+
const lock = mutationLock(path);
|
|
198
|
+
const target = `${process.pid}:${randomUUID()}`;
|
|
199
|
+
let entry = "";
|
|
200
|
+
let inherited = [];
|
|
201
|
+
for (;;) {
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
try {
|
|
204
|
+
mkdirSync(lock);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (!isCode(error, "EEXIST"))
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
const holder = mutationHolder(lock);
|
|
211
|
+
if (holder?.pid !== undefined && alive(holder.pid)) {
|
|
212
|
+
await new Promise((wake) => setTimeout(wake, pollMs));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
entry = `owner.${(holder?.gen ?? -1) + 1}`;
|
|
216
|
+
try {
|
|
217
|
+
symlinkSync(target, join(lock, entry));
|
|
218
|
+
inherited = readdirSync(lock).filter((name) => name !== entry && lockGeneration(name) !== undefined);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (!isCode(error, "EEXIST") && !isCode(error, "ENOENT"))
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
return await run();
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
releaseMutationLock(lock, entry, target, inherited);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
/** Publish `mine` at `path` atomically: the record is written whole to a private file and linked
|
|
234
|
+
* into place — `link` is exclusive (EEXIST when the path is taken) and a reader never sees a partial
|
|
235
|
+
* record, so a process killed at any point of its acquisition leaves either nothing or a complete
|
|
236
|
+
* record naming a pid a waiter can check. Returns false when the path is taken. */
|
|
237
|
+
export function tryReserve(path, mine) {
|
|
238
|
+
const draft = `${path}.${mine.token}.draft`;
|
|
239
|
+
writeFileSync(draft, JSON.stringify(mine) + "\n");
|
|
240
|
+
try {
|
|
241
|
+
linkSync(draft, path);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (isCode(error, "EEXIST"))
|
|
246
|
+
return false;
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
rmSync(draft, { force: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Link the inspected dead inode to its stable tombstone, then remove the canonical name. This runs
|
|
254
|
+
* only under the mutation lock. EEXIST means an earlier reclaimer died after link: recovery finishes
|
|
255
|
+
* only if both names still bind that exact inode, never a replacement holder. */
|
|
256
|
+
const reclaimDeadLocked = (path, identity) => {
|
|
257
|
+
const tombstone = `${path}.dead-${identity}`;
|
|
258
|
+
try {
|
|
259
|
+
linkSync(path, tombstone);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (!isCode(error, "EEXIST") && !isCode(error, "ENOENT"))
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
const dead = readOccupant(tombstone);
|
|
266
|
+
const current = readOccupant(path);
|
|
267
|
+
if (dead?.identity !== identity || current?.identity !== identity)
|
|
268
|
+
return false;
|
|
269
|
+
unlinkSync(path);
|
|
270
|
+
return true;
|
|
271
|
+
};
|
|
272
|
+
/** Exposed for recovery regressions; the standalone verify uses the combined critical section. */
|
|
273
|
+
export const reclaimDead = async (path, identity, pollMs = 10) => withMutationLock(path, pollMs, undefined, () => reclaimDeadLocked(path, identity));
|
|
274
|
+
const inspectAndReserve = (path, mine) => {
|
|
275
|
+
let occupant = readOccupant(path);
|
|
276
|
+
if (occupant && (!occupant.holder || !alive(occupant.holder.pid))) {
|
|
277
|
+
reclaimDeadLocked(path, occupant.identity);
|
|
278
|
+
occupant = readOccupant(path);
|
|
279
|
+
}
|
|
280
|
+
if (!occupant && tryReserve(path, mine))
|
|
281
|
+
return { acquired: true };
|
|
282
|
+
return { acquired: false, holder: occupant?.holder };
|
|
283
|
+
};
|
|
284
|
+
export async function withRepositoryLease(cwd, run, opts = {}) {
|
|
285
|
+
const path = await repositoryLeasePath(cwd);
|
|
286
|
+
const pollMs = opts.pollMs ?? 1_000;
|
|
287
|
+
const mine = { pid: process.pid, cwd, at: Date.now(), token: randomUUID() };
|
|
288
|
+
let waitingOn;
|
|
289
|
+
for (;;) {
|
|
290
|
+
opts.signal?.throwIfAborted();
|
|
291
|
+
const attempt = await withMutationLock(path, pollMs, opts.signal, () => inspectAndReserve(path, mine));
|
|
292
|
+
if (attempt.acquired)
|
|
293
|
+
break;
|
|
294
|
+
if (attempt.holder && attempt.holder.pid !== waitingOn) {
|
|
295
|
+
waitingOn = attempt.holder.pid;
|
|
296
|
+
opts.onWait?.(attempt.holder);
|
|
297
|
+
}
|
|
298
|
+
await new Promise((wake) => setTimeout(wake, pollMs));
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
return await run();
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
// Release is serialized with reclamation/acquisition and removes only this generation.
|
|
305
|
+
await withMutationLock(path, pollMs, undefined, () => {
|
|
306
|
+
if (readHolder(path)?.token === mine.token)
|
|
307
|
+
unlinkSync(path);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
package/dist/run/merge.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface TipVerifyResult {
|
|
|
8
8
|
fingerprints: string[];
|
|
9
9
|
details: string;
|
|
10
10
|
artifact?: string;
|
|
11
|
+
reportPath?: string;
|
|
12
|
+
spawnedCommand?: string;
|
|
11
13
|
/** Q121s: nonzero exit whose failures are ALL baseline-recorded — forgiven exactly as the battery forgives. */
|
|
12
14
|
forgiven?: boolean;
|
|
13
15
|
/**
|