tickmarkr 2.8.3 → 2.8.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.
package/dist/run/git.js CHANGED
@@ -3,13 +3,14 @@ import { executionSignal, withoutExecutionBudget } from "./execution-budget.js";
3
3
  import { commandLeaseEnvironment, repositoryLeaseEnvironment, registerRepositoryChild, withCommandLease } from "./lease.js";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { execFile, execFileSync, spawn } from "node:child_process";
6
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
6
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
7
7
  import { availableParallelism, homedir, tmpdir } from "node:os";
8
8
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
9
9
  import { StringDecoder } from "node:string_decoder";
10
10
  import { shq } from "../adapters/types.js";
11
11
  import { tickmarkrDir } from "../graph/graph.js";
12
12
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
13
+ import { HOST_SESSION_VARS } from "../drivers/host-env.js";
13
14
  import { titleEnvironment } from "./title-environment.js";
14
15
  import { assertGitTrust, GitTrustRefusal, ownGitPin, protectedGitEnv, trustedCommonDir } from "./git-trust.js";
15
16
  export { ROUTING_ENV_SEAMS };
@@ -330,6 +331,9 @@ function executeShell(cmd, cwd, timeoutMs, login, options, observation, ownGit)
330
331
  const env = ownGit ? protectedGitEnv(payloadEnv) : payloadEnv;
331
332
  for (const k of ROUTING_ENV_SEAMS)
332
333
  delete env[k];
334
+ // Queue row 193 (D-2102 add.1): no gate or agent child inherits the launching agent session's host variables.
335
+ for (const k of HOST_SESSION_VARS)
336
+ delete env[k];
333
337
  // A run freezes the operator override and cores at startup; admission can lower the round cap.
334
338
  env[FORK_CAP_ENV] = verificationBudget.getStore()
335
339
  ? String(resolvedCapacity().forkCap)
@@ -1089,19 +1093,14 @@ export async function probeRefsWritable(cwd = process.cwd()) {
1089
1093
  const error = (delRes.stderr || delRes.stdout).trim() || `git update-ref -d failed (${delRes.code})`;
1090
1094
  return { ok: false, path: refsDir, refsDir, error };
1091
1095
  }
