tickmarkr 2.5.5 → 2.5.7

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 (69) hide show
  1. package/README.md +3 -1
  2. package/dist/adapters/prompt.js +21 -1
  3. package/dist/cli/commands/approve.js +69 -8
  4. package/dist/cli/commands/doctor.d.ts +10 -0
  5. package/dist/cli/commands/fleet.js +4 -0
  6. package/dist/cli/commands/plan.js +20 -4
  7. package/dist/cli/commands/status.js +95 -34
  8. package/dist/cli/commands/verify.js +108 -85
  9. package/dist/config/config.d.ts +11 -0
  10. package/dist/config/config.js +21 -12
  11. package/dist/config/fleet-overlay.js +55 -17
  12. package/dist/drivers/index.js +2 -1
  13. package/dist/drivers/orca.d.ts +21 -1
  14. package/dist/drivers/orca.js +209 -27
  15. package/dist/gates/baseline.d.ts +21 -5
  16. package/dist/gates/baseline.js +67 -17
  17. package/dist/gates/cache.d.ts +14 -13
  18. package/dist/gates/cache.js +17 -5
  19. package/dist/gates/llm.d.ts +3 -0
  20. package/dist/gates/llm.js +11 -0
  21. package/dist/gates/review.d.ts +28 -3
  22. package/dist/gates/review.js +118 -15
  23. package/dist/gates/run-gates.d.ts +10 -2
  24. package/dist/gates/run-gates.js +362 -108
  25. package/dist/gates/test-manifest.d.ts +33 -1
  26. package/dist/gates/test-manifest.js +132 -40
  27. package/dist/gates/test-reporter.js +20 -7
  28. package/dist/graph/graph.d.ts +4 -0
  29. package/dist/graph/graph.js +50 -1
  30. package/dist/run/activity.d.ts +28 -0
  31. package/dist/run/activity.js +194 -0
  32. package/dist/run/consult.js +5 -4
  33. package/dist/run/daemon.d.ts +11 -0
  34. package/dist/run/daemon.js +663 -128
  35. package/dist/run/execution-budget.d.ts +25 -0
  36. package/dist/run/execution-budget.js +142 -0
  37. package/dist/run/git.d.ts +46 -1
  38. package/dist/run/git.js +149 -12
  39. package/dist/run/journal.d.ts +23 -5
  40. package/dist/run/journal.js +98 -25
  41. package/dist/run/lease.d.ts +44 -0
  42. package/dist/run/lease.js +226 -3
  43. package/dist/run/operator-page-summary.d.ts +56 -0
  44. package/dist/run/operator-page-summary.js +68 -0
  45. package/dist/run/operator-summary.d.ts +69 -0
  46. package/dist/run/operator-summary.js +77 -0
  47. package/dist/run/protocol.d.ts +71 -0
  48. package/dist/run/protocol.js +32 -0
  49. package/dist/run/recovery.d.ts +8 -0
  50. package/dist/run/recovery.js +25 -0
  51. package/dist/run/repair-selection.d.ts +12 -0
  52. package/dist/run/repair-selection.js +56 -0
  53. package/dist/run/stall.d.ts +6 -1
  54. package/dist/run/stall.js +60 -3
  55. package/dist/tui/cockpit/board.d.ts +9 -0
  56. package/dist/tui/cockpit/board.js +10 -0
  57. package/dist/tui/cockpit/derive.d.ts +35 -0
  58. package/dist/tui/cockpit/derive.js +152 -10
  59. package/dist/tui/cockpit/evidence-view.d.ts +2 -0
  60. package/dist/tui/cockpit/evidence-view.js +42 -12
  61. package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
  62. package/dist/tui/cockpit/run-cockpit.js +95 -1
  63. package/dist/tui/cockpit/run-view.d.ts +37 -0
  64. package/dist/tui/cockpit/run-view.js +189 -2
  65. package/dist/tui/ink/fleet-app.d.ts +10 -2
  66. package/dist/tui/ink/fleet-app.js +33 -15
  67. package/package.json +2 -2
  68. package/skills/tickmarkr-overseer/SKILL.md +55 -3
  69. 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 { graphDefinitionHash, stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
7
+ import { graphDefinitionHash, stateDirName, taskContentDigest, taskDefinitionFingerprint, 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 } : {}),
@@ -644,16 +656,25 @@ function decisionForNextDispatch(events, taskId, event) {
644
656
  * `task-dispatch`: everything between the two — worktree recreation, setup, prompt write, slot
645
657
  * allocation, the launch itself — can still die with no worker having read a word, and clearing at
646
658
  * task-dispatch meant `--retry-failed` after exactly that death rebuilt the prompt without the gate
647
- * failures OR the delivery failure that preceded it. `task-approved` also clears (an operator approval
648
- * retires the findings it settled — the uphold case re-derives its own brief separately).
659
+ * failures OR the delivery failure that preceded it. Of the approvals, only a WAIVE clears (the operator
660
+ * retired the findings by fiat — the uphold case re-derives its own brief separately). OBS-1074: a
661
+ * plain approve, a scope grant or a recheck re-funds an attempt that must still see why the last one
662
+ * parked, plus the operator's stated reason — v2.5.7's T11 looped four times on one hygiene oracle
663
+ * because every approval erased exactly the finding the fresh attempt was funded to fix.
649
664
  */
650
665
  export function journaledFailureBrief(events, taskId) {
651
666
  let rows = [];
652
667
  for (const e of events) {
653
668
  if (e.taskId !== taskId)
654
669
  continue;
655
- if (e.event === "worker-launch" || e.event === "task-approved")
670
+ if (e.event === "worker-launch")
656
671
  rows = [];
672
+ else if (e.event === "task-approved") {
673
+ if (e.data.release === GATE_SATISFIED_RELEASE)
674
+ rows = [];
675
+ else if (typeof e.data.reason === "string" && e.data.reason.trim())
676
+ rows.push(`approval: ${e.data.reason.trim()}`);
677
+ }
657
678
  else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
658
679
  && typeof e.data.details === "string")
659
680
  rows.push(`${e.data.gate}: ${e.data.details}`);
@@ -773,7 +794,7 @@ const DispatchAssignmentSchema = z.object({
773
794
  // routing profile's QUALITY_FAIL_PARKS (route/profile.ts): the channel did nothing wrong.
774
795
  export const PARK_KINDS = ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota",
775
796
  "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch",
776
- "authoring", "scope-request"];
797
+ "authoring", "scope-request", "diff-cap"];
777
798
  // v1.85 T3: "repair" is a third dispatch mode beside the v1.29 session pair — a fix-only attempt that
778
799
  // carries the failing findings and the diff CONTENT of the work already landed, instead of re-buying
779
800
  // ~20m of onboarding to rediscover them (62 of 68 measured re-dispatches were fresh).
@@ -971,12 +992,37 @@ export function recordedGraphDefinitionHash(events) {
971
992
  }
972
993
  return recorded ?? undefined;
973
994
  }
995
+ /**
996
+ * OBS-1073 residual: the `--graph-changed` release belongs to the ENGAGEMENT, not to the launch call. The
997
+ * daemon writes it on the run-resume row; every replay that runs inside that engagement from another
998
+ * process (the approve CLI) reads it back here. Without it the first in-run approval after a released
999
+ * launch re-ran the whole-graph asserts the launch had waived and killed the daemon (v2.5.7 run …152220).
1000
+ */
1001
+ export function engagementReleased(events) {
1002
+ const engagement = events.filter((e) => e.event === "run-start" || e.event === "run-resume").at(-1);
1003
+ return engagement?.data.graphChanged === true;
1004
+ }
1005
+ /** The run's own graph.json (copied by the daemon at every start/resume) — the definitions the last engagement ran on. */
1006
+ function snapshotDefinitions(journal) {
1007
+ const path = join(journal.dir, "graph.json");
1008
+ if (!existsSync(path))
1009
+ return new Map();
1010
+ const snapshot = JSON.parse(readFileSync(path, "utf8"));
1011
+ return new Map((snapshot.tasks ?? []).map((t) => [t.id, taskDefinitionFingerprint(t)]));
1012
+ }
974
1013
  /** An approval is the durable authority; graph.json is only its materialized projection. */
975
1014
  export const ScopeAmendmentSchema = z.object({
976
1015
  from: z.string(), to: z.string(), beforeFiles: z.array(z.string()), files: z.array(z.string()),
977
1016
  parkLine: z.number().int().positive(),
1017
+ /** OBS-1073: the amended task's definition minus files[] at approval; absent on rows older than v2.5.7. */
1018
+ definition: z.string().optional(),
978
1019
  });
979
- export function replayScopeAmendments(graph, events) {
1020
+ /**
1021
+ * `release` is the operator's audited `--graph-changed`. `approvedDefinitions` supplies, per amended task,
1022
+ * the definition fingerprint the amendment was granted against when the row itself carries none (rows
1023
+ * older than v2.5.7): the caller reads it from the run's materialized graph snapshot.
1024
+ */
1025
+ export function replayScopeAmendments(graph, events, release = false, approvedDefinitions = new Map()) {
980
1026
  const amendments = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
981
1027
  .map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
982
1028
  if (!amendments.length)
@@ -995,20 +1041,37 @@ export function replayScopeAmendments(graph, events) {
995
1041
  }
996
1042
  result = { ...result, tasks: result.tasks.map((t) => t.id === id ? { ...t, files: original } : t) };
997
1043
  }
1044
+ // OBS-1073: `from`/`to` bind the WHOLE graph, so a later spec repair of ANOTHER task (a pin-sweep miss
1045
+ // found mid-run, D-57) makes them unmatchable forever and `--graph-changed` — applied only after this
1046
+ // replay — never reaches its own release. Under that audited release the amended task's files[]
1047
+ // recognition above still holds and the caller journals the rehash; only the whole-graph identity
1048
+ // asserts are waived. Without the release nothing changes: fail closed.
998
1049
  for (const { event, amendment } of amendments) {
999
- if (graphDefinitionHash(result) !== amendment.from) {
1050
+ if (release) {
1051
+ // The release waives the WHOLE-GRAPH identity (another task may lawfully have moved), never the
1052
+ // amended task's own definition: goal, acceptance, deps, floor must still be what was approved.
1053
+ const approved = amendment.definition ?? approvedDefinitions.get(event.taskId ?? "");
1054
+ const current = result.tasks.find((t) => t.id === event.taskId);
1055
+ if (approved === undefined || current === undefined) {
1056
+ throw new Error(`refusing scope amendment replay under --graph-changed: no approved definition fingerprint for ${event.taskId}`);
1057
+ }
1058
+ if (taskDefinitionFingerprint(current) !== approved) {
1059
+ throw new Error(`refusing scope amendment replay: ${event.taskId} definition changed beyond the approved amendment (goal, acceptance, deps or routing)`);
1060
+ }
1061
+ }
1062
+ if (!release && graphDefinitionHash(result) !== amendment.from) {
1000
1063
  throw new Error(`refusing scope amendment replay: graph task definition changed beyond approval for ${event.taskId}`);
1001
1064
  }
1002
1065
  result = { ...result, tasks: result.tasks.map((t) => t.id === event.taskId ? { ...t, files: amendment.files } : t) };
1003
- if (graphDefinitionHash(result) !== amendment.to)
1066
+ if (!release && graphDefinitionHash(result) !== amendment.to)
1004
1067
  throw new Error("refusing invalid scope amendment hash");
1005
1068
  }
1006
1069
  return result;
1007
1070
  }
1008
1071
  /** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
1009
- export function applyScopeAmendments(graph, journal, auditReplay = false) {
1072
+ export function applyScopeAmendments(graph, journal, auditReplay = false, release = false) {
1010
1073
  const events = journal.read();
1011
- const result = replayScopeAmendments(graph, events); // validate all before moving any identity
1074
+ const result = replayScopeAmendments(graph, events, release, release ? snapshotDefinitions(journal) : new Map()); // validate all before moving any identity
1012
1075
  const approvals = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
1013
1076
  for (const approval of approvals) {
1014
1077
  const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
@@ -1483,24 +1546,32 @@ export class Journal {
1483
1546
  // A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
1484
1547
  pendingReroute.add(e.taskId);
1485
1548
  }
1486
- else if (e.event === "task-approved"
1487
- && (e.data.release === ATTEMPT_CAP_RELEASE || e.data.release === RECHECK_RELEASE)) {
1549
+ else if (e.event === "task-approved" && e.data.release === ATTEMPT_CAP_RELEASE) {
1488
1550
  // v1.24 OBS-18: operator released an attempt-cap park. Pre-v1.24 task-approved events have no
1489
1551
  // `release` key ⇒ this branch never fires (corpus criterion: identical statuses + resume state).
1490
- // attempts reset to 0 so the daemon's attempt-cap check does not re-park in the same tick;
1491
- // tried survives (consult bans and burned channels are not forgotten); lastAssignment is
1492
- // cleared so the daemon's nextChannel-over-tried path skips burned channels on first dispatch
1493
- // (restoring the last burned assignment would re-try it first — the failure the tried-list exists to prevent).
1552
+ // attempts and tried reset so the daemon's attempt-cap check does not re-park in the same tick
1553
+ // and the newly funded engagement starts with a cleared seat list; lastAssignment clears too.
1554
+ // OBS-1028: a RECHECK release is deliberately NOT here — it marks no gate satisfied and funds no
1555
+ // attempt, so the ladder (attempts AND tried) survives it untouched. Only THIS fresh-budget
1556
+ // release resets the ladder, and it resets both halves: a fresh budget on a cleared seat list.
1494
1557
  const st = m.get(e.taskId);
1495
1558
  if (st) {
1496
1559
  st.attempts = 0;
1560
+ st.tried = [];
1497
1561
  st.lastAssignment = undefined;
1498
1562
  }
1499
1563
  }
1564
+ else if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE) {
1565
+ // OBS-1028: the ladder survives a recheck (attempts AND tried); only the seat memory is cleared
1566
+ // so a red battery's next dispatch is picked over the exclusions instead of restoring the parked seat.
1567
+ const st = m.get(e.taskId);
1568
+ if (st)
1569
+ st.lastAssignment = undefined;
1570
+ }
1500
1571
  else if (e.event === "task-approved" && e.data.release === REVIEW_UPHELD_RELEASE) {
1501
- // OBS-189: the operator upheld the reviewer. Same budget semantics as the attempt-cap release
1502
- // (fresh attempts, tried survives, no burned-channel restore) PLUS the findings carry: the
1503
- // newest failed review's details ride into the funded attempt as its retry brief.
1572
+ // OBS-189: the operator upheld the reviewer. It funds fresh attempts without forgetting tried
1573
+ // seats (unlike an attempt-cap fresh-budget release), and carries the newest failed review's
1574
+ // details into the funded attempt as its retry brief.
1504
1575
  const st = m.get(e.taskId);
1505
1576
  if (st) {
1506
1577
  st.attempts = 0;
@@ -1608,7 +1679,9 @@ export class Journal {
1608
1679
  replayExcludedChannels() {
1609
1680
  const excluded = new Set();
1610
1681
  for (const e of this.read()) {
1611
- if (e.event === "channel-exclusion" && typeof e.data.channel === "string") {
1682
+ // OBS-1010: an in-run no-trailer demotion is a WORKER exclusion too; review-pool-demotion is
1683
+ // the reviewer seat's own fold (daemon demotedReviewers) and never benches the worker channel.
1684
+ if ((e.event === "channel-exclusion" || e.event === "channel-demotion") && typeof e.data.channel === "string") {
1612
1685
  excluded.add(e.data.channel);
1613
1686
  }
1614
1687
  else if (e.event === "dead-channel-failover" && typeof e.data.from === "string") {
@@ -12,3 +12,47 @@ export declare class CommandLeases {
12
12
  private readonly owners;
13
13
  run<T>(run: () => Promise<T>, waiting: (count: number) => void, pollMs: number, signal?: AbortSignal): Promise<T>;
14
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>;
package/dist/run/lease.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { execFile } from "node:child_process";
2
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";
3
7
  // The process census and command admission deliberately use the same command-head classifier.
4
8
  export function isRunnerCommand(command) {
5
9
  // Inspect executable positions only: inference prompts and shell script bodies may name runners.
@@ -17,9 +21,11 @@ export function isRunnerCommand(command) {
17
21
  const flags = "(?:\\s+(?:--?[\\w-]+(?:=[^\\s]+)?|--))*";
18
22
  const manager = `${binary}(?:npm|pnpm|yarn|bun)${flags}`;
19
23
  const jsRunner = `${binary}(?:vitest(?:\\.mjs)?|jest|mocha)(?:\\s|$)`;
20
- // Test scripts may be scoped (test:unit); runner binaries may be launched through a manager.
21
- // Keep each match anchored to that executable position so prompt/argument mentions stay out.
22
- return new RegExp(`^${manager}\\s+(?:run${flags}\\s+)?test[\\w:.-]*(?:\\s|$)`, "i").test(head)
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)
23
29
  || new RegExp(`^${manager}\\s+(?:(?:exec|x|dlx)${flags}\\s+)?${jsRunner}`, "i").test(head)
24
30
  || new RegExp(`^${binary}npx${flags}\\s+${jsRunner}`, "i").test(head)
25
31
  || /^(?:\S*\/)?(?:go|make|cargo)\s+test(?:\s|$)/i.test(head)
@@ -85,3 +91,220 @@ export class CommandLeases {
85
91
  }
86
92
  }
87
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
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pure operator-page grouping over the `operator-page` rows the daemon journals (one row per
3
+ * delivered page, carrying the producer's `suppressed` count). This module consumes that count and
4
+ * never throttles a second time: it only coalesces EQUIVALENT rows for display while keeping every
5
+ * source line, both evidence timestamps, and the producer's suppressed count separate from what was
6
+ * actually observed. Folding is incremental and pure; `fold(fold(empty, a), b)` equals
7
+ * `fold(empty, [...a, ...b])` and no journal byte is touched.
8
+ */
9
+ import type { JournalEvent } from "./journal.js";
10
+ export interface OperatorPageRow {
11
+ /** Source reference (journal line number or any stable locator). */
12
+ readonly line: number;
13
+ readonly ts: string;
14
+ readonly runId: string;
15
+ readonly taskId: string;
16
+ /** Current park identity (e.g. park kind + attempt); part of the group key. */
17
+ readonly park: string;
18
+ /** Decision state (e.g. blocked / idle / failed / resolved); part of the group key. */
19
+ readonly status: string;
20
+ /** Producer-suppressed page count; absent on rows written before suppression metadata existed. */
21
+ readonly suppressed?: number;
22
+ readonly blocker?: string;
23
+ readonly owner?: string;
24
+ readonly requiredAction?: string;
25
+ readonly permittedActions?: readonly string[];
26
+ }
27
+ export interface OperatorPageGroup {
28
+ readonly key: string;
29
+ readonly runId: string;
30
+ readonly taskId: string;
31
+ readonly park: string;
32
+ readonly status: string;
33
+ readonly blocker?: string;
34
+ readonly owner?: string;
35
+ readonly requiredAction?: string;
36
+ readonly permittedActions: readonly string[];
37
+ readonly firstEvidenceAt: string;
38
+ readonly lastEvidenceAt: string;
39
+ /** Rows actually present in the journal. */
40
+ readonly observedCount: number;
41
+ /** Sum of the producer's `suppressed` counts; never added to observedCount. */
42
+ readonly suppressedCount: number;
43
+ /** True when at least one row lacked suppression metadata (pre-metadata journal). */
44
+ readonly rawOnly: boolean;
45
+ readonly lines: readonly number[];
46
+ }
47
+ export interface OperatorPageSummary {
48
+ readonly groups: readonly OperatorPageGroup[];
49
+ }
50
+ export declare const EMPTY_OPERATOR_PAGE_SUMMARY: OperatorPageSummary;
51
+ /** Projects a journal event onto a row; returns undefined for anything that is not an operator page. */
52
+ export declare function operatorPageRow(event: JournalEvent, line: number, runId: string): OperatorPageRow | undefined;
53
+ /** Folds rows into a summary. Pure: returns a new summary, never mutates inputs. */
54
+ export declare function foldOperatorPages(prior: OperatorPageSummary, rows: readonly OperatorPageRow[]): OperatorPageSummary;
55
+ /** Full replay from journal events. */
56
+ export declare function summarizeOperatorPages(events: readonly JournalEvent[], runId: string): OperatorPageSummary;
@@ -0,0 +1,68 @@
1
+ export const EMPTY_OPERATOR_PAGE_SUMMARY = { groups: [] };
2
+ const str = (v) => (typeof v === "string" ? v : undefined);
3
+ /** Projects a journal event onto a row; returns undefined for anything that is not an operator page. */
4
+ export function operatorPageRow(event, line, runId) {
5
+ if (event.event !== "operator-page" || event.taskId === undefined)
6
+ return undefined;
7
+ const d = event.data;
8
+ const permitted = Array.isArray(d.permittedActions) ? d.permittedActions.filter((a) => typeof a === "string") : undefined;
9
+ return {
10
+ line, ts: event.ts, runId, taskId: event.taskId,
11
+ park: str(d.park) ?? `${str(d.slot) ?? ""}#${typeof d.attempt === "number" ? d.attempt : ""}`,
12
+ status: str(d.status) ?? "unknown",
13
+ ...(typeof d.suppressed === "number" ? { suppressed: d.suppressed } : {}),
14
+ ...(str(d.blocker) !== undefined ? { blocker: str(d.blocker) } : {}),
15
+ ...(str(d.owner) !== undefined ? { owner: str(d.owner) } : {}),
16
+ ...(str(d.requiredAction) !== undefined ? { requiredAction: str(d.requiredAction) } : {}),
17
+ ...(permitted !== undefined ? { permittedActions: permitted } : {}),
18
+ };
19
+ }
20
+ const groupKey = (r) => [r.runId, r.taskId, r.park, r.status].join("\u0000");
21
+ const openKey = (r) => `${r.runId}\u0000${r.taskId}`;
22
+ /** Permitted actions compare as a SET: order and duplicates never open a new group. */
23
+ const actionSet = (a) => [...new Set(a ?? [])].sort().join("\u0000");
24
+ const sameDecision = (g, r) => g.key === groupKey(r)
25
+ && g.blocker === r.blocker && g.owner === r.owner && g.requiredAction === r.requiredAction
26
+ && actionSet(g.permittedActions) === actionSet(r.permittedActions);
27
+ /** Folds rows into a summary. Pure: returns a new summary, never mutates inputs. */
28
+ export function foldOperatorPages(prior, rows) {
29
+ const groups = [...prior.groups];
30
+ // One open group per run/task: ANY change in park, status or decision closes it, so a later
31
+ // transition can never merge backward into an earlier group across an intervening change.
32
+ const open = new Map();
33
+ groups.forEach((g, i) => open.set(openKey(g), i));
34
+ for (const row of rows) {
35
+ const key = groupKey(row);
36
+ const idx = open.get(openKey(row));
37
+ const g = idx === undefined ? undefined : groups[idx];
38
+ if (g !== undefined && sameDecision(g, row)) {
39
+ groups[idx] = {
40
+ ...g,
41
+ firstEvidenceAt: row.ts < g.firstEvidenceAt ? row.ts : g.firstEvidenceAt,
42
+ lastEvidenceAt: row.ts > g.lastEvidenceAt ? row.ts : g.lastEvidenceAt,
43
+ observedCount: g.observedCount + 1,
44
+ suppressedCount: g.suppressedCount + (row.suppressed ?? 0),
45
+ rawOnly: g.rawOnly || row.suppressed === undefined,
46
+ lines: [...g.lines, row.line],
47
+ };
48
+ continue;
49
+ }
50
+ groups.push({
51
+ key, runId: row.runId, taskId: row.taskId, park: row.park, status: row.status,
52
+ ...(row.blocker !== undefined ? { blocker: row.blocker } : {}),
53
+ ...(row.owner !== undefined ? { owner: row.owner } : {}),
54
+ ...(row.requiredAction !== undefined ? { requiredAction: row.requiredAction } : {}),
55
+ permittedActions: [...(row.permittedActions ?? [])],
56
+ firstEvidenceAt: row.ts, lastEvidenceAt: row.ts,
57
+ observedCount: 1, suppressedCount: row.suppressed ?? 0,
58
+ rawOnly: row.suppressed === undefined, lines: [row.line],
59
+ });
60
+ open.set(openKey(row), groups.length - 1);
61
+ }
62
+ return { groups };
63
+ }
64
+ /** Full replay from journal events. */
65
+ export function summarizeOperatorPages(events, runId) {
66
+ const rows = events.flatMap((e, i) => { const r = operatorPageRow(e, i + 1, runId); return r ? [r] : []; });
67
+ return foldOperatorPages(EMPTY_OPERATOR_PAGE_SUMMARY, rows);
68
+ }