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/adapters/claude-code.js +6 -2
- package/dist/adapters/codex-commit-check.js +2 -1
- package/dist/adapters/cursor-agent.js +2 -1
- package/dist/adapters/grok.js +2 -1
- package/dist/adapters/kimi.js +41 -10
- package/dist/adapters/opencode.js +2 -1
- package/dist/adapters/pi.js +3 -2
- package/dist/adapters/qwen.js +2 -1
- package/dist/adapters/registry.js +2 -1
- package/dist/adapters/types.d.ts +2 -0
- package/dist/adapters/types.js +18 -3
- package/dist/cli/commands/doctor.d.ts +49 -5
- package/dist/cli/commands/doctor.js +956 -94
- package/dist/cli/commands/fleet.js +11 -3
- package/dist/cli/commands/init.js +2 -2
- package/dist/cli/commands/verify.js +12 -3
- package/dist/compile/index.js +4 -1
- package/dist/config/config.d.ts +15 -0
- package/dist/config/config.js +67 -5
- package/dist/drivers/host-env.d.ts +3 -0
- package/dist/drivers/host-env.js +47 -0
- package/dist/drivers/orca.js +3 -1
- package/dist/drivers/subprocess.d.ts +1 -1
- package/dist/drivers/subprocess.js +4 -3
- package/dist/gates/acceptance.js +130 -12
- package/dist/gates/baseline.d.ts +1 -1
- package/dist/gates/llm.js +11 -6
- package/dist/run/git.js +12 -13
- package/dist/run/journal.js +40 -11
- package/dist/run/lock.d.ts +5 -0
- package/dist/run/lock.js +202 -9
- package/dist/run/protocol.d.ts +1 -1
- package/dist/tui/cockpit/live-store.js +12 -2
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +65 -21
- package/skills/tickmarkr-loop/SKILL.md +65 -21
- package/skills/tickmarkr-overseer/SKILL.md +88 -32
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
|
-
//
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
}
|
package/dist/run/journal.js
CHANGED
|
@@ -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
|
|
1656
|
-
//
|
|
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(
|
|
1675
|
+
const dir = join(trustedCommonDir(repoRoot), "tickmarkr-run-id-sequence");
|
|
1659
1676
|
mkdirSync(dir, { recursive: true });
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1662
|
-
|
|
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()) {
|
package/dist/run/lock.d.ts
CHANGED
|
@@ -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", () => {
|
|
29
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
}
|
package/dist/run/protocol.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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;
|