1092
- // Clean up empty directories left behind by git
1093
- try {
1094
- const preflightDir = join(refsDir, "tickmarkr", "preflight");
1095
- if (existsSync(preflightDir) && readdirSync(preflightDir).length === 0) {
1096
- rmSync(preflightDir, { recursive: true, force: true });
1097
- }
1098
- const tmDir = join(refsDir, "tickmarkr");
1099
- if (existsSync(tmDir) && readdirSync(tmDir).length === 0) {
1100
- rmSync(tmDir, { recursive: true, force: true });
1096
+ // Queue row 194 (D-2102): remove only EMPTY directories git left behind, non-recursively — rmdir(2)
1097
+ // refuses (ENOTEMPTY) a ref another process writes under refs/tickmarkr at this moment, where an
1098
+ // emptiness check followed by a recursive remove deleted it. Best-effort: a refusal leaves the tree.
1099
+ for (const dir of [join(refsDir, "tickmarkr", "preflight"), join(refsDir, "tickmarkr")]) {
1100
+ try {
1101
+ rmdirSync(dir);
1101
1102
  }
1102
- }
1103
- catch {
1104
- // best-effort cleanup of empty dirs
1103
+ catch { /* absent, or no longer empty */ }
1105
1104
  }
1106
1105
  return { ok: true, path: refsDir, refsDir };
1107
1106
  }
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmdirSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { z } from "zod";
7
7
  import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
@@ -11,7 +11,7 @@ import { channelRouteIdentity, modelProvider } from "../route/preference.js";
11
11
  import { buildProfile, classify } from "../route/profile.js";
12
12
  import { DecisionEventSchema, trackJournalRows, } from "./protocol.js";
13
13
  import { PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER } from "./git.js";
14
- import { assertGitTrust, protectedGitEnv } from "./git-trust.js";
14
+ import { assertGitTrust, protectedGitEnv, trustedCommonDir } from "./git-trust.js";
15
15
  import { normalizeGateOutcome } from "./outcome.js";
16
16
  import { redactSecrets } from "./redact.js";
17
17
  export function phaseForGate(gate) {
@@ -1651,30 +1651,59 @@ export function engagementComparable(events, loadedHash) {
1651
1651
  return recorded === loadedHash ? { comparable: true, recorded } : { comparable: false, reason: "mismatch", recorded };
1652
1652
  }
1653
1653
  const RUN_SEQUENCE_WIDTH = 16;
1654
+ const TICKET = /^\d{16}$/;
1655
+ const ticketsIn = (dir) => {
1656
+ try {
1657
+ return readdirSync(dir).filter((name) => TICKET.test(name));
1658
+ }
1659
+ catch (error) {
1660
+ if (error.code === "ENOENT")
1661
+ return [];
1662
+ throw error;
1663
+ }
1664
+ };
1665
+ const highest = (tickets) => tickets.reduce((max, name) => (BigInt(name) > max ? BigInt(name) : max), 0n);
1654
1666
  // The daemon mints before acquiring graph.lock, and each CLI invocation has fresh module state. Claim
1655
- // a repository-state ticket with mkdir's atomic EEXIST boundary instead: a losing process advances and
1656
- // retries, while the winning directory remains as the durable high-water evidence for later invocations.
1667
+ // a ticket with mkdir's atomic EEXIST boundary: a losing process advances and retries. Queue row 194
1668
+ // (D-2102): the sequence lives at the trusted common git dir, shared by the main checkout and every
1669
+ // linked worktree (a trust refusal refuses — never a per-checkout fallback), and its first ticket sits
1670
+ // above this checkout's own pre-change sequence. Pruning keeps it bounded, and the durable high-water
1671
+ // mark is the highest ticket itself: a prune removes a ticket only after seeing a higher one, so the
1672
+ // highest ever claimed survives. A stale reader can recreate a pruned ticket, but then sees that higher
1673
+ // one and claims again above it — only a ticket that is the highest after its own mkdir is issued.
1657
1674
  function claimRunSequence(repoRoot = process.cwd()) {
1658
- const dir = join(tickmarkrDir(repoRoot), "run-id-sequence");
1675
+ const dir = join(trustedCommonDir(repoRoot), "tickmarkr-run-id-sequence");
1659
1676
  mkdirSync(dir, { recursive: true });
1660
- const allocated = readdirSync(dir).filter((name) => /^\d{16}$/.test(name));
1661
- let candidate = allocated.reduce((max, name) => {
1662
- const value = BigInt(name);
1663
- return value > max ? value : max;
1664
- }, 0n) + 1n;
1677
+ const own = highest(ticketsIn(join(repoRoot, stateDirName(repoRoot), "run-id-sequence")));
1678
+ const shared = highest(ticketsIn(dir));
1679
+ let candidate = (own > shared ? own : shared) + 1n;
1665
1680
  while (true) {
1666
1681
  const suffix = candidate.toString().padStart(RUN_SEQUENCE_WIDTH, "0");
1667
1682
  if (suffix.length > RUN_SEQUENCE_WIDTH)
1668
1683
  throw new Error("run id sequence exhausted");
1669
1684
  try {
1670
1685
  mkdirSync(join(dir, suffix));
1671
- return suffix;
1672
1686
  }
1673
1687
  catch (error) {
1674
1688
  if (error.code !== "EEXIST")
1675
1689
  throw error;
1676
1690
  candidate += 1n;
1691
+ continue;
1692
+ }
1693
+ const tickets = ticketsIn(dir);
1694
+ const top = highest(tickets);
1695
+ if (top > candidate) {
1696
+ candidate = top + 1n;
1697
+ continue;
1698
+ }
1699
+ for (const name of tickets) {
1700
+ if (BigInt(name) < candidate)
1701
+ try {
1702
+ rmdirSync(join(dir, name));
1703
+ }
1704
+ catch { /* a concurrent prune took it */ }
1677
1705
  }
1706
+ return suffix;
1678
1707
  }
1679
1708
  }
1680
1709
  export function newRunId(now = new Date()) {
@@ -3,6 +3,8 @@ export declare const STALE_MS = 60000;
3
3
  export interface Inspection {
4
4
  pid?: number;
5
5
  runId?: string;
6
+ startedAt?: number;
7
+ checkout?: string;
6
8
  garbage: boolean;
7
9
  dead: boolean;
8
10
  expired: boolean;
@@ -15,6 +17,9 @@ export type RunLineEvent = {
15
17
  };
16
18
  export declare function shouldRefuse(i: Pick<Inspection, "garbage" | "dead">): boolean;
17
19
  export declare function isPidLive(pid: number): boolean;
20
+ /** The repository run claim for the checkout enclosing `repoRoot`; undefined when no checkout encloses it
21
+ * (graph.lock alone guards there, as before). Any other git trust refusal propagates: fail closed. */
22
+ export declare function repositoryClaimPath(repoRoot: string): string | undefined;
18
23
  export declare function acquireRunLock(repoRoot: string, runId: string): {
19
24
  reclaimed?: {
20
25
  pid: number;
package/dist/run/lock.js CHANGED
@@ -1,7 +1,8 @@
1
- import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdtempSync, openSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { tickmarkrDir, stateDirName } from "../graph/graph.js";
5
+ import { GitTrustRefusal, trustedCommonDir } from "./git-trust.js";
5
6
  import { parseRunId } from "./journal.js";
6
7
  // HARD-01/02: coarse per-run advisory lock over .tickmarkr/graph.json. LOCK-02: the lock is created by
7
8
  // the link(2) idiom — write the full payload to graph.lock.<pid>.tmp, then linkSync(tmp, lockPath),
@@ -17,16 +18,31 @@ export const STALE_MS = 60_000; // 6× heartbeat headroom; PITFALLS floor is ≥
17
18
  // T-10-01: the payload is a trust boundary — parse with zod, fail closed on garbage. Only
18
19
  // pid/runId/startedAt are ever read; anything else is ignored (info-disclosure surface).
19
20
  const PayloadSchema = z.object({ pid: z.number().int().positive(), runId: z.string(), startedAt: z.number() });
21
+ // Queue row 194 (D-2102): graph.lock lives per CHECKOUT, so a run in a linked worktree (Claude Desktop
22
+ // nests each session under <repo>/.claude/worktrees/) never saw a run in the main checkout. The daemon's
23
+ // acquire therefore also takes this REPOSITORY RUN CLAIM at the trusted common git dir — the runner
24
+ // lease's anchor — by the same link(2) idiom and the same decision table (shouldRefuse). compile's two
25
+ // holders take graph.lock only: it guards that checkout's own graph, never the repository's run.
26
+ const ClaimSchema = PayloadSchema.extend({ checkout: z.string() });
27
+ const MarkerSchema = z.object({ pid: z.number().int().positive(), startedAt: z.number() });
28
+ const CLAIM_FILE = "tickmarkr-run.claim";
29
+ const COMPILE_HOLDERS = new Set(["compile", "compile-refusal"]);
30
+ const NO_CHECKOUT = "no checkout encloses this directory"; // git-trust's refusal detail outside any checkout
20
31
  const lockPath = (repoRoot) => join(tickmarkrDir(repoRoot), "graph.lock");
21
32
  const approvalSerializationPath = (repoRoot) => join(tickmarkrDir(repoRoot), "approval.lock");
22
33
  let heartbeat;
23
34
  let heldPath;
35
+ const heldClaims = new Map(); // graph.lock path → its repository claim
24
36
  let approvalSequence = 0;
25
37
  // Best-effort release if the daemon exits without hitting its finally (crash mid-body). NO
26
38
  // SIGINT/SIGTERM handlers — those change kill semantics and leak listeners across the test
27
39
  // suite's many runDaemon calls; signal-death is exactly what stale-reclaim exists for.
28
- process.once("exit", () => { if (heldPath)
29
- unlinkIfOurs(heldPath); });
40
+ process.once("exit", () => {
41
+ for (const claim of heldClaims.values())
42
+ unlinkClaimIfOurs(claim);
43
+ if (heldPath)
44
+ unlinkIfOurs(heldPath);
45
+ });
30
46
  // LOCK-04: the ONE decision table. Both acquireRunLock and isRunLockLive consume this — the two
31
47
  // hand-maintained copies of the rule cannot drift. The table changes HERE, once. previewUnlock /
32
48
  // commitUnlock (below) read the SAME underlying snapshot shape with their own eligibility rules
@@ -65,16 +81,17 @@ export function isPidLive(pid) {
65
81
  }
66
82
  }
67
83
  // statSync throws ENOENT when no lock exists — callers treat that as "not held".
68
- function inspect(p) {
84
+ function inspect(p, schema = PayloadSchema) {
69
85
  const st = statSync(p); // single stat: both the heartbeat mtime and the reclaim-guard inode
70
86
  const mtimeMs = st.mtimeMs;
71
87
  const expired = Date.now() - mtimeMs > STALE_MS;
72
- const parsed = PayloadSchema.safeParse(readPayload(p));
88
+ const parsed = schema.safeParse(readPayload(p));
73
89
  const garbage = !parsed.success; // LOCK-01: its own state — shouldRefuse refuses it unconditionally; only `tickmarkr unlock` removes it
74
90
  const pid = parsed.success ? parsed.data.pid : undefined;
75
91
  // harmless fallback for the garbage row — garbage short-circuits shouldRefuse before this is read
76
92
  const dead = pid === undefined ? true : !isPidLive(pid);
77
- return { pid, runId: parsed.success ? parsed.data.runId : undefined, garbage, dead, expired, mtimeMs, ino: st.ino };
93
+ const checkout = parsed.success ? parsed.data.checkout : undefined;
94
+ return { pid, runId: parsed.success ? parsed.data.runId : undefined, startedAt: parsed.success ? parsed.data.startedAt : undefined, checkout, garbage, dead, expired, mtimeMs, ino: st.ino };
78
95
  }
79
96
  function readPayload(p) {
80
97
  try {
@@ -92,7 +109,177 @@ function unlinkIfOurs(p) {
92
109
  }
93
110
  catch { /* ENOENT — nothing to release */ }
94
111
  }
112
+ function unlinkClaimIfOurs(claim) {
113
+ try {
114
+ const parsed = ClaimSchema.safeParse(readPayload(claim.path));
115
+ if (parsed.success && parsed.data.pid === process.pid && parsed.data.checkout === claim.checkout)
116
+ unlinkSync(claim.path);
117
+ }
118
+ catch { /* ENOENT — nothing to release */ }
119
+ }
120
+ /** The repository run claim for the checkout enclosing `repoRoot`; undefined when no checkout encloses it
121
+ * (graph.lock alone guards there, as before). Any other git trust refusal propagates: fail closed. */
122
+ export function repositoryClaimPath(repoRoot) {
123
+ try {
124
+ return join(trustedCommonDir(repoRoot), CLAIM_FILE);
125
+ }
126
+ catch (e) {
127
+ if (e instanceof GitTrustRefusal && e.detail === NO_CHECKOUT)
128
+ return undefined;
129
+ throw e;
130
+ }
131
+ }
132
+ // The case table: a live holder from ANY other checkout of this repository refuses naming its checkout,
133
+ // run id and pid; a provably dead holder is reclaimed, then the taker re-serializes through link(2);
134
+ // garbage refuses and is removed by hand only. A holder in another repository never meets this path: its
135
+ // common dir — and so its claim — differs.
136
+ function takeRepositoryClaim(repoRoot, runId) {
137
+ const p = repositoryClaimPath(repoRoot);
138
+ if (!p)
139
+ return;
140
+ const checkout = realpathSync(repoRoot);
141
+ while (!linkExclusive(p, { pid: process.pid, runId, startedAt: Date.now(), checkout })) {
142
+ const held = readClaim(p);
143
+ if (!held)
144
+ continue; // released between our link and our read
145
+ if (shouldRefuse(held.insp))
146
+ refuseClaim(p, held.insp);
147
+ reclaimDeadClaim(p, held.id);
148
+ }
149
+ heldClaims.set(lockPath(repoRoot), { path: p, checkout });
150
+ // While this live claim is the claim, every reclaim marker is keyed to a claim gone for good — moot,
151
+ // guarding nothing — so the taker sweeps them all; a reclaimer that died holding one strands nothing.
152
+ const prefix = `${basename(p)}.reclaim.`;
153
+ try {
154
+ for (const name of readdirSync(dirname(p)))
155
+ if (name.startsWith(prefix))
156
+ try {
157
+ unlinkSync(join(dirname(p), name));
158
+ }
159
+ catch { /* another taker swept it */ }
160
+ }
161
+ catch { /* best-effort: a later taker sweeps what is left */ }
162
+ }
163
+ // link(2) exclusive create of a complete payload: true when created, false when `p` already exists.
164
+ function linkExclusive(p, payload) {
165
+ const tmp = join(dirname(p), `${CLAIM_FILE}.${process.pid}.tmp`); // one per process: the claim and its markers share it
166
+ try {
167
+ unlinkSync(tmp);
168
+ }
169
+ catch { /* best-effort clean of our own stray tmp from a prior SIGKILL */ }
170
+ writeFileSync(tmp, JSON.stringify(payload));
171
+ try {
172
+ linkSync(tmp, p);
173
+ return true;
174
+ }
175
+ catch (e) {
176
+ if (e.code === "EEXIST")
177
+ return false;
178
+ throw e;
179
+ }
180
+ finally {
181
+ unlinkSync(tmp);
182
+ }
183
+ }
184
+ // A claim's identity is its inode, pid and start time: a claim is never rewritten in place, so an identity
185
+ // once replaced never returns. undefined when no claim exists.
186
+ function readClaim(p) {
187
+ try {
188
+ const insp = inspect(p, ClaimSchema);
189
+ return { insp, id: `${insp.ino}-${insp.pid}-${insp.startedAt}` };
190
+ }
191
+ catch (e) {
192
+ if (e.code === "ENOENT")
193
+ return undefined;
194
+ throw e;
195
+ }
196
+ }
197
+ // The reclaim runs under an exclusive RECLAIM MARKER beside the claim, keyed to the dead claim's identity:
198
+ // two takers that both saw one dead claim could otherwise each pass a re-stat, and the later unlink would
199
+ // delete the earlier taker's live replacement. Under the marker the claim is re-read, and only that same
200
+ // dead claim is removed. A live marker owner refuses the other taker — a concurrent reclaimer is a run
201
+ // starting in this repository. A marker whose owner died inside the reclaim is STRANDED: the next taker
202
+ // passes to the next generation, which only one taker creates — a dead owner never removes its marker, so
203
+ // two live owners never hold one identity at once — and recovers with no hand removal.
204
+ // ponytail: a stranded marker whose dead pid is reused by an unrelated live process reads live and refuses
205
+ // until the next successful taker sweeps it or it is removed by hand — the fail-closed direction, as graph.lock.
206
+ function reclaimDeadClaim(p, id) {
207
+ let marker = `${p}.reclaim.${id}.0`;
208
+ for (let gen = 0; !linkExclusive(marker, { pid: process.pid, startedAt: Date.now() });) {
209
+ if (markerStranded(p, marker))
210
+ marker = `${p}.reclaim.${id}.${++gen}`;
211
+ }
212
+ try {
213
+ const now = readClaim(p);
214
+ if (now?.id !== id)
215
+ return; // replaced since our read: the caller re-serializes through link(2)
216
+ if (shouldRefuse(now.insp))
217
+ refuseClaim(p, now.insp);
218
+ try {
219
+ unlinkSync(p);
220
+ }
221
+ catch (e) {
222
+ if (e.code !== "ENOENT")
223
+ throw e;
224
+ }
225
+ }
226
+ finally {
227
+ try {
228
+ unlinkSync(marker);
229
+ }
230
+ catch { /* swept as moot by the claim's next taker */ }
231
+ }
232
+ }
233
+ // true when `marker`'s owner provably died holding it: dead, and the same bytes on the same inode after the
234
+ // death proof. false when it went or changed under the read (that generation is retried). A live owner
235
+ // refuses; a garbage marker refuses for removal by hand.
236
+ function markerStranded(p, marker) {
237
+ const read = () => {
238
+ try {
239
+ return { ino: statSync(marker).ino, bytes: readFileSync(marker, "utf8") };
240
+ }
241
+ catch (e) {
242
+ if (e.code === "ENOENT")
243
+ return undefined;
244
+ throw e;
245
+ }
246
+ };
247
+ const before = read();
248
+ if (!before)
249
+ return false;
250
+ let owner;
251
+ try {
252
+ owner = MarkerSchema.parse(JSON.parse(before.bytes));
253
+ }
254
+ catch {
255
+ throw new Error(`reclaim marker ${marker} holds an unreadable/garbage payload — refusing to reclaim repository run claim ${p}; once no tickmarkr run is starting in this repository, remove ${marker} by hand`);
256
+ }
257
+ if (isPidLive(owner.pid))
258
+ throw new Error(`repository run claim ${p} is being reclaimed from its dead holder by another tickmarkr process (pid ${owner.pid}) — retry; if none is starting in this repository, remove ${marker} by hand`);
259
+ const after = read();
260
+ return after !== undefined && after.ino === before.ino && after.bytes === before.bytes;
261
+ }
262
+ function refuseClaim(p, insp) {
263
+ if (insp.garbage)
264
+ throw new Error(`repository run claim ${p} holds an unreadable/garbage payload — refusing to reclaim it; once no tickmarkr run is live in this repository, remove ${p} by hand`);
265
+ throw new Error(`repository run claim ${p} held by pid ${insp.pid} (run ${insp.runId}) in checkout ${insp.checkout} — one live tickmarkr run per repository across its main checkout and linked worktrees; wait for that run to end`);
266
+ }
267
+ // Run and resume (runDaemon) take graph.lock, then the repository claim; a claim refusal releases the
268
+ // graph.lock just taken, before any journal exists. compile's holders take graph.lock alone.
95
269
  export function acquireRunLock(repoRoot, runId) {
270
+ const acquired = acquireGraphLock(repoRoot, runId);
271
+ if (COMPILE_HOLDERS.has(runId))
272
+ return acquired;
273
+ try {
274
+ takeRepositoryClaim(repoRoot, runId);
275
+ }
276
+ catch (e) {
277
+ releaseRunLock(repoRoot);
278
+ throw e;
279
+ }
280
+ return acquired;
281
+ }
282
+ function acquireGraphLock(repoRoot, runId) {
96
283
  const p = lockPath(repoRoot);
97
284
  const tmp = join(tickmarkrDir(repoRoot), `graph.lock.${process.pid}.tmp`);
98
285
  try {
@@ -127,7 +314,7 @@ export function acquireRunLock(repoRoot, runId) {
127
314
  // Single-machine tool — this is the ceiling.
128
315
  const st = statSync(p); // re-stat immediately before removing — abort if replaced since inspect()
129
316
  if (st.ino !== insp.ino || st.mtimeMs !== insp.mtimeMs)
130
- return acquireRunLock(repoRoot, runId); // loser path
317
+ return acquireGraphLock(repoRoot, runId); // loser path
131
318
  try {
132
319
  unlinkSync(p);
133
320
  }
@@ -135,7 +322,7 @@ export function acquireRunLock(repoRoot, runId) {
135
322
  if (e2.code !== "ENOENT")
136
323
  throw e2;
137
324
  } // successor already took it
138
- const again = acquireRunLock(repoRoot, runId); // re-serializes two stealers through linkSync
325
+ const again = acquireGraphLock(repoRoot, runId); // re-serializes two stealers through linkSync
139
326
  return { reclaimed: { pid: pid ?? -1, mtimeMs }, ...again };
140
327
  }
141
328
  const stateDir = stateDirName(repoRoot);
@@ -151,6 +338,12 @@ export function releaseRunLock(repoRoot) {
151
338
  clearInterval(heartbeat);
152
339
  heartbeat = undefined;
153
340
  }
341
+ // the claim goes first: released after graph.lock, a successor run in this checkout would meet our claim
342
+ const claim = heldClaims.get(lockPath(repoRoot));
343
+ if (claim) {
344
+ unlinkClaimIfOurs(claim);
345
+ heldClaims.delete(lockPath(repoRoot));
346
+ }
154
347
  unlinkIfOurs(lockPath(repoRoot)); // never delete a reclaiming successor's lock — only ours
155
348
  heldPath = undefined;
156
349
  }
@@ -75,8 +75,8 @@ export declare const GateEvidenceReceiptSchema: z.ZodObject<{
75
75
  termination: z.ZodObject<{
76
76
  kind: z.ZodEnum<{
77
77
  unknown: "unknown";
78
- timeout: "timeout";
79
78
  signal: "signal";
79
+ timeout: "timeout";
80
80
  "not-started": "not-started";
81
81
  exit: "exit";
82
82
  }>;
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { graphPath, stateDirName } from "../../graph/graph.js";
5
+ import { repoOverlayPath } from "../../config/config.js";
5
6
  import { validateGraph } from "../../graph/schema.js";
6
7
  import { foldOwedChecks, parseJournalText, parseRunId } from "../../run/journal.js";
7
8
  import { isPidLive, STALE_MS } from "../../run/lock.js";
@@ -329,7 +330,16 @@ export function createLiveStore(options) {
329
330
  reset: () => { fold = new OperatorStateFold(); }, record: record => fold.apply(record),
330
331
  });
331
332
  const graph = new JsonSource(graphPath(options.cwd), raw => validateGraph(JSON.parse(raw)), STORE_LIMITS.graphBytes);
332
- const config = new JsonSource(options.configPath ?? join(state, "config.yaml"), raw => raw);
333
+ // Queue row 195 / D-2102: the cockpit's live config source follows the ONE repo-layer path rule, re-resolved
334
+ // on every observation so an own overlay created or removed, or a main file
335
+ // appearing, moves the source; an explicit configPath stays fixed.
336
+ let config;
337
+ const readConfig = (at) => {
338
+ const path = options.configPath ?? repoOverlayPath(options.cwd);
339
+ if (config?.path !== path)
340
+ config = new JsonSource(path, raw => raw);
341
+ return config.read(at);
342
+ };
333
343
  const cache = new JsonSource(options.cachePath ?? join(state, "doctor.json"), JSON.parse);
334
344
  const lock = new JsonSource(join(state, "graph.lock"), raw => {
335
345
  const v = JSON.parse(raw);
@@ -379,7 +389,7 @@ export function createLiveStore(options) {
379
389
  owed = owedOf(journal);
380
390
  }
381
391
  const graphReading = graph.read(observedAt);
382
- const configReading = config.read(observedAt);
392
+ const configReading = readConfig(observedAt);
383
393
  const cacheReading = cache.read(observedAt);
384
394
  const owner = lock.read(observedAt);
385
395
  const alive = owner.status === "readable" && owner.value ? (options.isDaemonAlive ?? isPidLive)(owner.value.pid) : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.8.3",
3
+ "version": "2.8.5",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",