tickmarkr 2.7.3 → 2.7.5

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.
@@ -7,7 +7,7 @@ import { allAdapters, probeAll, readDoctor, rolePools, writeDoctor } from "../..
7
7
  import { channelKey } from "../../adapters/types.js";
8
8
  import { loadConfig } from "../../config/config.js";
9
9
  import { captureBaseline, detectGateCommands, staleFileCountCommands } from "../../gates/baseline.js";
10
- import { getVerdictStore, runnerInputsHash } from "../../gates/cache.js";
10
+ import { getVerdictStore, lockfileKeyBytes, runnerInputsHash } from "../../gates/cache.js";
11
11
  import { modelProvider } from "../../gates/review.js";
12
12
  import { runGates } from "../../gates/run-gates.js";
13
13
  import { getTask, loadGraph } from "../../graph/graph.js";
@@ -15,7 +15,7 @@ import { GATE_NAMES } from "../../graph/schema.js";
15
15
  import { executionSignal } from "../../run/execution-budget.js";
16
16
  import { addDetachedWorktree, linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.js";
17
17
  import { foldOwedChecks, integrationMapped, owedCriteria, Journal, OWED_DISCHARGE_EVENT } from "../../run/journal.js";
18
- import { withRepositoryLease } from "../../run/lease.js";
18
+ import { describeLeaseWait, withRepositoryLease } from "../../run/lease.js";
19
19
  import { isPidLive } from "../../run/lock.js";
20
20
  import { recordLaunchServices } from "../../run/launchservices-check.js";
21
21
  /**
@@ -92,7 +92,7 @@ export function baselineCachePath(cwd, baseSha, commands) {
92
92
  const lockParts = [];
93
93
  for (const file of LOCKFILES) {
94
94
  const path = join(cwd, file);
95
- lockParts.push(file, existsSync(path) ? readFileSync(path) : "<absent>");
95
+ lockParts.push(file, existsSync(path) ? lockfileKeyBytes(file, readFileSync(path)) : "<absent>");
96
96
  }
97
97
  const commandParts = Object.entries(commands).sort(([a], [b]) => a.localeCompare(b)).map(([name, command]) => `${name}\0${command}\0`);
98
98
  return join(realpathSync(tmpdir()), "tickmarkr-verify", "cache", `baseline-${baseSha.slice(0, 12)}-${hashParts(lockParts)}-${hashParts(commandParts)}-${runnerInputsHash()}.json`);
@@ -529,9 +529,9 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
529
529
  return after ? rows.map((r) => ({ ...r, pass: false, details: `${refused(after)}\n${r.details}`, meta: { ...r.meta, subjectChanged: after } })) : rows;
530
530
  }, {
531
531
  signal: executionSignal(),
532
- onWait: (holder) => {
533
- console.error(`verify: waiting for the repository's runner lease held by pid ${holder.pid} in ${holder.cwd}`);
534
- recordJournal?.append("suite-wait", values.task ?? "VERIFY", { holderPid: holder.pid, holderCwd: holder.cwd });
532
+ onWait: (holder, blockers) => {
533
+ console.error(`verify: waiting for the repository's runner lease ${describeLeaseWait(holder, blockers)}`);
534
+ recordJournal?.append("suite-wait", values.task ?? "VERIFY", { holderPid: holder.pid, holderCwd: holder.cwd, ...(blockers ? { blockers } : {}) });
535
535
  },
536
536
  });
537
537
  results = [...results, ...suite].sort((a, b) => GATE_NAMES.indexOf(a.gate) - GATE_NAMES.indexOf(b.gate));
@@ -5,6 +5,7 @@ export declare const DEFAULT_VERDICT_CACHE_BOUND = 128;
5
5
  export declare function setVerdictCacheBoundForTests(bound: number | undefined): void;
6
6
  export declare function resetVerdictCacheBoundForTests(): void;
7
7
  export declare const LOCKFILES: readonly ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"];
8
+ export declare function lockfileKeyBytes(file: string, bytes: Buffer): Buffer | string;
8
9
  export declare function lockfileHash(worktree: string): string;
9
10
  export declare function canonicalJson(obj: unknown): string;
10
11
  export declare function baselineIdentity(baseline?: Baseline): string;
@@ -23,6 +23,32 @@ export const LOCKFILES = [
23
23
  "bun.lock",
24
24
  "bun.lockb",
25
25
  ];
26
+ // Queue row 122: a version-only bump rewrites an npm v2/v3 lockfile's root `version` and `packages[""].version` and nothing
27
+ // a dependency resolves from, so those two fields leave the key; every other byte keys as written. Other lockfile kinds, a
28
+ // v1 or unversioned npm lockfile, and an unparseable one key on their raw bytes, which can only miss, never falsely hit.
29
+ export function lockfileKeyBytes(file, bytes) {
30
+ if (file !== "package-lock.json" && file !== "npm-shrinkwrap.json")
31
+ return bytes;
32
+ let lock;
33
+ try {
34
+ lock = JSON.parse(bytes.toString("utf8"));
35
+ }
36
+ catch {
37
+ return bytes;
38
+ }
39
+ if (!lock || typeof lock !== "object" || Array.isArray(lock))
40
+ return bytes;
41
+ const { version: _version, ...rest } = lock;
42
+ if (rest.lockfileVersion !== 2 && rest.lockfileVersion !== 3)
43
+ return bytes;
44
+ const packages = rest.packages;
45
+ const own = packages?.[""];
46
+ if (own && typeof own === "object" && !Array.isArray(own)) {
47
+ const { version: _ownVersion, ...ownRest } = own;
48
+ rest.packages = { ...packages, "": ownRest };
49
+ }
50
+ return canonicalJson(rest);
51
+ }
26
52
  export function lockfileHash(worktree) {
27
53
  const hash = createHash("sha256");
28
54
  let found = false;
@@ -31,7 +57,7 @@ export function lockfileHash(worktree) {
31
57
  if (existsSync(p)) {
32
58
  found = true;
33
59
  try {
34
- hash.update(file).update("\0").update(readFileSync(p)).update("\0");
60
+ hash.update(file).update("\0").update(lockfileKeyBytes(file, readFileSync(p))).update("\0");
35
61
  }
36
62
  catch {
37
63
  hash.update(file).update("\0unreadable\0");
@@ -35,11 +35,19 @@ export interface RepositoryLeaseHolder {
35
35
  roots?: number[];
36
36
  rootBirths?: Record<string, string>;
37
37
  }
38
+ /** Queue row 120: a live process keeping a dead or released holder's lease, as the wait names it: pid, executable and
39
+ * cwd. Its arguments and environment are never printed or read (`ps -o comm=`, the census's cwd-only probe). */
40
+ export interface LeaseBlocker {
41
+ pid: number;
42
+ executable: string;
43
+ cwd?: string;
44
+ }
38
45
  export interface RepositoryLeaseOptions {
39
46
  pollMs?: number;
40
47
  signal?: AbortSignal;
41
- /** Called once per distinct holder the waiter is queued behind. */
42
- onWait?: (holder: RepositoryLeaseHolder) => void;
48
+ /** Called once per distinct holder the waiter is queued behind, and again whenever the set of processes keeping a
49
+ * dead holder's lease changes; `blockers` names that set (queue row 120). */
50
+ onWait?: (holder: RepositoryLeaseHolder, blockers?: readonly LeaseBlocker[]) => void;
43
51
  /** The token a descendant inherited; defaults to REPOSITORY_LEASE_TOKEN_ENV. */
44
52
  inherited?: string;
45
53
  /** Manifest jobs bridge capabilities through explicit child environments, never process.env. */
@@ -82,6 +90,15 @@ export declare const readHolder: (path: string) => RepositoryLeaseHolder | undef
82
90
  export declare function tryReserve(path: string, mine: RepositoryLeaseHolder): boolean;
83
91
  /** Exposed for recovery regressions; the standalone verify uses the combined critical section. */
84
92
  export declare const reclaimDead: (path: string, identity: string, pollMs?: number) => Promise<boolean>;
93
+ /** Each blocker's pid, executable and working directory, and NO argument: `ps -o comm=` (a command line joins argv with
94
+ * spaces, so no redaction could tell where a secret value ends, D-1710). The cwd comes from the census's cwd-only probe
95
+ * (async, batched, never rejecting, no environment read; queue row 103: no synchronous lsof on a waiting loop), imported
96
+ * lazily because suite-census imports this module. Diagnostics only: an unreadable process reads "(executable
97
+ * unreadable)", and this never throws into the wait. */
98
+ export declare function describeLeaseBlockers(pids: readonly number[]): Promise<LeaseBlocker[]>;
99
+ /** The wait's own words: the holder alone while it lives; once it has exited or released the lease, every process still
100
+ * keeping it. */
101
+ export declare function describeLeaseWait(holder: RepositoryLeaseHolder, blockers?: readonly LeaseBlocker[]): string;
85
102
  export declare function protectedProcesses(holder: RepositoryLeaseHolder, tokenEnv?: string, ownedOnly?: boolean): Promise<number[]>;
86
103
  export declare const repositoryLeaseEnvironment: (env: NodeJS.ProcessEnv) => NodeJS.ProcessEnv;
87
104
  /** Called synchronously at spawn, before any user callback, and only on an owned generation. */
package/dist/run/lease.js CHANGED
@@ -305,14 +305,47 @@ const reclaimDeadLocked = (path, identity) => {
305
305
  export const reclaimDead = async (path, identity, pollMs = 10) => withMutationLock(path, pollMs, undefined, () => reclaimDeadLocked(path, identity));
306
306
  const inspectAndReserve = async (path, mine) => {
307
307
  let occupant = readOccupant(path);
308
- if (occupant && (!occupant.holder || (!holderAlive(occupant.holder) && !(occupant.holder.protectOrphans && (await protectedProcesses(occupant.holder)).length)))) {
308
+ const dead = occupant?.holder !== undefined && !holderAlive(occupant.holder);
309
+ // A dead holder's protected tree is what keeps the lease; the waiter is told which processes those are (queue row 120).
310
+ const blocking = dead && occupant.holder.protectOrphans ? await protectedProcesses(occupant.holder) : [];
311
+ if (occupant && (!occupant.holder || (dead && !blocking.length))) {
309
312
  reclaimDeadLocked(path, occupant.identity);
310
313
  occupant = readOccupant(path);
311
314
  }
312
315
  if (!occupant && tryReserve(path, mine))
313
316
  return { acquired: true };
314
- return { acquired: false, holder: occupant?.holder };
317
+ return { acquired: false, holder: occupant?.holder, ...(blocking.length ? { blocking } : {}) };
315
318
  };
319
+ /** Each blocker's pid, executable and working directory, and NO argument: `ps -o comm=` (a command line joins argv with
320
+ * spaces, so no redaction could tell where a secret value ends, D-1710). The cwd comes from the census's cwd-only probe
321
+ * (async, batched, never rejecting, no environment read; queue row 103: no synchronous lsof on a waiting loop), imported
322
+ * lazily because suite-census imports this module. Diagnostics only: an unreadable process reads "(executable
323
+ * unreadable)", and this never throws into the wait. */
324
+ export async function describeLeaseBlockers(pids) {
325
+ const rows = await new Promise((done) => {
326
+ try {
327
+ execFile("ps", ["-o", "pid=,comm=", "-p", pids.join(",")], { encoding: "utf8", timeout: 5_000 }, (_error, stdout) => done(stdout ?? ""));
328
+ }
329
+ catch {
330
+ done("");
331
+ }
332
+ });
333
+ const executables = new Map(rows.split("\n").flatMap((line) => { const row = /^\s*(\d+)\s+(.*)$/.exec(line); return row ? [[Number(row[1]), row[2].trim()]] : []; }));
334
+ const cwds = await import("./suite-census.js").then(({ batchedProcessCwds }) => batchedProcessCwds(pids)).catch(() => undefined);
335
+ return pids.map((pid) => {
336
+ const cwd = cwds?.get(pid);
337
+ return { pid, executable: executables.get(pid) || "(executable unreadable)", ...(cwd ? { cwd } : {}) };
338
+ });
339
+ }
340
+ /** The wait's own words: the holder alone while it lives; once it has exited or released the lease, every process still
341
+ * keeping it. */
342
+ export function describeLeaseWait(holder, blockers) {
343
+ const held = `held by pid ${holder.pid} in ${holder.cwd}`;
344
+ if (!blockers?.length)
345
+ return held;
346
+ const named = blockers.map((b) => `pid ${b.pid} ${b.executable}${b.cwd ? ` (cwd ${b.cwd})` : ""}`).join("; ");
347
+ return `${held}, which has exited or released it; ${blockers.length} process(es) it started still run and keep the lease until they exit: ${named}`;
348
+ }
316
349
  // A durable capability is also a crash owner: census checks the exact generation's inherited
317
350
  // environment and recorded process groups, including live orphans reparented after owner death.
318
351
  // An unreadable census refuses reclamation/release. No dead-pid shortcut admits over a live tree.
@@ -386,6 +419,8 @@ export function registerRepositoryChild(pid) {
386
419
  }
387
420
  export const withFreshCommandLease = (command, run) => ownership.run({ token: "", active: false }, () => withCommandLease(command, run));
388
421
  const repositoryQueues = new Map();
422
+ /** Queue row 120: the queue head's last view of what keeps a dead holder's lease, so in-process followers name it too. */
423
+ const repositoryBlocking = new Map();
389
424
  /** The standalone wrapper uses the same command-then-repository order when a scheduler exists.
390
425
  * Without one (CLI/global setup), this remains exactly the outer file reservation API. */
391
426
  export function withRepositoryLease(cwd, run, opts = {}) {
@@ -426,10 +461,11 @@ async function repositoryLease(cwd, run, opts) {
426
461
  const ticket = Symbol("repository job");
427
462
  queue.add(ticket);
428
463
  let waitingOn;
429
- const waitOn = (holder) => {
430
- if (holder && holder.token !== waitingOn) {
431
- waitingOn = holder.token;
432
- opts.onWait?.(holder);
464
+ const waitOn = async (holder, blocking = []) => {
465
+ const key = holder && `${holder.token}\0${[...blocking].sort((a, b) => a - b).join(",")}`;
466
+ if (holder && key !== waitingOn) {
467
+ waitingOn = key;
468
+ opts.onWait?.(holder, blocking.length ? await describeLeaseBlockers(blocking) : undefined);
433
469
  }
434
470
  };
435
471
  try {
@@ -437,12 +473,19 @@ async function repositoryLease(cwd, run, opts) {
437
473
  opts.signal?.throwIfAborted();
438
474
  if (queue.values().next().value === ticket) {
439
475
  const attempt = await withMutationLock(path, pollMs, opts.signal, () => inspectAndReserve(path, mine));
440
- if (attempt.acquired)
476
+ if (attempt.acquired) {
477
+ repositoryBlocking.delete(path);
441
478
  break;
442
- waitOn(attempt.holder);
479
+ }
480
+ if (attempt.holder)
481
+ repositoryBlocking.set(path, { token: attempt.holder.token, blocking: attempt.blocking ?? [] });
482
+ await waitOn(attempt.holder, attempt.blocking);
483
+ }
484
+ else {
485
+ const holder = readHolder(path);
486
+ const seen = repositoryBlocking.get(path);
487
+ await waitOn(holder, holder && seen?.token === holder.token ? seen.blocking : []);
443
488
  }
444
- else
445
- waitOn(readHolder(path));
446
489
  await new Promise(wake => setTimeout(wake, pollMs));
447
490
  }
448
491
  const exported = process.env[REPOSITORY_LEASE_TOKEN_ENV];
@@ -495,8 +538,10 @@ async function repositoryLease(cwd, run, opts) {
495
538
  }
496
539
  finally {
497
540
  queue.delete(ticket);
498
- if (!queue.size)
541
+ if (!queue.size) {
499
542
  repositoryQueues.delete(path);
543
+ repositoryBlocking.delete(path);
544
+ }
500
545
  // A waiter removes no repository generation, including cancellation before acquisition.
501
546
  }
502
547
  }
@@ -6,6 +6,9 @@
6
6
  * not already probed — the census asks for a parent's cwd too. The answers feed countLiveSuites' existing lookups.
7
7
  */
8
8
  export type ProbeExec = (file: string, args: readonly string[]) => Promise<string>;
9
+ /** Each pid's working directory: /proc first, then ONE batched lsof for the rest; never rejects, and reads no process
10
+ * environment (the runner-lease wait names blockers through it, queue row 120). Answers land in `cwds`. */
11
+ export declare function batchedProcessCwds(pids: readonly number[], exec?: ProbeExec, proc?: ProcReader, cwds?: Map<number, string>): Promise<Map<number, string>>;
9
12
  /** The pids of every live (non-zombie) row of a `ps -o pid=,ppid=,state=,command=` snapshot whose command is a runner. */
10
13
  export declare function runnerPids(snapshot: string): number[];
11
14
  /** The /proc reads, injectable so the Linux branch is testable anywhere; each throws where /proc has no entry. */
@@ -16,6 +16,42 @@ const probeExec = (file, args) => new Promise((resolve) => {
16
16
  resolve("");
17
17
  }
18
18
  });
19
+ // D-1567 batch poisoning: lsof and ps fail the WHOLE command for one -p member they cannot take (a marker of 22 digits
20
+ // prints as 1e+21, 310 nines as Infinity; ps refuses one above int32), erasing every other pid's answer. The base probed
21
+ // per pid, so such a pid lost only its own answer: it stays unanswered here and never joins a batch.
22
+ const probeable = (pid) => Number.isSafeInteger(pid) && pid > 0 && pid <= 2_147_483_647;
23
+ /** Each pid's working directory: /proc first, then ONE batched lsof for the rest; never rejects, and reads no process
24
+ * environment (the runner-lease wait names blockers through it, queue row 120). Answers land in `cwds`. */
25
+ export async function batchedProcessCwds(pids, exec = probeExec, proc = procReader, cwds = new Map()) {
26
+ const rest = [];
27
+ for (const pid of pids.filter(probeable)) {
28
+ try {
29
+ cwds.set(pid, proc.cwd(pid));
30
+ }
31
+ catch {
32
+ rest.push(pid);
33
+ } // Darwin has no /proc
34
+ }
35
+ if (rest.length === 0)
36
+ return cwds;
37
+ let listing = "";
38
+ try {
39
+ listing = await exec("lsof", ["-a", "-p", rest.join(","), "-d", "cwd", "-Fn"]);
40
+ }
41
+ catch { /* the census never rejects */ }
42
+ let pid;
43
+ for (const line of listing.split("\n")) {
44
+ if (line.startsWith("p"))
45
+ pid = Number(line.slice(1));
46
+ else if (line.startsWith("n") && pid !== undefined) {
47
+ try {
48
+ cwds.set(pid, realpathSync(line.slice(1)));
49
+ }
50
+ catch { /* the directory is gone */ }
51
+ }
52
+ }
53
+ return cwds;
54
+ }
19
55
  /** The pids of every live (non-zombie) row of a `ps -o pid=,ppid=,state=,command=` snapshot whose command is a runner. */
20
56
  export function runnerPids(snapshot) {
21
57
  const pids = [];
@@ -41,34 +77,7 @@ export async function batchedProcessProbes(pids, exec = probeExec, proc = procRe
41
77
  const cwds = new Map();
42
78
  const parents = new Map();
43
79
  const parentIn = (text) => new RegExp(`(?:^|\\s)${SUITE_PARENT_ENV}=(\\d+)(?:\\s|$)`).exec(text)?.[1];
44
- // D-1567 batch poisoning: lsof and ps fail the WHOLE command for one -p member they cannot take (a marker of 22 digits
45
- // prints as 1e+21, 310 nines as Infinity; ps refuses one above int32), erasing every other pid's answer. The base probed
46
- // per pid, so such a pid lost only its own answer: it stays unanswered here and never joins a batch.
47
- const probeable = (pid) => Number.isSafeInteger(pid) && pid > 0 && pid <= 2_147_483_647;
48
- const probeCwds = async (wanted) => {
49
- const rest = [];
50
- for (const pid of wanted.filter(probeable)) {
51
- try {
52
- cwds.set(pid, proc.cwd(pid));
53
- }
54
- catch {
55
- rest.push(pid);
56
- } // Darwin has no /proc
57
- }
58
- if (rest.length === 0)
59
- return;
60
- let pid;
61
- for (const line of (await ask("lsof", ["-a", "-p", rest.join(","), "-d", "cwd", "-Fn"])).split("\n")) {
62
- if (line.startsWith("p"))
63
- pid = Number(line.slice(1));
64
- else if (line.startsWith("n") && pid !== undefined) {
65
- try {
66
- cwds.set(pid, realpathSync(line.slice(1)));
67
- }
68
- catch { /* the directory is gone */ }
69
- }
70
- }
71
- };
80
+ const probeCwds = (wanted) => batchedProcessCwds(wanted, ask, proc, cwds);
72
81
  const probeParents = async (wanted) => {
73
82
  const rest = [];
74
83
  for (const pid of wanted.filter(probeable)) {
@@ -1,6 +1,6 @@
1
1
  import type { ShellReceipt } from "./protocol.js";
2
2
  import type { GateEvidenceOptions } from "../gates/baseline.js";
3
- import { type RepositoryLeaseHolder } from "./lease.js";
3
+ import { type LeaseBlocker, type RepositoryLeaseHolder } from "./lease.js";
4
4
  export interface VerificationPhase {
5
5
  phase: "discovery" | "first-pass" | "continuation-discovery" | "continuation";
6
6
  nonce: string;
@@ -33,7 +33,9 @@ export interface VerificationJobReport {
33
33
  releasedAt?: number;
34
34
  reservationReused?: boolean;
35
35
  reservation?: RepositoryLeaseHolder;
36
- waitingOn?: RepositoryLeaseHolder;
36
+ waitingOn?: RepositoryLeaseHolder & {
37
+ blockers?: readonly LeaseBlocker[];
38
+ };
37
39
  state: "queued" | "running" | "completed" | "failed" | "cancelled";
38
40
  phases: VerificationPhase[];
39
41
  }
@@ -132,7 +132,7 @@ export async function withVerificationJob(command, cwd, reportPath, evidence, ru
132
132
  }
133
133
  }));
134
134
  }, { isolated: true, protectOrphans: true, independent: !!jobs.getStore(), signal, pollMs: 20,
135
- onWait: holder => { report.waitingOn = { ...holder }; job.persist(); },
135
+ onWait: (holder, blockers) => { report.waitingOn = { ...holder, ...(blockers ? { blockers } : {}) }; job.persist(); },
136
136
  onAdmission: holder => { owner = holder; report.repositoryAdmittedAt = clock(); report.reservation = { ...holder }; job.persist(); },
137
137
  onRelease: () => { report.reapedAt ??= clock(); report.repositoryReleasedAt = clock(); job.persist(); } }));
138
138
  report.releasedAt = clock();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.7.3",
3
+ "version": "2.7.5",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",