tickmarkr 2.1.1 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/run/git.js CHANGED
@@ -64,6 +64,31 @@ export const runWithForkBudget = (concurrency, fn) => forkBudget.run(String(deri
64
64
  export const resolvedForkCap = () => forkBudget.getStore() ?? DEFAULT_FORK_CAP;
65
65
  /** The shipped shell ceiling: the fallback every caller gets when nothing measured a better one. */
66
66
  export const DEFAULT_SHELL_TIMEOUT_MS = 600000;
67
+ /**
68
+ * OBS-688: every gate, every baseline capture and every tip verification reaches the machine through
69
+ * this one seam, and the seam took its spawn from the standard library directly — so the one failure
70
+ * it must handle, the kernel REFUSING the fork, was unreachable from a fixture and could only be
71
+ * described. It is injectable here for exactly that reason; production always holds `spawn` itself.
72
+ * Undefined-until-set, never captured at module load: the standard library binding stays read at
73
+ * CALL time, exactly as before this seam existed, so a suite that mocks `node:child_process` without
74
+ * a `spawn` export still imports this module (tests/adapters/pi-auth.test.ts does).
75
+ */
76
+ let spawnChild;
77
+ export const setSpawnForTests = (fn) => { spawnChild = fn; };
78
+ export const resetSpawnForTests = () => { spawnChild = undefined; };
79
+ /**
80
+ * A refusal is NOT evidence about the work: the command never started, so nothing ran, and a retry
81
+ * cannot repeat a side effect. That argument is the whole safety case for retrying here, and it
82
+ * holds for exactly one closed case — the kernel refused the fork for a temporary resource shortage
83
+ * (EAGAIN: the fork table is full, which a gate burst does to a box twice in one night). Every
84
+ * other spawn error is a standing fact about the machine — a missing interpreter above all — and
85
+ * retrying it buys nothing while delaying every genuine failure by the whole backoff, so it returns
86
+ * on the first read. The retry is also gated on the child having produced NO byte and never having
87
+ * emitted `spawn`: past either, a command has run and re-running it is a side effect, never a retry.
88
+ */
89
+ const RETRYABLE_SPAWN_CODE = "EAGAIN";
90
+ export const SPAWN_ATTEMPT_LIMIT = 4;
91
+ export const SPAWN_RETRY_BACKOFF_MS = 50;
67
92
  // stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
68
93
  // (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
69
94
  function shell(cmd, cwd, timeoutMs, login) {
@@ -77,14 +102,14 @@ function shell(cmd, cwd, timeoutMs, login) {
77
102
  // OBS-110: apply the run's own fork cap only when the operator has not already set one.
78
103
  if (!(FORK_CAP_ENV in env))
79
104
  env[FORK_CAP_ENV] = resolvedForkCap();
80
- return new Promise((resolve) => {
105
+ const attempt = () => new Promise((resolve) => {
81
106
  const startedAt = Date.now();
82
107
  // detached: bash gets its own process group so a timeout can kill the whole tree —
83
108
  // SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
84
109
  // open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
85
- const p = spawn("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
110
+ const p = (spawnChild ?? spawn)("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
86
111
  let stdout = "", stderr = "";
87
- let timedOut = false, done = false;
112
+ let timedOut = false, done = false, started = false;
88
113
  const finish = (code, err) => {
89
114
  if (done)
90
115
  return;
@@ -101,14 +126,37 @@ function shell(cmd, cwd, timeoutMs, login) {
101
126
  p.kill("SIGKILL");
102
127
  }
103
128
  }, timeoutMs);
129
+ p.on("spawn", () => { started = true; }); // the command exists from here on — never retryable past it
104
130
  p.stdout.on("data", (d) => (stdout += d));
105
131
  p.stderr.on("data", (d) => (stderr += d));
106
- p.on("error", (e) => finish(127, String(e)));
132
+ p.on("error", (e) => {
133
+ if (!done && !started && !stdout && !stderr && e.code === RETRYABLE_SPAWN_CODE) {
134
+ done = true;
135
+ clearTimeout(timer);
136
+ resolve({ refused: e });
137
+ return;
138
+ }
139
+ finish(127, String(e));
140
+ });
107
141
  p.on("close", (code) => finish(code ?? 1));
108
142
  // "close" waits for stdio to drain; a surviving pipe-holder must not outlive the timeout
109
143
  p.on("exit", (code) => { if (timedOut)
110
144
  finish(code ?? 1); });
111
145
  });
146
+ return (async () => {
147
+ const startedAt = Date.now();
148
+ for (let n = 1;; n++) {
149
+ const r = await attempt();
150
+ if (!("refused" in r))
151
+ return r;
152
+ // Bounded, and the bound is what makes a persisting shortage a REPORTED failure rather than a
153
+ // wedged daemon: past it the caller gets the refusal's own text under exit 127, as before.
154
+ if (n >= SPAWN_ATTEMPT_LIMIT) {
155
+ return { code: 127, stdout: "", stderr: String(r.refused), durationMs: Date.now() - startedAt };
156
+ }
157
+ await new Promise((wake) => setTimeout(wake, SPAWN_RETRY_BACKOFF_MS * n));
158
+ }
159
+ })();
112
160
  }
113
161
  export function sh(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
114
162
  return shell(cmd, cwd, timeoutMs, true);
@@ -10,6 +10,7 @@ export interface Inspection {
10
10
  ino: number;
11
11
  }
12
12
  export declare function shouldRefuse(i: Pick<Inspection, "garbage" | "dead">): boolean;
13
+ export declare function isPidLive(pid: number): boolean;
13
14
  export declare function acquireRunLock(repoRoot: string, runId: string): {
14
15
  reclaimed?: {
15
16
  pid: number;
package/dist/run/lock.js CHANGED
@@ -45,6 +45,23 @@ process.once("exit", () => { if (heldPath)
45
45
  export function shouldRefuse(i) {
46
46
  return i.garbage || !i.dead;
47
47
  }
48
+ // LOCK-04, PID-SCOPED: the same decision table, in the shape a caller holding only a pid can consume.
49
+ // Every other liveness export here takes a repository root, so a reader with a pid off a journal row
50
+ // had no seam to reach and wrote the four lines itself — twice. One copy was faithful; the other
51
+ // treated ANY thrown error as death, so a daemon owned by another user (EPERM) read dead there and
52
+ // alive here. A rule that forbids a second `process.kill(pid, 0)` without exporting a usable
53
+ // predicate produces exactly those copies, so this is the predicate. no throw ⇒ ALIVE; ESRCH ⇒ the
54
+ // only proof-positive death; ANY other errno (EPERM = alive-but-not-ours, EINVAL, …) ⇒ ALIVE,
55
+ // because none of them is evidence of death and this table fails closed toward alive.
56
+ export function isPidLive(pid) {
57
+ try {
58
+ process.kill(pid, 0);
59
+ return true;
60
+ }
61
+ catch (k) {
62
+ return k.code !== "ESRCH";
63
+ }
64
+ }
48
65
  // statSync throws ENOENT when no lock exists — callers treat that as "not held".
49
66
  function inspect(p) {
50
67
  const st = statSync(p); // single stat: both the heartbeat mtime and the reclaim-guard inode
@@ -53,16 +70,8 @@ function inspect(p) {
53
70
  const parsed = PayloadSchema.safeParse(readPayload(p));
54
71
  const garbage = !parsed.success; // LOCK-01: its own state — shouldRefuse refuses it unconditionally; only `tickmarkr unlock` removes it
55
72
  const pid = parsed.success ? parsed.data.pid : undefined;
56
- let dead = pid === undefined; // harmless fallback for the garbage row — garbage short-circuits shouldRefuse before this is read
57
- if (pid !== undefined) {
58
- try {
59
- process.kill(pid, 0);
60
- dead = false;
61
- } // no throw ⇒ ALIVE
62
- catch (k) {
63
- dead = k.code === "ESRCH";
64
- } // ESRCH ⇒ dead; EPERM ⇒ ALIVE
65
- }
73
+ // harmless fallback for the garbage row — garbage short-circuits shouldRefuse before this is read
74
+ const dead = pid === undefined ? true : !isPidLive(pid);
66
75
  return { pid, runId: parsed.success ? parsed.data.runId : undefined, garbage, dead, expired, mtimeMs, ino: st.ino };
67
76
  }
68
77
  function readPayload(p) {
@@ -222,9 +231,10 @@ export async function acquireApprovalSerialization(repoRoot, runId) {
222
231
  }
223
232
  }
224
233
  // LOCK-04: the owner the decision table sees, read-only, for callers that need the pid as well as
225
- // the answer. inspect() owns pid-liveness (ESRCH dead / EPERM alive / garbage fail-closed); a second
226
- // `process.kill(pid, 0)` anywhere else would be a second copy of that rule, free to drift. undefined
227
- // ⇒ no lock at all — never conflate that with a lock whose recorded owner is dead.
234
+ // the answer. inspect() owns pid-liveness (ESRCH dead / EPERM alive / garbage fail-closed) and reads
235
+ // it from isPidLive above; a second `process.kill(pid, 0)` anywhere else would be a second copy of
236
+ // that rule, free to drift — a caller holding only a pid consumes isPidLive, never its own probe.
237
+ // undefined ⇒ no lock at all — never conflate that with a lock whose recorded owner is dead.
228
238
  export function runLockOwner(repoRoot) {
229
239
  let insp;
230
240
  try {
@@ -3,24 +3,30 @@ export declare const SUPERVISION_BEAT_MS = 10000;
3
3
  /** Ceiling before a beat reads STALE: SIX beats, lock.ts's ratio — five may be missed before alarm. */
4
4
  export declare const SUPERVISION_STALE_MS: number;
5
5
  export declare const SUPERVISION_FUTURE_GRACE_MS = 1000;
6
- export declare const SUPERVISION_TIERS: readonly ["orchestrator", "overseer", "watch"];
6
+ export declare const SUPERVISION_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch"];
7
7
  export type SupervisionTier = (typeof SUPERVISION_TIERS)[number];
8
+ export declare const SUPERVISION_SEAT_TIERS: readonly ["orchestrator-context", "overseer-context"];
9
+ export type SeatTier = (typeof SUPERVISION_SEAT_TIERS)[number];
10
+ /** Does this tier's record have to name the seat it speaks for? */
11
+ export declare const isSeatTier: (tier: string) => tier is SeatTier;
8
12
  export type SupervisionState = "ABSENT" | "STALE" | "ARMED" | "UNREADABLE" | "DISARMED";
9
13
  export interface TierLiveness {
10
14
  tier: SupervisionTier;
11
15
  state: SupervisionState;
12
16
  /** Absent for ABSENT, UNREADABLE and DISARMED — no beat to age. Always present for STALE and ARMED. */
13
17
  beatAgeMs?: number;
18
+ /** The seat the record names, when it names one. Always present on a seat tier that is not ABSENT. */
19
+ seat?: string;
14
20
  }
15
21
  export declare const supervisionBeatPath: (repoRoot: string, tier: SupervisionTier) => string;
16
22
  /** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
17
23
  export declare const supervisionStandDownPath: (repoRoot: string, tier: SupervisionTier) => string;
18
- export declare function beatSupervision(repoRoot: string, tier: SupervisionTier): void;
24
+ export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string): void;
19
25
  /** Handle a watcher holds for as long as it is supervising; disarm stands it down and is idempotent. */
20
26
  export interface ArmedSupervision {
21
27
  disarm: () => void;
22
28
  }
23
- export declare function armSupervision(repoRoot: string, tier: SupervisionTier, beatMs?: number): ArmedSupervision;
29
+ export declare function armSupervision(repoRoot: string, tier: SupervisionTier, beatMs?: number, seat?: string): ArmedSupervision;
24
30
  export declare function readTierLiveness(repoRoot: string, tier: SupervisionTier, now?: number): TierLiveness;
25
31
  export declare function supervisionStatus(repoRoot: string, tier: SupervisionTier, now?: number): TierLiveness;
26
32
  /** Every KNOWN tier, always — a tier omitted from this list would read as one that is fine. */
@@ -29,7 +29,22 @@ export const SUPERVISION_FUTURE_GRACE_MS = 1_000;
29
29
  // The supervision seats this harness has. An unlisted tier is an INVISIBLE tier, which is the failure
30
30
  // mode itself — an auditor read "no watchers were ever armed" off a surface that named none. Adding a
31
31
  // seat means adding it here, and `status` then renders it whether or not it has ever beaten.
32
- export const SUPERVISION_TIERS = ["orchestrator", "overseer", "watch"];
32
+ // SUP-05: CONTEXT is per SEAT, so its tiers are per seat too. One shared `context` tier would be read
33
+ // by both supervising seats and beaten by whichever of them still had a watcher, so a live overseer
34
+ // watcher would render the dead orchestrator one as armed — the mask this whole instrument exists to
35
+ // remove. The enumeration is CLOSED at one tier per supervising seat: orchestrator and overseer.
36
+ // `watch` beats nowhere on this tree and stays ABSENT, which is what ABSENT means.
37
+ export const SUPERVISION_TIERS = [
38
+ "orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch",
39
+ ];
40
+ // The tiers whose records must NAME the seat behind them. A one-shot `tickmarkr beat` records a pid
41
+ // that has already exited by the time anyone reads it, and an instant — nothing a reader can attribute
42
+ // to a seat. Measured 2026-08-26: a consult seat of another tier ran the documented beat loop and the
43
+ // board read that tier armed with no seat of that tier having armed anything. ARMED-and-seatless reads
44
+ // as coverage, which is worse than ABSENT, so on these tiers a record that names no seat is not a beat.
45
+ export const SUPERVISION_SEAT_TIERS = ["orchestrator-context", "overseer-context"];
46
+ /** Does this tier's record have to name the seat it speaks for? */
47
+ export const isSeatTier = (tier) => SUPERVISION_SEAT_TIERS.includes(tier);
33
48
  // PURE path math: stateDirName, never tickmarkrDir — the latter mkdirs the state dir and writes its
34
49
  // .gitignore, so routing a READER through it would make status create the very tree it reports on.
35
50
  export const supervisionBeatPath = (repoRoot, tier) => join(repoRoot, stateDirName(repoRoot), "supervision", `${tier}.beat`);
@@ -38,11 +53,16 @@ export const supervisionStandDownPath = (repoRoot, tier) => join(repoRoot, state
38
53
  // WRITER — a watcher's own call, on its own tier, every SUPERVISION_BEAT_MS. Never a reader's: the
39
54
  // purity fence (status --watch leaves the state dir byte-identical) is the test that catches a reader
40
55
  // that beats on the watcher's behalf, which would report every dead tier as healthy.
41
- export function beatSupervision(repoRoot, tier) {
56
+ export function beatSupervision(repoRoot, tier, seat) {
57
+ // A seat tier may not be armed anonymously, and the refusal belongs HERE rather than only in the
58
+ // verb: any caller that could write a seatless record could arm a tier nobody occupies.
59
+ if (isSeatTier(tier) && !seat?.trim()) {
60
+ throw new Error(`${tier} is a per-seat tier — a beat must declare the seat identity it speaks for`);
61
+ }
42
62
  tickmarkrDir(repoRoot); // the write path DOES create — beats land inside the gitignored state dir
43
63
  const p = supervisionBeatPath(repoRoot, tier);
44
64
  mkdirSync(dirname(p), { recursive: true });
45
- writeFileSync(p, JSON.stringify({ tier, pid: process.pid, beatAt: new Date().toISOString() }) + "\n");
65
+ writeFileSync(p, JSON.stringify({ tier, ...(seat ? { seat } : {}), pid: process.pid, beatAt: new Date().toISOString() }) + "\n");
46
66
  }
47
67
  // THE WATCHER-FACING ENTRY POINT — the loop SUPERVISION_BEAT_MS actually drives. A supervising seat
48
68
  // calls this once at the top of its watch and holds the handle for the duration; a seat that dies,
@@ -57,7 +77,7 @@ export function beatSupervision(repoRoot, tier) {
57
77
  //
58
78
  // Arming CLEARS any prior stand-down record: a tier that stood down and armed again is armed, and a
59
79
  // marker left behind by the last run would otherwise report the live one as stood down forever.
60
- export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS) {
80
+ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, seat) {
61
81
  // The clearing gets its OWN try: a cleanup that cannot complete (a directory dropped at the marker
62
82
  // path, a permission) must not cost the first beat. Sharing one try did exactly that — the tier armed
63
83
  // with NO beat while the old marker stayed on disk, the one combination that reports a live watcher
@@ -67,12 +87,12 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS) {
67
87
  }
68
88
  catch { /* uncleared: the reader validates the marker and a newer beat outranks it — never masked */ }
69
89
  try {
70
- beatSupervision(repoRoot, tier);
90
+ beatSupervision(repoRoot, tier, seat);
71
91
  }
72
- catch { /* repo gone / disk full — the tier reads ABSENT rather than crashing its watcher */ }
92
+ catch { /* repo gone / disk full / no seat — the tier reads ABSENT rather than crashing its watcher */ }
73
93
  const timer = setInterval(() => {
74
94
  try {
75
- beatSupervision(repoRoot, tier);
95
+ beatSupervision(repoRoot, tier, seat);
76
96
  }
77
97
  catch { /* repo gone / disk full — let the beat expire */ }
78
98
  }, beatMs);
@@ -94,7 +114,9 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS) {
94
114
  const tmp = `${p}.${process.pid}.tmp`;
95
115
  try {
96
116
  mkdirSync(dirname(p), { recursive: true });
97
- writeFileSync(tmp, JSON.stringify({ tier, pid: process.pid, disarmedAt: new Date().toISOString() }) + "\n");
117
+ writeFileSync(tmp, JSON.stringify({
118
+ tier, ...(seat ? { seat } : {}), pid: process.pid, disarmedAt: new Date().toISOString(),
119
+ }) + "\n");
98
120
  renameSync(tmp, p);
99
121
  }
100
122
  catch { /* unrecordable stand-down ages out as STALE — pessimistic, which is the safe way to fail */ }
@@ -109,25 +131,48 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS) {
109
131
  // never silently ARMED. Callers wanting the TIER's state want supervisionStatus below; this answers
110
132
  // the narrower question "does the beat say alive", which is all a beat can ever say.
111
133
  export function readTierLiveness(repoRoot, tier, now = Date.now()) {
112
- return beatLiveness(tier, beatMtimeMs(repoRoot, tier), now);
134
+ return beatLiveness(tier, readBeat(repoRoot, tier), now);
113
135
  }
114
136
  // The beat's inode, or why there is no age to derive from it. Split out so the stand-down ranking below
115
137
  // reads the SAME mtime this derivation does rather than a second, later stat of a moving record.
116
- function beatMtimeMs(repoRoot, tier) {
138
+ function readBeat(repoRoot, tier) {
139
+ const p = supervisionBeatPath(repoRoot, tier);
117
140
  let st;
118
141
  try {
119
- st = statSync(supervisionBeatPath(repoRoot, tier));
142
+ st = statSync(p);
120
143
  }
121
144
  catch (e) {
122
145
  const code = e.code;
123
146
  // never armed — reachable without anything having been written
124
147
  return code === "ENOENT" || code === "ENOTDIR" ? "ABSENT" : "UNREADABLE";
125
148
  }
126
- return st.isFile() ? st.mtimeMs : "UNREADABLE"; // a directory at the beat path is not a heartbeat
149
+ if (!st.isFile())
150
+ return "UNREADABLE"; // a directory at the beat path is not a heartbeat
151
+ // SUP-05: the payload is read for ONE field — the seat — and never for the age, which stays the
152
+ // mtime. On the legacy tiers an unparseable payload is still a beat (a record that cannot be parsed
153
+ // is not evidence that nobody armed the tier). On a SEAT tier it is the opposite: a record naming no
154
+ // seat leaves the tier armed and unattributable, which reads as coverage no seat is providing, so
155
+ // it is UNREADABLE — something is there and no beat any reader can attribute comes out of it.
156
+ const seat = beatSeat(p);
157
+ if (isSeatTier(tier) && seat === undefined)
158
+ return "UNREADABLE";
159
+ return { mtimeMs: st.mtimeMs, ...(seat !== undefined ? { seat } : {}) };
160
+ }
161
+ /** The seat a record declares, or undefined for any record that declares none this reader can use. */
162
+ function beatSeat(path) {
163
+ try {
164
+ const rec = JSON.parse(readFileSync(path, "utf8"));
165
+ return typeof rec?.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
166
+ }
167
+ catch {
168
+ return undefined;
169
+ } // unparseable bytes name no seat — the caller decides what that means
127
170
  }
128
- function beatLiveness(tier, mtimeMs, now) {
129
- if (typeof mtimeMs !== "number")
130
- return { tier, state: mtimeMs };
171
+ function beatLiveness(tier, beat, now) {
172
+ if (typeof beat !== "object")
173
+ return { tier, state: beat };
174
+ const { mtimeMs, seat } = beat;
175
+ const named = seat !== undefined ? { seat } : {};
131
176
  const age = now - mtimeMs;
132
177
  // SUP-03: a beat dated AHEAD of the reader's clock past the grace above is not a fresh beat — it is
133
178
  // a record whose age cannot be derived. Clamping it to zero (what this line used to do) made any
@@ -138,7 +183,7 @@ function beatLiveness(tier, mtimeMs, now) {
138
183
  if (age < -SUPERVISION_FUTURE_GRACE_MS)
139
184
  return { tier, state: "UNREADABLE" };
140
185
  const beatAgeMs = Math.max(0, age); // inside the grace: the two clocks' resolutions, not skew
141
- return { tier, state: beatAgeMs > SUPERVISION_STALE_MS ? "STALE" : "ARMED", beatAgeMs };
186
+ return { tier, state: beatAgeMs > SUPERVISION_STALE_MS ? "STALE" : "ARMED", beatAgeMs, ...named };
142
187
  }
143
188
  // A stand-down is only what a watcher RECORDED, so the record has to READ as one: a regular file whose
144
189
  // payload names this tier and the instant it stood down. Path existence is not proof — a directory, a
@@ -157,33 +202,44 @@ function readStandDown(repoRoot, tier) {
157
202
  }
158
203
  if (!st.isFile())
159
204
  return "UNREADABLE";
205
+ let seat;
160
206
  try {
161
207
  const rec = JSON.parse(readFileSync(p, "utf8"));
162
208
  if (rec?.tier !== tier)
163
209
  return "UNREADABLE";
164
210
  if (typeof rec.disarmedAt !== "string" || Number.isNaN(Date.parse(rec.disarmedAt)))
165
211
  return "UNREADABLE";
212
+ seat = typeof rec.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
213
+ // A seat tier's hand-off names WHICH seat left, on the same rule as its beat: an anonymous
214
+ // stand-down on a per-seat tier says a watcher left without saying whose, so it is no record.
215
+ if (isSeatTier(tier) && seat === undefined)
216
+ return "UNREADABLE";
166
217
  }
167
218
  catch {
168
219
  return "UNREADABLE";
169
220
  } // unparseable or unreadable bytes — not a stand-down anyone can read
170
- return { mtimeMs: st.mtimeMs };
221
+ return { mtimeMs: st.mtimeMs, ...(seat !== undefined ? { seat } : {}) };
171
222
  }
172
223
  // THE TIER'S STATE — what every surface and every operator reads. A valid stand-down outranks the beat:
173
224
  // the watcher that wrote it is gone ON PURPOSE, and its last beat ages out exactly like a dead one's
174
225
  // would. It outranks the beat it FOLLOWED and no other — a beat stamped after the marker was written by
175
226
  // a watcher that armed again, so a marker some failed cleanup left behind can never mask a live tier.
176
227
  export function supervisionStatus(repoRoot, tier, now = Date.now()) {
177
- const beat = beatMtimeMs(repoRoot, tier);
228
+ const beat = readBeat(repoRoot, tier);
178
229
  const standDown = readStandDown(repoRoot, tier);
179
230
  if (standDown === "UNREADABLE")
180
231
  return { tier, state: "UNREADABLE" };
181
- if (standDown !== "NONE" && !(typeof beat === "number" && beat > standDown.mtimeMs)) {
182
- return { tier, state: "DISARMED" };
232
+ if (standDown !== "NONE" && !(typeof beat === "object" && beat.mtimeMs > standDown.mtimeMs)) {
233
+ // the seat that stood down is named by the marker, falling back to whatever its last beat named
234
+ const seat = standDown.seat ?? (typeof beat === "object" ? beat.seat : undefined);
235
+ return { tier, state: "DISARMED", ...(seat !== undefined ? { seat } : {}) };
183
236
  }
184
237
  return beatLiveness(tier, beat, now);
185
238
  }
186
239
  /** Every KNOWN tier, always — a tier omitted from this list would read as one that is fine. */
187
240
  export const readSupervision = (repoRoot, now = Date.now()) => SUPERVISION_TIERS.map((tier) => supervisionStatus(repoRoot, tier, now));
188
241
  /** One line, one word per tier. Shared by both status surfaces so neither can render a state twice. */
189
- export const supervisionText = (tiers, divider = " · ") => `supervision: ${tiers.map((t) => `${t.tier} ${t.state}`).join(divider)}`;
242
+ // The seat is rendered BESIDE the state, never instead of it: `overseer-context ARMED (w3:p2)` says
243
+ // both that something is beating and who is behind it, which is the pair an operator needs to act. A
244
+ // row with no seat to name renders exactly as it always did.
245
+ export const supervisionText = (tiers, divider = " · ") => `supervision: ${tiers.map((t) => `${t.tier} ${t.state}${t.seat ? ` (${t.seat})` : ""}`).join(divider)}`;
@@ -1,4 +1,5 @@
1
1
  import { channelKey } from "../../adapters/types.js";
2
+ import { isPidLive } from "../../run/lock.js";
2
3
  export const SPARKLINE_BUCKET_WINDOW = 12;
3
4
  const MINUTE_MS = 60_000;
4
5
  const SPARKLINE_BUCKET_WIDTH_LADDER_MINUTES = [
@@ -85,15 +86,6 @@ function elapsedReading(first, last) {
85
86
  const padded = (part) => String(part).padStart(2, "0");
86
87
  return `${padded(hours)}:${padded(minutes)}:${padded(remainder)}`;
87
88
  }
88
- function defaultDaemonLiveness(pid) {
89
- try {
90
- process.kill(pid, 0);
91
- return true;
92
- }
93
- catch {
94
- return false;
95
- }
96
- }
97
89
  function latestLifecycle(events) {
98
90
  for (let index = events.length - 1; index >= 0; index -= 1) {
99
91
  const event = events[index].event;
@@ -700,7 +692,10 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
700
692
  const pid = daemonPid(events);
701
693
  const alive = lifecycle !== "active"
702
694
  ? false
703
- : pid !== undefined && (options.isDaemonAlive ?? defaultDaemonLiveness)(pid);
695
+ // lock.ts's isPidLive is the ONE pid-liveness table. The local copy this replaced treated ANY
696
+ // thrown probe error as death, so a daemon owned by another user (EPERM) read dead here and
697
+ // alive in the lock — the cockpit called a live run interrupted.
698
+ : pid !== undefined && (options.isDaemonAlive ?? isPidLive)(pid);
704
699
  const interrupted = !alive;
705
700
  const tasks = deriveTasks(events, interrupted);
706
701
  const taskFacts = [...tasks.values()];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,7 @@
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
34
34
  "build:clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && npm run build",
35
- "lint": "oxlint src tests scripts",
35
+ "lint": "oxlint src tests scripts --format stylish",
36
36
  "pretest": "npm run build",
37
37
  "test": "vitest run",
38
38
  "test:coverage": "vitest run --coverage",
@@ -45,10 +45,27 @@ Before `tickmarkr compile` or `tickmarkr run`, compare the installed binary agai
45
45
 
46
46
  1. Run `tickmarkr version` (one line, machine-parseable).
47
47
  2. Read the `version` field from the repository's `package.json`.
48
- 3. If the binary is **older on major.minor** than the repo (e.g. binary `1.36.x` vs repo `1.38.x`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`) or link the repo binary. Do not compile, plan, or run on hope.
48
+ 3. If the binary and repository do not **agree on the entire version** (including the patch; e.g. binary `2.1.0` vs repo `2.1.1`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`) or link the repo binary. Do not compile, plan, or run on hope.
49
49
 
50
50
  A stale binary silently skips daemon gates shipped in newer releases — the v1.38 run exposed this when a global `1.36.0` binary missed the daemon tip-verify gate entirely (OBS-38). Preflight failure is always stop-and-report; never proceed-and-hope.
51
51
 
52
+ ### No run may be live in THIS repository
53
+
54
+ The version check above is only half the preflight. Before `compile` or `run`, confirm no run is already
55
+ live **in this repository**:
56
+
57
+ 1. Lead with this repository's own `.tickmarkr/graph.lock`. Read its recorded holder pid.
58
+ 2. Treat the lock as held by a LIVE run until `kill -0 <pid>` proves that holder dead.
59
+ 3. **Never require a machine-wide process pattern to be empty.** A lawful run in another repository — or
60
+ the probing shell's own argv — matches such a pattern, so an empty result is not evidence of safety
61
+ and a non-empty one is not evidence of danger.
62
+ 4. If you use a process probe as secondary evidence, exclude the probing process itself, resolve every
63
+ candidate's own working directory (for example `lsof -a -p <pid> -d cwd`), and count only candidates
64
+ whose cwd is **this repository root**.
65
+
66
+ The invariant this protects is per-repository — *never run two tickmarkr runs in the same repository
67
+ concurrently* — so a machine-wide check answers a question nobody asked and blocks work that is lawful.
68
+
52
69
  ## Verified handoffs (agent-to-agent messaging)
53
70
 
54
71
  When relaying missions between agents in a multi-agent terminal, **never use bare send-text** (`herdr agent send` / pane send-text) — it writes text without pressing Enter, so handoffs sit unsubmitted (OBS-39).
@@ -66,6 +83,6 @@ After sending, **confirm delivery** by reading the target pane and verifying the
66
83
  2. **Compile** — run `tickmarkr compile <spec-or-directory>`. Fix source-spec defects instead of editing the generated graph.
67
84
  3. **Plan** — run `tickmarkr plan`. Review routes, capability-floor warnings, and human gates before execution.
68
85
  4. **Run** — run `tickmarkr run`. Watch the run journal for its terminal event rather than polling agents — a self-terminating poll (`until grep -q '"event":"run-end"' <state-dir>/runs/<runId>/journal.jsonl; do sleep 20; done`), never `tail -F | grep -m1` (wedges on the journal's final line) and never a pane-level done wait (turn-end flaps). Resolve blocked interactions in the relevant agent session.
69
- 5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal AND the tip verify is not "failed". Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
86
+ 5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", and the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty — a run with a parked task is partial, not green. Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
70
87
  6. **Record** — write `tickmarkr report <runId> --md` beside the source spec and commit the execution record when the repository tracks those records.
71
88
  7. **Continue** — move to the next requested target. If a target fails or is parked, stop with the journal evidence rather than silently skipping it.
@@ -40,10 +40,27 @@ Before `tickmarkr compile` or `tickmarkr run`, compare the installed binary agai
40
40
 
41
41
  1. Run `tickmarkr version` (one line, machine-parseable).
42
42
  2. Read the `version` field from the repository's `package.json`.
43
- 3. If the binary is **older on major.minor** than the repo (e.g. binary `1.36.x` vs repo `1.38.x`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`) or link the repo binary. Do not compile, plan, or run on hope.
43
+ 3. If the binary and repository do not **agree on the entire version** (including the patch; e.g. binary `2.1.0` vs repo `2.1.1`), **stop immediately** and tell the operator to update the global install (`npm i -g tickmarkr@latest`) or link the repo binary. Do not compile, plan, or run on hope.
44
44
 
45
45
  A stale binary silently skips daemon gates shipped in newer releases — the v1.38 run exposed this when a global `1.36.0` binary missed the daemon tip-verify gate entirely (OBS-38). Preflight failure is always stop-and-report; never proceed-and-hope.
46
46
 
47
+ ### No run may be live in THIS repository
48
+
49
+ The version check above is only half the preflight. Before `compile` or `run`, confirm no run is already
50
+ live **in this repository**:
51
+
52
+ 1. Lead with this repository's own `.tickmarkr/graph.lock`. Read its recorded holder pid.
53
+ 2. Treat the lock as held by a LIVE run until `kill -0 <pid>` proves that holder dead.
54
+ 3. **Never require a machine-wide process pattern to be empty.** A lawful run in another repository — or
55
+ the probing shell's own argv — matches such a pattern, so an empty result is not evidence of safety
56
+ and a non-empty one is not evidence of danger.
57
+ 4. If you use a process probe as secondary evidence, exclude the probing process itself, resolve every
58
+ candidate's own working directory (for example `lsof -a -p <pid> -d cwd`), and count only candidates
59
+ whose cwd is **this repository root**.
60
+
61
+ The invariant this protects is per-repository — *never run two tickmarkr runs in the same repository
62
+ concurrently* — so a machine-wide check answers a question nobody asked and blocks work that is lawful.
63
+
47
64
  ## Verified handoffs (agent-to-agent messaging)
48
65
 
49
66
  When relaying missions between agents in a multi-agent terminal, **never use bare send-text** (`herdr agent send` / pane send-text) — it writes text without pressing Enter, so handoffs sit unsubmitted (OBS-39).
@@ -70,5 +87,5 @@ When spawning consultants (agents gathering synthesis input for decisions like S
70
87
  2. **Compile** — run `tickmarkr compile <spec>`. Correct compilation errors in the spec, never in the generated graph.
71
88
  3. **Plan** — run `tickmarkr plan`. Review the routing table, capability-floor warnings, and every human gate, including work that each gate blocks.
72
89
  4. **Run** — run `tickmarkr run`. Watch the run journal for its terminal event rather than repeatedly polling agents. Use a self-terminating poll — `until grep -q '"event":"run-end"' <state-dir>/runs/<runId>/journal.jsonl; do sleep 20; done` — never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). Resolve blocked interactions in the agent session; do not turn them into proxy questions.
73
- 5. **Verify and consolidate** — accept only a green run. A run is green when the run-end event exists in the journal AND the tip verify is not "failed". Tickmarkr consolidates accepted task work on `tickmarkr/<runId>`; it never signs off to the main branch. A human may later merge that integration branch through the repository's normal release process.
90
+ 5. **Verify and consolidate** — accept only a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", and the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty — a run with a parked task is partial, not green. Tickmarkr consolidates accepted task work on `tickmarkr/<runId>`; it never signs off to the main branch. A human may later merge that integration branch through the repository's normal release process.
74
91
  6. **Record** — write `tickmarkr report <runId> --md` beside the source spec and commit the execution record when the repository tracks those records. Then [stand down](#stand-down-mission-end-and-retirement).