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.
Files changed (78) hide show
  1. package/README.md +3 -1
  2. package/dist/adapters/registry.js +6 -1
  3. package/dist/adapters/types.d.ts +3 -0
  4. package/dist/cli/commands/approve.d.ts +1 -0
  5. package/dist/cli/commands/approve.js +118 -9
  6. package/dist/cli/commands/doctor.d.ts +10 -0
  7. package/dist/cli/commands/doctor.js +44 -29
  8. package/dist/cli/commands/fleet.js +199 -33
  9. package/dist/cli/commands/init.js +196 -6
  10. package/dist/cli/commands/plan.js +20 -4
  11. package/dist/cli/commands/resume.js +4 -2
  12. package/dist/cli/commands/run.js +11 -2
  13. package/dist/cli/commands/verify.js +102 -81
  14. package/dist/cli/help.d.ts +2 -0
  15. package/dist/cli/help.js +3 -1
  16. package/dist/config/config.d.ts +25 -8
  17. package/dist/config/config.js +41 -29
  18. package/dist/config/fleet-overlay.d.ts +3 -9
  19. package/dist/config/fleet-overlay.js +114 -19
  20. package/dist/config/fleet-why.d.ts +7 -0
  21. package/dist/config/fleet-why.js +5 -0
  22. package/dist/drivers/index.d.ts +15 -1
  23. package/dist/drivers/index.js +39 -10
  24. package/dist/drivers/orca.d.ts +119 -10
  25. package/dist/drivers/orca.js +781 -110
  26. package/dist/gates/baseline.d.ts +17 -3
  27. package/dist/gates/baseline.js +63 -15
  28. package/dist/gates/cache.d.ts +101 -0
  29. package/dist/gates/cache.js +401 -0
  30. package/dist/gates/llm.d.ts +3 -0
  31. package/dist/gates/llm.js +11 -0
  32. package/dist/gates/review.d.ts +28 -3
  33. package/dist/gates/review.js +106 -14
  34. package/dist/gates/run-gates.d.ts +9 -1
  35. package/dist/gates/run-gates.js +407 -102
  36. package/dist/gates/test-manifest.d.ts +128 -0
  37. package/dist/gates/test-manifest.js +463 -0
  38. package/dist/gates/test-reporter.d.ts +4 -0
  39. package/dist/gates/test-reporter.js +57 -0
  40. package/dist/graph/graph.d.ts +2 -0
  41. package/dist/graph/graph.js +45 -1
  42. package/dist/route/preference.d.ts +22 -1
  43. package/dist/route/preference.js +123 -25
  44. package/dist/route/router.js +31 -6
  45. package/dist/run/consult.js +5 -4
  46. package/dist/run/daemon.d.ts +15 -0
  47. package/dist/run/daemon.js +638 -167
  48. package/dist/run/execution-budget.d.ts +25 -0
  49. package/dist/run/execution-budget.js +142 -0
  50. package/dist/run/git.d.ts +50 -1
  51. package/dist/run/git.js +131 -12
  52. package/dist/run/journal.d.ts +16 -2
  53. package/dist/run/journal.js +115 -25
  54. package/dist/run/lease.d.ts +58 -0
  55. package/dist/run/lease.js +310 -0
  56. package/dist/run/merge.d.ts +2 -0
  57. package/dist/run/merge.js +91 -3
  58. package/dist/run/operator-state.d.ts +11 -0
  59. package/dist/run/operator-state.js +17 -3
  60. package/dist/run/recovery.d.ts +8 -0
  61. package/dist/run/recovery.js +25 -0
  62. package/dist/run/repair-selection.d.ts +12 -0
  63. package/dist/run/repair-selection.js +56 -0
  64. package/dist/run/stall.d.ts +6 -1
  65. package/dist/run/stall.js +60 -3
  66. package/dist/tui/cockpit/board.d.ts +96 -0
  67. package/dist/tui/cockpit/board.js +346 -0
  68. package/dist/tui/cockpit/decision-actions.js +2 -0
  69. package/dist/tui/cockpit/layout.d.ts +5 -1
  70. package/dist/tui/cockpit/layout.js +8 -3
  71. package/dist/tui/cockpit/live-runtime.js +71 -21
  72. package/dist/tui/cockpit/run-view.d.ts +7 -5
  73. package/dist/tui/cockpit/run-view.js +12 -11
  74. package/dist/tui/ink/fleet-app.d.ts +49 -27
  75. package/dist/tui/ink/fleet-app.js +229 -38
  76. package/package.json +2 -2
  77. package/skills/tickmarkr-overseer/SKILL.md +173 -113
  78. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
@@ -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 zeros the attempt counter (fresh budget) while keeping
64
- // tried (consult bans / burned channels). Absent on pre-v1.24 events ⇒ inert (corpus outcome-identical).
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. Budget semantics match attempt-cap
78
- // (fresh attempts, tried survives) because the park cost the task its remaining budget.
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 || normalizeGateFailure(note) || UNIDENTIFIED;
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 line = redactSecrets(JSON.stringify(row));
1247
- appendFileSync(this.journalPath, line + "\n");
1248
- try {
1249
- this.narrate?.(JSON.parse(line));
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
- catch {
1252
- // narration is observational; a broken sink must not affect the journal or run
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 reset to 0 so the daemon's attempt-cap check does not re-park in the same tick;
1423
- // tried survives (consult bans and burned channels are not forgotten); lastAssignment is
1424
- // cleared so the daemon's nextChannel-over-tried path skips burned channels on first dispatch
1425
- // (restoring the last burned assignment would re-try it first — the failure the tried-list exists to prevent).
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. Same budget semantics as the attempt-cap release
1434
- // (fresh attempts, tried survives, no burned-channel restore) PLUS the findings carry: the
1435
- // newest failed review's details ride into the funded attempt as its retry brief.
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
- if (e.event === "channel-exclusion" && typeof e.data.channel === "string") {
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
+ }
@@ -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
  /**