tickmarkr 1.30.0
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/README.md +347 -0
- package/dist/adapters/claude-code.d.ts +4 -0
- package/dist/adapters/claude-code.js +191 -0
- package/dist/adapters/codex.d.ts +8 -0
- package/dist/adapters/codex.js +257 -0
- package/dist/adapters/cursor-agent.d.ts +4 -0
- package/dist/adapters/cursor-agent.js +62 -0
- package/dist/adapters/fake.d.ts +45 -0
- package/dist/adapters/fake.js +115 -0
- package/dist/adapters/grok.d.ts +4 -0
- package/dist/adapters/grok.js +122 -0
- package/dist/adapters/model-lints.d.ts +6 -0
- package/dist/adapters/model-lints.js +130 -0
- package/dist/adapters/opencode.d.ts +3 -0
- package/dist/adapters/opencode.js +108 -0
- package/dist/adapters/pi.d.ts +3 -0
- package/dist/adapters/pi.js +191 -0
- package/dist/adapters/prompt.d.ts +6 -0
- package/dist/adapters/prompt.js +84 -0
- package/dist/adapters/registry.d.ts +31 -0
- package/dist/adapters/registry.js +165 -0
- package/dist/adapters/types.d.ts +98 -0
- package/dist/adapters/types.js +56 -0
- package/dist/cli/commands/approve.d.ts +1 -0
- package/dist/cli/commands/approve.js +72 -0
- package/dist/cli/commands/compile.d.ts +1 -0
- package/dist/cli/commands/compile.js +23 -0
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.js +103 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +32 -0
- package/dist/cli/commands/plan.d.ts +2 -0
- package/dist/cli/commands/plan.js +101 -0
- package/dist/cli/commands/profile.d.ts +1 -0
- package/dist/cli/commands/profile.js +56 -0
- package/dist/cli/commands/report.d.ts +4 -0
- package/dist/cli/commands/report.js +306 -0
- package/dist/cli/commands/resume.d.ts +1 -0
- package/dist/cli/commands/resume.js +11 -0
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +35 -0
- package/dist/cli/commands/scope.d.ts +3 -0
- package/dist/cli/commands/scope.js +22 -0
- package/dist/cli/commands/status.d.ts +9 -0
- package/dist/cli/commands/status.js +162 -0
- package/dist/cli/commands/unlock.d.ts +1 -0
- package/dist/cli/commands/unlock.js +11 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +65 -0
- package/dist/compile/collateral.d.ts +6 -0
- package/dist/compile/collateral.js +128 -0
- package/dist/compile/common.d.ts +12 -0
- package/dist/compile/common.js +56 -0
- package/dist/compile/gsd.d.ts +3 -0
- package/dist/compile/gsd.js +222 -0
- package/dist/compile/index.d.ts +4 -0
- package/dist/compile/index.js +33 -0
- package/dist/compile/native.d.ts +4 -0
- package/dist/compile/native.js +196 -0
- package/dist/compile/prd.d.ts +2 -0
- package/dist/compile/prd.js +64 -0
- package/dist/compile/speckit.d.ts +2 -0
- package/dist/compile/speckit.js +66 -0
- package/dist/config/config.d.ts +184 -0
- package/dist/config/config.js +329 -0
- package/dist/drivers/herdr.d.ts +44 -0
- package/dist/drivers/herdr.js +394 -0
- package/dist/drivers/index.d.ts +3 -0
- package/dist/drivers/index.js +13 -0
- package/dist/drivers/subprocess.d.ts +25 -0
- package/dist/drivers/subprocess.js +106 -0
- package/dist/drivers/types.d.ts +61 -0
- package/dist/drivers/types.js +77 -0
- package/dist/gates/acceptance.d.ts +19 -0
- package/dist/gates/acceptance.js +92 -0
- package/dist/gates/baseline.d.ts +12 -0
- package/dist/gates/baseline.js +97 -0
- package/dist/gates/evidence.d.ts +4 -0
- package/dist/gates/evidence.js +12 -0
- package/dist/gates/llm.d.ts +25 -0
- package/dist/gates/llm.js +101 -0
- package/dist/gates/review.d.ts +12 -0
- package/dist/gates/review.js +80 -0
- package/dist/gates/run-gates.d.ts +34 -0
- package/dist/gates/run-gates.js +105 -0
- package/dist/gates/scope.d.ts +8 -0
- package/dist/gates/scope.js +32 -0
- package/dist/gates/types.d.ts +6 -0
- package/dist/gates/types.js +1 -0
- package/dist/graph/graph.d.ts +20 -0
- package/dist/graph/graph.js +148 -0
- package/dist/graph/schema.d.ts +185 -0
- package/dist/graph/schema.js +141 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plan/prompt.d.ts +4 -0
- package/dist/plan/prompt.js +31 -0
- package/dist/plan/scope.d.ts +17 -0
- package/dist/plan/scope.js +141 -0
- package/dist/report/cost.d.ts +24 -0
- package/dist/report/cost.js +89 -0
- package/dist/route/preference.d.ts +27 -0
- package/dist/route/preference.js +49 -0
- package/dist/route/profile.d.ts +65 -0
- package/dist/route/profile.js +197 -0
- package/dist/route/router.d.ts +26 -0
- package/dist/route/router.js +200 -0
- package/dist/run/consult.d.ts +23 -0
- package/dist/run/consult.js +80 -0
- package/dist/run/daemon.d.ts +23 -0
- package/dist/run/daemon.js +728 -0
- package/dist/run/git.d.ts +12 -0
- package/dist/run/git.js +56 -0
- package/dist/run/journal.d.ts +91 -0
- package/dist/run/journal.js +287 -0
- package/dist/run/lock.d.ts +29 -0
- package/dist/run/lock.js +182 -0
- package/dist/run/merge.d.ts +12 -0
- package/dist/run/merge.js +44 -0
- package/dist/run/reconcile.d.ts +4 -0
- package/dist/run/reconcile.js +95 -0
- package/package.json +39 -0
- package/schema/rungraph.schema.json +306 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ShResult {
|
|
2
|
+
code: number;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
timedOut?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
|
|
8
|
+
export declare function shOk(cmd: string, cwd: string): Promise<string>;
|
|
9
|
+
export declare function gitHead(cwd: string): Promise<string>;
|
|
10
|
+
export declare function resolveIntegrationBranch(repo: string, branch: string): Promise<string>;
|
|
11
|
+
export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
|
|
12
|
+
export declare function removeWorktree(repo: string, dir: string): Promise<void>;
|
package/dist/run/git.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { shq } from "../adapters/types.js";
|
|
5
|
+
import { drovrDir } from "../graph/graph.js";
|
|
6
|
+
// stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
|
|
7
|
+
// (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
|
|
8
|
+
export function sh(cmd, cwd, timeoutMs = 600000) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const p = spawn("bash", ["-lc", cmd], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
11
|
+
let stdout = "", stderr = "";
|
|
12
|
+
let timedOut = false;
|
|
13
|
+
const timer = setTimeout(() => { timedOut = true; p.kill("SIGKILL"); }, timeoutMs);
|
|
14
|
+
p.stdout.on("data", (d) => (stdout += d));
|
|
15
|
+
p.stderr.on("data", (d) => (stderr += d));
|
|
16
|
+
p.on("error", (e) => { clearTimeout(timer); resolve({ code: 127, stdout, stderr: String(e), timedOut }); });
|
|
17
|
+
p.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 1, stdout, stderr, timedOut }); });
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function shOk(cmd, cwd) {
|
|
21
|
+
const r = await sh(cmd, cwd);
|
|
22
|
+
if (r.code !== 0)
|
|
23
|
+
throw new Error(`command failed (${r.code}): ${cmd}\n${r.stderr || r.stdout}`);
|
|
24
|
+
return r.stdout;
|
|
25
|
+
}
|
|
26
|
+
export async function gitHead(cwd) {
|
|
27
|
+
return (await shOk("git rev-parse HEAD", cwd)).trim();
|
|
28
|
+
}
|
|
29
|
+
const sanitize = (branch) => branch.replace(/[^\w.-]+/g, "-");
|
|
30
|
+
const branchExists = async (repo, branch) => (await sh(`git rev-parse --verify ${shq(`refs/heads/${branch}`)}`, repo)).code === 0;
|
|
31
|
+
export async function resolveIntegrationBranch(repo, branch) {
|
|
32
|
+
if (!branch.startsWith("drovr/"))
|
|
33
|
+
return branch;
|
|
34
|
+
const legacy = `drover/${branch.slice("drovr/".length)}`;
|
|
35
|
+
return !(await branchExists(repo, branch)) && await branchExists(repo, legacy) ? legacy : branch;
|
|
36
|
+
}
|
|
37
|
+
const resolveTaskBranch = async (repo, branch) => {
|
|
38
|
+
const split = branch.lastIndexOf("--");
|
|
39
|
+
if (split < 0)
|
|
40
|
+
return branch;
|
|
41
|
+
const integration = await resolveIntegrationBranch(repo, branch.slice(0, split));
|
|
42
|
+
return `${integration}${branch.slice(split)}`;
|
|
43
|
+
};
|
|
44
|
+
export async function createWorktree(repo, branch, baseRef) {
|
|
45
|
+
branch = await resolveTaskBranch(repo, branch);
|
|
46
|
+
const dir = join(drovrDir(repo), "worktrees", sanitize(branch));
|
|
47
|
+
if (existsSync(dir))
|
|
48
|
+
await removeWorktree(repo, dir);
|
|
49
|
+
await shOk(`git worktree add -B ${shq(branch)} ${shq(dir)} ${shq(baseRef)}`, repo);
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
export async function removeWorktree(repo, dir) {
|
|
53
|
+
await sh(`git worktree remove --force ${shq(dir)}`, repo); // best-effort; stale dirs are re-added with -B
|
|
54
|
+
await sh(`rm -rf ${shq(dir)}`, repo);
|
|
55
|
+
await sh("git worktree prune", repo);
|
|
56
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type Assignment } from "../adapters/types.js";
|
|
3
|
+
import type { DrovrConfig } from "../config/config.js";
|
|
4
|
+
import { type TaskStatus } from "../graph/schema.js";
|
|
5
|
+
import { type RoutingProfile } from "../route/profile.js";
|
|
6
|
+
export interface JournalEvent {
|
|
7
|
+
ts: string;
|
|
8
|
+
event: string;
|
|
9
|
+
taskId?: string;
|
|
10
|
+
data: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export declare function formatJournalNarration({ event, taskId, data }: JournalEvent): string;
|
|
13
|
+
export interface ResumeState {
|
|
14
|
+
attempts: number;
|
|
15
|
+
tried: string[];
|
|
16
|
+
lastAssignment?: Assignment;
|
|
17
|
+
}
|
|
18
|
+
export declare const ATTEMPT_CAP_RELEASE: "attempt-cap";
|
|
19
|
+
export declare const PARK_KINDS: readonly ["ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict"];
|
|
20
|
+
export type ParkKind = (typeof PARK_KINDS)[number];
|
|
21
|
+
export declare const RETRY_MODES: readonly ["resume", "fresh"];
|
|
22
|
+
export type RetryMode = (typeof RETRY_MODES)[number];
|
|
23
|
+
export declare const TelemetryRowSchema: z.ZodObject<{
|
|
24
|
+
taskId: z.ZodString;
|
|
25
|
+
shape: z.ZodString;
|
|
26
|
+
adapter: z.ZodString;
|
|
27
|
+
model: z.ZodString;
|
|
28
|
+
channel: z.ZodString;
|
|
29
|
+
attempts: z.ZodNumber;
|
|
30
|
+
outcome: z.ZodEnum<{
|
|
31
|
+
failed: "failed";
|
|
32
|
+
done: "done";
|
|
33
|
+
human: "human";
|
|
34
|
+
}>;
|
|
35
|
+
durationMs: z.ZodNumber;
|
|
36
|
+
firstAttemptOk: z.ZodOptional<z.ZodBoolean>;
|
|
37
|
+
gateFails: z.ZodOptional<z.ZodNumber>;
|
|
38
|
+
consults: z.ZodOptional<z.ZodNumber>;
|
|
39
|
+
parkKind: z.ZodOptional<z.ZodEnum<{
|
|
40
|
+
setup: "setup";
|
|
41
|
+
"ladder-exhausted": "ladder-exhausted";
|
|
42
|
+
"attempt-cap": "attempt-cap";
|
|
43
|
+
"gate-fail": "gate-fail";
|
|
44
|
+
quota: "quota";
|
|
45
|
+
"reroute-exhausted": "reroute-exhausted";
|
|
46
|
+
stall: "stall";
|
|
47
|
+
"merge-conflict": "merge-conflict";
|
|
48
|
+
}>>;
|
|
49
|
+
tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
50
|
+
input: z.ZodNumber;
|
|
51
|
+
output: z.ZodNumber;
|
|
52
|
+
cacheRead: z.ZodOptional<z.ZodNumber>;
|
|
53
|
+
cacheWrite: z.ZodOptional<z.ZodNumber>;
|
|
54
|
+
reasoning: z.ZodOptional<z.ZodNumber>;
|
|
55
|
+
}, z.core.$strip>>>;
|
|
56
|
+
meteredAttempts: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
|
|
57
|
+
quotaFailover: z.ZodOptional<z.ZodLiteral<true>>;
|
|
58
|
+
overrun: z.ZodOptional<z.ZodLiteral<true>>;
|
|
59
|
+
retryMode: z.ZodOptional<z.ZodEnum<{
|
|
60
|
+
resume: "resume";
|
|
61
|
+
fresh: "fresh";
|
|
62
|
+
}>>;
|
|
63
|
+
}, z.core.$strip>;
|
|
64
|
+
export type TelemetryRow = z.infer<typeof TelemetryRowSchema>;
|
|
65
|
+
export declare function newRunId(now?: Date): string;
|
|
66
|
+
export declare function readAllTelemetry(repoRoot: string, lastK: number, opts?: {
|
|
67
|
+
after?: string;
|
|
68
|
+
}): (TelemetryRow & {
|
|
69
|
+
runId: string;
|
|
70
|
+
})[];
|
|
71
|
+
export declare const RUNS_WINDOW = 50;
|
|
72
|
+
export declare function readProfileCursor(repoRoot: string): string | undefined;
|
|
73
|
+
export declare function loadRoutingProfile(repoRoot: string, cfg: DrovrConfig, opts?: {
|
|
74
|
+
preview?: boolean;
|
|
75
|
+
}): RoutingProfile | undefined;
|
|
76
|
+
export declare class Journal {
|
|
77
|
+
readonly dir: string;
|
|
78
|
+
readonly runId: string;
|
|
79
|
+
private readonly narrate?;
|
|
80
|
+
private constructor();
|
|
81
|
+
static create(repoRoot: string, runId: string, narrate?: (event: JournalEvent) => void): Journal;
|
|
82
|
+
static open(repoRoot: string, runId: string, narrate?: (event: JournalEvent) => void): Journal;
|
|
83
|
+
static latestRunId(repoRoot: string): string | null;
|
|
84
|
+
private get journalPath();
|
|
85
|
+
append(event: string, taskId?: string, data?: Record<string, unknown>): void;
|
|
86
|
+
read(): JournalEvent[];
|
|
87
|
+
replayStatuses(): Map<string, TaskStatus>;
|
|
88
|
+
replayResumeState(): Map<string, ResumeState>;
|
|
89
|
+
telemetry(row: TelemetryRow): void;
|
|
90
|
+
readTelemetry(): TelemetryRow[];
|
|
91
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { channelKey, TokenUsageSchema } from "../adapters/types.js";
|
|
5
|
+
import { stateDirName } from "../graph/graph.js";
|
|
6
|
+
import { TIERS } from "../graph/schema.js";
|
|
7
|
+
import { buildProfile } from "../route/profile.js";
|
|
8
|
+
export function formatJournalNarration({ event, taskId, data }) {
|
|
9
|
+
const assignment = data.assignment;
|
|
10
|
+
const direct = [data.summary, data.reason, data.error, data.step, data.action, data.lint, data.branch, data.from]
|
|
11
|
+
.find((value) => typeof value === "string" || typeof value === "number");
|
|
12
|
+
const detail = Array.isArray(data.done)
|
|
13
|
+
? `done ${data.done.length}, failed ${Array.isArray(data.failed) ? data.failed.length : 0}`
|
|
14
|
+
: typeof data.gate === "string"
|
|
15
|
+
? `${data.gate}${data.pass === true ? " passed" : data.pass === false ? " failed" : ""}`
|
|
16
|
+
: typeof data.code === "number" ? `exit ${data.code}`
|
|
17
|
+
: typeof data.pid === "number" ? `pid ${data.pid}`
|
|
18
|
+
: typeof data.baseRef === "string" ? `base ${data.baseRef.slice(0, 12)}`
|
|
19
|
+
: direct === undefined
|
|
20
|
+
? typeof assignment?.adapter === "string" && typeof assignment.model === "string" ? `${assignment.adapter}:${assignment.model}` : undefined
|
|
21
|
+
: String(direct);
|
|
22
|
+
return [event, taskId, detail?.replace(/\s+/g, " ").slice(0, 120)].filter(Boolean).join(" — ");
|
|
23
|
+
}
|
|
24
|
+
// v1.24 OBS-18: explicit data on task-approved when the operator releases an attempt-cap park.
|
|
25
|
+
// Approve stamps this; replayResumeState zeros the attempt counter (fresh budget) while keeping
|
|
26
|
+
// tried (consult bans / burned channels). Absent on pre-v1.24 events ⇒ inert (corpus outcome-identical).
|
|
27
|
+
export const ATTEMPT_CAP_RELEASE = "attempt-cap";
|
|
28
|
+
// Fail-closed shape for a dispatched assignment (journal.ts:75-90 posture): a malformed assignment in
|
|
29
|
+
// one dispatch degrades that single task toward today's behavior — counts toward attempts, contributes
|
|
30
|
+
// nothing to tried, poisons only lastAssignment — never crashes resume, never poisons other tasks.
|
|
31
|
+
const DispatchAssignmentSchema = z.object({
|
|
32
|
+
adapter: z.string(),
|
|
33
|
+
model: z.string(),
|
|
34
|
+
channel: z.enum(["sub", "api"]),
|
|
35
|
+
tier: z.enum(TIERS),
|
|
36
|
+
});
|
|
37
|
+
export const PARK_KINDS = ["ladder-exhausted", "attempt-cap", "gate-fail", "quota",
|
|
38
|
+
"reroute-exhausted", "setup", "stall", "merge-conflict"];
|
|
39
|
+
export const RETRY_MODES = ["resume", "fresh"];
|
|
40
|
+
export const TelemetryRowSchema = z.object({
|
|
41
|
+
// v1.5 core — required; every old row has all eight (daemon.ts writes all eight)
|
|
42
|
+
taskId: z.string(), shape: z.string(), adapter: z.string(), model: z.string(), channel: z.string(),
|
|
43
|
+
attempts: z.number(), outcome: z.enum(["done", "failed", "human"]), durationMs: z.number(),
|
|
44
|
+
// v1.6 additive (TEL-01/02) — OPTIONAL: a v1.5 row parses with these === undefined ("unobserved"),
|
|
45
|
+
// never false/0. Phase 12 readers must branch on === true / === false; `?? false` is the poisoning bug.
|
|
46
|
+
firstAttemptOk: z.boolean().optional(),
|
|
47
|
+
gateFails: z.number().optional(),
|
|
48
|
+
consults: z.number().optional(),
|
|
49
|
+
parkKind: z.enum(PARK_KINDS).optional(),
|
|
50
|
+
// v1.7 additive (SPEND-02) — OPTIONAL: absent = unmetered, never 0. .catch(undefined): a MALFORMED
|
|
51
|
+
// tokens sub-object degrades to unmetered instead of safeParse-dropping the whole row — a metering
|
|
52
|
+
// bug must never remove a row from profile derivation (that would let metering perturb routing).
|
|
53
|
+
tokens: TokenUsageSchema.optional().catch(undefined),
|
|
54
|
+
// v1.7 (SPEND-02): count of attempts that produced a usage record. Present IFF tokens is present.
|
|
55
|
+
// meteredAttempts < attempts ⇒ tokens is a FLOOR (partially metered task) — Phase 18 must label it,
|
|
56
|
+
// never print it as a total. Absent ⇒ the row is unmetered entirely.
|
|
57
|
+
meteredAttempts: z.number().int().positive().optional().catch(undefined),
|
|
58
|
+
// v1.8 additive (TEL-05) — OPTIONAL, z.literal(true) so `false` is UNREPRESENTABLE: absent = "no
|
|
59
|
+
// mid-task quota failover observed" (never false), present = attributed to the channel throttled
|
|
60
|
+
// away FROM. Phase 26 ROUTE-12 (utilization axis) consumes this; only the if(next) branch writes it.
|
|
61
|
+
quotaFailover: z.literal(true).optional(),
|
|
62
|
+
// v1.13 additive (ROUTE-18) — OPTIONAL, z.literal(true) so `false` is UNREPRESENTABLE: absent = "no
|
|
63
|
+
// overrun observed" (never false), present = the channel burned a window without emitting a trailer
|
|
64
|
+
// (worker-result ok:false, finished:false — no-trailer timeout OR trailer-less crash-exit). Written
|
|
65
|
+
// only at the daemon's !finished site (after the quota check, quota-disjoint); 48-01's ProfileRow
|
|
66
|
+
// (src/route/profile.ts) consumes the field-name contract `overrun`. Mirrors quotaFailover exactly.
|
|
67
|
+
overrun: z.literal(true).optional(),
|
|
68
|
+
// v1.29 additive: mode of the attempt represented by this row. Absent on old telemetry.
|
|
69
|
+
retryMode: z.enum(RETRY_MODES).optional(),
|
|
70
|
+
});
|
|
71
|
+
export function newRunId(now = new Date()) {
|
|
72
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
73
|
+
return `run-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
74
|
+
}
|
|
75
|
+
const runsDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "runs");
|
|
76
|
+
// One JSONL reader for every append-only log: skip blanks, drop any line that
|
|
77
|
+
// won't parse, keeping everything before it intact.
|
|
78
|
+
function readJsonl(path) {
|
|
79
|
+
if (!existsSync(path))
|
|
80
|
+
return [];
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
83
|
+
if (!line.trim())
|
|
84
|
+
continue;
|
|
85
|
+
try {
|
|
86
|
+
out.push(JSON.parse(line));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// torn trailing write after a crash — ignore; everything before it is intact
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
// Cross-run telemetry for Phase-12 profile derivation: the last K runs' rows, each
|
|
95
|
+
// tagged with its runId (runIds are zero-padded run-YYYYMMDD-HHMMSS ⇒ plain .sort() is
|
|
96
|
+
// chronological, same as latestRunId). Rows are facts, not classifications — Phase 12
|
|
97
|
+
// owns the quality-denominator/reward policy. A safeParse failure drops that one row
|
|
98
|
+
// (same posture as a torn line); a garbage row must never crash profile derivation.
|
|
99
|
+
// Note: a per-task row attributes to the FINAL channel — a channel escalated away from
|
|
100
|
+
// mid-task contributes no row; accepted for v1.6.
|
|
101
|
+
export function readAllTelemetry(repoRoot, lastK, opts = {}) {
|
|
102
|
+
const dir = runsDir(repoRoot);
|
|
103
|
+
if (!existsSync(dir))
|
|
104
|
+
return [];
|
|
105
|
+
let runIds = readdirSync(dir).filter((d) => d.startsWith("run-")).sort();
|
|
106
|
+
// VIS-03 reset cursor: runIds are zero-padded run-YYYYMMDD-HHMMSS ⇒ string > is chronological
|
|
107
|
+
if (opts.after)
|
|
108
|
+
runIds = runIds.filter((id) => id > opts.after);
|
|
109
|
+
runIds = runIds.slice(-lastK);
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const runId of runIds) {
|
|
112
|
+
for (const raw of readJsonl(join(dir, runId, "telemetry.jsonl"))) {
|
|
113
|
+
const r = TelemetryRowSchema.safeParse(raw);
|
|
114
|
+
if (r.success)
|
|
115
|
+
out.push({ ...r.data, runId });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
// ponytail: fixed 50-run window; promote to a routing.learned.* config knob only if operators need to tune it.
|
|
121
|
+
export const RUNS_WINDOW = 50;
|
|
122
|
+
// VIS-03 reset cursor — one trimmed runId line at .drovr/profile-since; absent/empty ⇒ undefined.
|
|
123
|
+
// Opaque: used ONLY in the runId > comparison above, never a shell or path join beyond .drovr/.
|
|
124
|
+
export function readProfileCursor(repoRoot) {
|
|
125
|
+
const path = join(repoRoot, stateDirName(repoRoot), "profile-since");
|
|
126
|
+
if (!existsSync(path))
|
|
127
|
+
return undefined;
|
|
128
|
+
return readFileSync(path, "utf8").trim() || undefined;
|
|
129
|
+
}
|
|
130
|
+
// The one shared profile builder (criterion 4: plan and daemon share ONE code path).
|
|
131
|
+
// preview:true bypasses the routing.learned:off short-circuit so `drovr plan` can render the
|
|
132
|
+
// trust-ramp preview while the daemon (no preview) stays inert (VALIDATION 13-01-11).
|
|
133
|
+
export function loadRoutingProfile(repoRoot, cfg, opts = {}) {
|
|
134
|
+
if (cfg.routing.learned === "off" && !opts.preview)
|
|
135
|
+
return undefined; // never built, never passed
|
|
136
|
+
const rows = readAllTelemetry(repoRoot, RUNS_WINDOW, { after: readProfileCursor(repoRoot) });
|
|
137
|
+
// ROUTE-15: halfLifeRuns threads from config as a pure param; undefined ⇒ module default (byte-identical).
|
|
138
|
+
return rows.length ? buildProfile(rows, { halfLifeRuns: cfg.routing.learnedTuning?.halfLifeRuns }) : undefined; // cold ⇒ undefined ⇒ v1.5 dead-code path
|
|
139
|
+
}
|
|
140
|
+
export class Journal {
|
|
141
|
+
dir;
|
|
142
|
+
runId;
|
|
143
|
+
narrate;
|
|
144
|
+
constructor(dir, runId, narrate) {
|
|
145
|
+
this.dir = dir;
|
|
146
|
+
this.runId = runId;
|
|
147
|
+
this.narrate = narrate;
|
|
148
|
+
}
|
|
149
|
+
static create(repoRoot, runId, narrate) {
|
|
150
|
+
const dir = join(runsDir(repoRoot), runId);
|
|
151
|
+
mkdirSync(dir, { recursive: true });
|
|
152
|
+
return new Journal(dir, runId, narrate);
|
|
153
|
+
}
|
|
154
|
+
static open(repoRoot, runId, narrate) {
|
|
155
|
+
const dir = join(runsDir(repoRoot), runId);
|
|
156
|
+
if (!existsSync(join(dir, "journal.jsonl")))
|
|
157
|
+
throw new Error(`no journal for ${runId} at ${dir}`);
|
|
158
|
+
return new Journal(dir, runId, narrate);
|
|
159
|
+
}
|
|
160
|
+
static latestRunId(repoRoot) {
|
|
161
|
+
if (!existsSync(runsDir(repoRoot)))
|
|
162
|
+
return null;
|
|
163
|
+
const ids = readdirSync(runsDir(repoRoot)).filter((d) => d.startsWith("run-")).sort();
|
|
164
|
+
return ids.at(-1) ?? null;
|
|
165
|
+
}
|
|
166
|
+
get journalPath() {
|
|
167
|
+
return join(this.dir, "journal.jsonl");
|
|
168
|
+
}
|
|
169
|
+
append(event, taskId, data = {}) {
|
|
170
|
+
const row = { ts: new Date().toISOString(), event, ...(taskId ? { taskId } : {}), data };
|
|
171
|
+
appendFileSync(this.journalPath, JSON.stringify(row) + "\n");
|
|
172
|
+
try {
|
|
173
|
+
this.narrate?.(row);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// narration is observational; a broken sink must not affect the journal or run
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
read() {
|
|
180
|
+
return readJsonl(this.journalPath);
|
|
181
|
+
}
|
|
182
|
+
replayStatuses() {
|
|
183
|
+
const s = new Map();
|
|
184
|
+
for (const e of this.read()) {
|
|
185
|
+
if (!e.taskId)
|
|
186
|
+
continue;
|
|
187
|
+
if (e.event === "task-dispatch")
|
|
188
|
+
s.set(e.taskId, "running");
|
|
189
|
+
else if (e.event === "task-done")
|
|
190
|
+
s.set(e.taskId, "done");
|
|
191
|
+
else if (e.event === "task-failed")
|
|
192
|
+
s.set(e.taskId, "failed");
|
|
193
|
+
else if (e.event === "task-human")
|
|
194
|
+
s.set(e.taskId, "human");
|
|
195
|
+
// GATE-08 (v1.12): approval is a journal EVENT, never a graph.json mutation (graph.json is compiled
|
|
196
|
+
// output; recompile re-emits humanGate:true and would silently erase it — Phase 42 D-02). Events
|
|
197
|
+
// replay in order, so task-human → task-approved lands on pending (last write wins). Additive-only:
|
|
198
|
+
// 26 real journals with no such event replay byte-identically (D-04).
|
|
199
|
+
else if (e.event === "task-approved")
|
|
200
|
+
s.set(e.taskId, "pending");
|
|
201
|
+
}
|
|
202
|
+
for (const [id, st] of s)
|
|
203
|
+
if (st === "running")
|
|
204
|
+
s.set(id, "pending");
|
|
205
|
+
return s;
|
|
206
|
+
}
|
|
207
|
+
// Phase 46 (RES-01/RES-02): companion replay — derives per-task resume state {attempts, tried,
|
|
208
|
+
// lastAssignment} from EXISTING events only (task-dispatch + consult-verdict + optional v1.24
|
|
209
|
+
// task-approved{release:attempt-cap}). Additive-only: no new required event, no schema change, the
|
|
210
|
+
// status replay above is byte-untouched (corpus criterion 3 is git-diff-provable). Motivated by the
|
|
211
|
+
// 2026-07-11 incident (run-20260711-185020, P43-03): `drovr resume` re-dispatched at attempt 0 on
|
|
212
|
+
// pi:zai/glm-5.2, the exact channel a frontier consult had just banned, because execTask's
|
|
213
|
+
// attempt/tried/assignment state is loop-local and dies with the process while the journal held every fact needed.
|
|
214
|
+
//
|
|
215
|
+
// attempts is a COUNT of task-dispatch events, NEVER max(data.attempt)+1: existing journals'
|
|
216
|
+
// post-resume dispatches restart at 0 (the bug corrupted its own evidence — incident journal L58
|
|
217
|
+
// logged attempt 0 two ms after run-resume). Count === max+1 on clean journals and is truthful on
|
|
218
|
+
// corrupted ones. tried is the ordered dedup of channelKey(assignment) across dispatches (≡ the
|
|
219
|
+
// pre-kill tried list). lastAssignment is the last well-formed dispatched assignment.
|
|
220
|
+
replayResumeState() {
|
|
221
|
+
const m = new Map();
|
|
222
|
+
const pendingReroute = new Set(); // reroute verdicts not yet cleared by a later dispatch
|
|
223
|
+
for (const e of this.read()) {
|
|
224
|
+
if (!e.taskId)
|
|
225
|
+
continue;
|
|
226
|
+
if (e.event === "task-dispatch") {
|
|
227
|
+
// A subsequent dispatch clears the pending reroute — the reroute was acted on pre-kill.
|
|
228
|
+
pendingReroute.delete(e.taskId);
|
|
229
|
+
let st = m.get(e.taskId);
|
|
230
|
+
if (!st) {
|
|
231
|
+
st = { attempts: 0, tried: [] };
|
|
232
|
+
m.set(e.taskId, st);
|
|
233
|
+
}
|
|
234
|
+
st.attempts++; // COUNT, never max(data.attempt)+1 — see rationale above
|
|
235
|
+
const parsed = DispatchAssignmentSchema.safeParse(e.data.assignment);
|
|
236
|
+
if (parsed.success) {
|
|
237
|
+
const key = channelKey(parsed.data);
|
|
238
|
+
if (!st.tried.includes(key))
|
|
239
|
+
st.tried.push(key);
|
|
240
|
+
st.lastAssignment = parsed.data;
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
// fail closed: malformed assignment still COUNTS (above) but adds nothing to tried and
|
|
244
|
+
// poisons only lastAssignment (a malformed LAST dispatch must not be restored).
|
|
245
|
+
st.lastAssignment = undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else if (e.event === "consult-verdict" && e.data.action === "reroute") {
|
|
249
|
+
// A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
|
|
250
|
+
pendingReroute.add(e.taskId);
|
|
251
|
+
}
|
|
252
|
+
else if (e.event === "task-approved" && e.data.release === ATTEMPT_CAP_RELEASE) {
|
|
253
|
+
// v1.24 OBS-18: operator released an attempt-cap park. Pre-v1.24 task-approved events have no
|
|
254
|
+
// `release` key ⇒ this branch never fires (corpus criterion: identical statuses + resume state).
|
|
255
|
+
// attempts reset to 0 so the daemon's attempt-cap check does not re-park in the same tick;
|
|
256
|
+
// tried survives (consult bans and burned channels are not forgotten); lastAssignment is
|
|
257
|
+
// cleared so the daemon's nextChannel-over-tried path skips burned channels on first dispatch
|
|
258
|
+
// (restoring the last burned assignment would re-try it first — the failure the tried-list exists to prevent).
|
|
259
|
+
const st = m.get(e.taskId);
|
|
260
|
+
if (st) {
|
|
261
|
+
st.attempts = 0;
|
|
262
|
+
st.lastAssignment = undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Trailing-reroute edge (D-01 kill between verdict and dispatch): a reroute verdict with NO
|
|
267
|
+
// subsequent dispatch means the last-dispatched channel is itself banned — add it to tried (if
|
|
268
|
+
// absent) and clear lastAssignment so the daemon falls back to nextChannel over the exclusions.
|
|
269
|
+
for (const taskId of pendingReroute) {
|
|
270
|
+
const st = m.get(taskId);
|
|
271
|
+
if (st?.lastAssignment) {
|
|
272
|
+
const key = channelKey(st.lastAssignment);
|
|
273
|
+
if (!st.tried.includes(key))
|
|
274
|
+
st.tried.push(key);
|
|
275
|
+
st.lastAssignment = undefined;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return m;
|
|
279
|
+
}
|
|
280
|
+
telemetry(row) {
|
|
281
|
+
appendFileSync(join(this.dir, "telemetry.jsonl"), JSON.stringify(row) + "\n");
|
|
282
|
+
}
|
|
283
|
+
// Per-run, raw (NOT schema-validated) — report.ts reads v1.5 core fields; stays byte-compatible.
|
|
284
|
+
readTelemetry() {
|
|
285
|
+
return readJsonl(join(this.dir, "telemetry.jsonl"));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const HEARTBEAT_MS = 10000;
|
|
2
|
+
export declare const STALE_MS = 60000;
|
|
3
|
+
export interface Inspection {
|
|
4
|
+
pid?: number;
|
|
5
|
+
runId?: string;
|
|
6
|
+
garbage: boolean;
|
|
7
|
+
dead: boolean;
|
|
8
|
+
expired: boolean;
|
|
9
|
+
mtimeMs: number;
|
|
10
|
+
ino: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function shouldRefuse(i: Pick<Inspection, "garbage" | "dead">): boolean;
|
|
13
|
+
export declare function acquireRunLock(repoRoot: string, runId: string): {
|
|
14
|
+
reclaimed?: {
|
|
15
|
+
pid: number;
|
|
16
|
+
mtimeMs: number;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
export declare function releaseRunLock(repoRoot: string): void;
|
|
20
|
+
export declare function isRunLockLive(repoRoot: string): boolean;
|
|
21
|
+
export declare function unlockRun(repoRoot: string): {
|
|
22
|
+
held: false;
|
|
23
|
+
} | {
|
|
24
|
+
held: true;
|
|
25
|
+
removed: true;
|
|
26
|
+
pid?: number;
|
|
27
|
+
runId?: string;
|
|
28
|
+
garbage: boolean;
|
|
29
|
+
};
|
package/dist/run/lock.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { linkSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { drovrDir } from "../graph/graph.js";
|
|
5
|
+
// HARD-01/02: coarse per-run advisory lock over .drovr/graph.json. LOCK-02: the lock is created by
|
|
6
|
+
// the link(2) idiom — write the full payload to graph.lock.<pid>.tmp, then linkSync(tmp, lockPath),
|
|
7
|
+
// which is atomic and throws EEXIST if the lock already exists (the mutual-exclusion primitive).
|
|
8
|
+
// rename() was REJECTED: it silently clobbers an existing destination, destroying that mutual
|
|
9
|
+
// exclusion (two daemons would both "acquire"). LOCK-02 (OBS-05): a PROVABLY-dead holder (ESRCH)
|
|
10
|
+
// self-clears immediately — expiry is NOT required. ESRCH is proof-positive death; the PID-reuse
|
|
11
|
+
// hazard runs the OTHER way (a reused pid reads ALIVE and refuses until `drovr unlock`). mtime stays
|
|
12
|
+
// the heartbeat — it feeds the reclaim race guard (ino+mtime re-stat) and the reclaimed audit value.
|
|
13
|
+
// Zero new deps — node:fs stdlib.
|
|
14
|
+
export const HEARTBEAT_MS = 10_000;
|
|
15
|
+
export const STALE_MS = 60_000; // 6× heartbeat headroom; PITFALLS floor is ≥5s beat / ~30s staleness
|
|
16
|
+
// T-10-01: the payload is a trust boundary — parse with zod, fail closed on garbage. Only
|
|
17
|
+
// pid/runId/startedAt are ever read; anything else is ignored (info-disclosure surface).
|
|
18
|
+
const PayloadSchema = z.object({ pid: z.number().int().positive(), runId: z.string(), startedAt: z.number() });
|
|
19
|
+
const lockPath = (repoRoot) => join(drovrDir(repoRoot), "graph.lock");
|
|
20
|
+
let heartbeat;
|
|
21
|
+
let heldPath;
|
|
22
|
+
// Best-effort release if the daemon exits without hitting its finally (crash mid-body). NO
|
|
23
|
+
// SIGINT/SIGTERM handlers — those change kill semantics and leak listeners across the test
|
|
24
|
+
// suite's many runDaemon calls; signal-death is exactly what stale-reclaim exists for.
|
|
25
|
+
process.once("exit", () => { if (heldPath)
|
|
26
|
+
unlinkIfOurs(heldPath); });
|
|
27
|
+
// LOCK-04: the ONE decision table. Both acquireRunLock and isRunLockLive consume this — the two
|
|
28
|
+
// hand-maintained copies of the rule cannot drift. The table changes HERE, once. unlockRun uses the
|
|
29
|
+
// SAME inspect() with its own live-holder refusal (`!dead && !garbage`) — it is the escape hatch, not
|
|
30
|
+
// a second copy of the rule.
|
|
31
|
+
// LOCK-02 (OBS-05): refuse iff garbage OR alive. dead (ESRCH) self-clears through the reclaim branch
|
|
32
|
+
// below — no 60s heartbeat wait. Expiry no longer participates in the DECISION (Inspect still carries
|
|
33
|
+
// `expired` for the race guard + reclaim audit); the heartbeat mechanism itself is untouched.
|
|
34
|
+
// ponytail: a pid REUSED by an unrelated live process reads ALIVE (kill(pid,0) succeeds) and now
|
|
35
|
+
// refuses indefinitely instead of expiring out in ≤60s — that is the fail-closed direction; the
|
|
36
|
+
// escape hatch is `drovr unlock`. Acceptable for a single-machine tool (ESRCH is the only
|
|
37
|
+
// proof-positive death; narrowing further needs pid-start-time correlation, out of scope here).
|
|
38
|
+
// LOCK-01: garbage ⇒ always refuse (mtime irrelevant). It short-circuits before dead can matter —
|
|
39
|
+
// so inspect()'s `dead = pid === undefined` fallback for the garbage row stays harmless. Safe post-16-01:
|
|
40
|
+
// the atomic link(2) write means drovr can no longer mint garbage itself; a garbage payload can only
|
|
41
|
+
// come from external corruption, which is exactly what must refuse. Only `drovr unlock` removes it
|
|
42
|
+
// — a self-heal reclaim would silently overwrite whatever corrupted the file.
|
|
43
|
+
export function shouldRefuse(i) {
|
|
44
|
+
return i.garbage || !i.dead;
|
|
45
|
+
}
|
|
46
|
+
// statSync throws ENOENT when no lock exists — callers treat that as "not held".
|
|
47
|
+
function inspect(p) {
|
|
48
|
+
const st = statSync(p); // single stat: both the heartbeat mtime and the reclaim-guard inode
|
|
49
|
+
const mtimeMs = st.mtimeMs;
|
|
50
|
+
const expired = Date.now() - mtimeMs > STALE_MS;
|
|
51
|
+
const parsed = PayloadSchema.safeParse(readPayload(p));
|
|
52
|
+
const garbage = !parsed.success; // LOCK-01: its own state — shouldRefuse refuses it unconditionally; only `drovr unlock` removes it
|
|
53
|
+
const pid = parsed.success ? parsed.data.pid : undefined;
|
|
54
|
+
let dead = pid === undefined; // harmless fallback for the garbage row — garbage short-circuits shouldRefuse before this is read
|
|
55
|
+
if (pid !== undefined) {
|
|
56
|
+
try {
|
|
57
|
+
process.kill(pid, 0);
|
|
58
|
+
dead = false;
|
|
59
|
+
} // no throw ⇒ ALIVE
|
|
60
|
+
catch (k) {
|
|
61
|
+
dead = k.code === "ESRCH";
|
|
62
|
+
} // ESRCH ⇒ dead; EPERM ⇒ ALIVE
|
|
63
|
+
}
|
|
64
|
+
return { pid, runId: parsed.success ? parsed.data.runId : undefined, garbage, dead, expired, mtimeMs, ino: st.ino };
|
|
65
|
+
}
|
|
66
|
+
function readPayload(p) {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function unlinkIfOurs(p) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = PayloadSchema.safeParse(readPayload(p));
|
|
77
|
+
if (parsed.success && parsed.data.pid === process.pid)
|
|
78
|
+
unlinkSync(p);
|
|
79
|
+
}
|
|
80
|
+
catch { /* ENOENT — nothing to release */ }
|
|
81
|
+
}
|
|
82
|
+
export function acquireRunLock(repoRoot, runId) {
|
|
83
|
+
const p = lockPath(repoRoot);
|
|
84
|
+
const tmp = join(drovrDir(repoRoot), `graph.lock.${process.pid}.tmp`);
|
|
85
|
+
try {
|
|
86
|
+
try {
|
|
87
|
+
unlinkSync(tmp);
|
|
88
|
+
}
|
|
89
|
+
catch { /* W6: best-effort clean of our own stray tmp from a prior SIGKILL */ }
|
|
90
|
+
writeFileSync(tmp, JSON.stringify({ pid: process.pid, runId, startedAt: Date.now() }));
|
|
91
|
+
try {
|
|
92
|
+
linkSync(tmp, p); // atomic: exists ⇒ EEXIST — replaces the old exclusive-create open, no write window on the lock path
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
unlinkSync(tmp); // the link survives; tmp is just a handle. finally ⇒ no litter, even for the EEXIST loser
|
|
96
|
+
}
|
|
97
|
+
heldPath = p;
|
|
98
|
+
heartbeat = setInterval(() => { try {
|
|
99
|
+
const now = new Date();
|
|
100
|
+
utimesSync(p, now, now);
|
|
101
|
+
}
|
|
102
|
+
catch { /* released */ } }, HEARTBEAT_MS);
|
|
103
|
+
heartbeat.unref(); // never hold the daemon's event loop open
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (e.code !== "EEXIST")
|
|
108
|
+
throw e;
|
|
109
|
+
const insp = inspect(p);
|
|
110
|
+
const { pid, runId: heldRun, garbage, mtimeMs } = insp;
|
|
111
|
+
if (!shouldRefuse(insp)) {
|
|
112
|
+
// ponytail: inode+mtime guard NARROWS but does not close the cross-process reclaim double-unlink
|
|
113
|
+
// race; a real fix needs unlink-by-inode (no POSIX primitive) or a lock-directory rename dance.
|
|
114
|
+
// Single-machine tool — this is the ceiling.
|
|
115
|
+
const st = statSync(p); // re-stat immediately before removing — abort if replaced since inspect()
|
|
116
|
+
if (st.ino !== insp.ino || st.mtimeMs !== insp.mtimeMs)
|
|
117
|
+
return acquireRunLock(repoRoot, runId); // loser path
|
|
118
|
+
try {
|
|
119
|
+
unlinkSync(p);
|
|
120
|
+
}
|
|
121
|
+
catch (e2) {
|
|
122
|
+
if (e2.code !== "ENOENT")
|
|
123
|
+
throw e2;
|
|
124
|
+
} // successor already took it
|
|
125
|
+
const again = acquireRunLock(repoRoot, runId); // re-serializes two stealers through linkSync
|
|
126
|
+
return { reclaimed: { pid: pid ?? -1, mtimeMs }, ...again };
|
|
127
|
+
}
|
|
128
|
+
if (garbage)
|
|
129
|
+
throw new Error(".drovr/graph.lock holds an unreadable/garbage payload — refusing to reclaim it; run `drovr unlock` to remove it");
|
|
130
|
+
// LOCK-02: shouldRefuse is false whenever dead, so this throw is reached only for a LIVE holder
|
|
131
|
+
// (incl. EPERM = alive-but-not-ours). The dead-but-fresh case self-clears via the reclaim branch.
|
|
132
|
+
throw new Error(`.drovr/graph.lock held by pid ${pid ?? "?"}${heldRun ? ` (run ${heldRun})` : ""} — another drovr run? (operator escape: \`drovr unlock\`)`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function releaseRunLock(repoRoot) {
|
|
136
|
+
if (heartbeat) {
|
|
137
|
+
clearInterval(heartbeat);
|
|
138
|
+
heartbeat = undefined;
|
|
139
|
+
}
|
|
140
|
+
unlinkIfOurs(lockPath(repoRoot)); // never delete a reclaiming successor's lock — only ours
|
|
141
|
+
heldPath = undefined;
|
|
142
|
+
}
|
|
143
|
+
// Read-only check for compile: true iff a lock exists that the decision table would REFUSE on
|
|
144
|
+
// (alive, EPERM, or ANY garbage). A provably-dead holder (ESRCH) reads not-live — compile proceeds
|
|
145
|
+
// in step with resume (LOCK-02/OBS-05). Never mutates the lock.
|
|
146
|
+
export function isRunLockLive(repoRoot) {
|
|
147
|
+
try {
|
|
148
|
+
return shouldRefuse(inspect(lockPath(repoRoot)));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
} // no lock
|
|
153
|
+
}
|
|
154
|
+
// LOCK-03: operator escape hatch. Liveness-checked delete — removes a dead-holder or garbage lock,
|
|
155
|
+
// REFUSES to remove one whose holder is alive (incl. EPERM = alive-but-not-ours). No --force: the
|
|
156
|
+
// refusal names the pid; the operator kills the process. Liveness logic stays here (LOCK-04
|
|
157
|
+
// discipline extends to this caller) — the CLI command is a thin formatter.
|
|
158
|
+
// W4 TOCTOU: liveness is checked, THEN unlinked — a live holder that dies (or a dead pid reused)
|
|
159
|
+
// between inspect() and unlinkSync is a window this does NOT close. Acceptable: unlock is
|
|
160
|
+
// operator-initiated on an already-parked run; worst case is removing a lock a just-reborn process
|
|
161
|
+
// would want, which the operator triggered and can recover by re-running. NOT an atomic re-check.
|
|
162
|
+
export function unlockRun(repoRoot) {
|
|
163
|
+
const p = lockPath(repoRoot);
|
|
164
|
+
let insp;
|
|
165
|
+
try {
|
|
166
|
+
insp = inspect(p);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return { held: false };
|
|
170
|
+
} // statSync ENOENT ⇒ no lock
|
|
171
|
+
if (!insp.dead && !insp.garbage) {
|
|
172
|
+
throw new Error(`.drovr/graph.lock held by LIVE pid ${insp.pid}${insp.runId ? ` (run ${insp.runId})` : ""} — refusing to unlock; stop that run first`);
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
unlinkSync(p);
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
if (e.code !== "ENOENT")
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
return { held: true, removed: true, pid: insp.pid, runId: insp.runId, garbage: insp.garbage };
|
|
182
|
+
}
|