tickmarkr 2.5.3 → 2.5.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/registry.js +6 -1
- package/dist/adapters/types.d.ts +3 -0
- package/dist/cli/commands/approve.d.ts +1 -0
- package/dist/cli/commands/approve.js +54 -6
- package/dist/cli/commands/doctor.d.ts +4 -0
- package/dist/cli/commands/doctor.js +44 -29
- package/dist/cli/commands/fleet.js +195 -33
- package/dist/cli/commands/init.js +196 -6
- package/dist/cli/commands/plan.js +144 -3
- package/dist/cli/commands/resume.js +6 -2
- package/dist/cli/commands/run.js +21 -2
- package/dist/cli/commands/verify.js +1 -1
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +3 -1
- package/dist/compile/collateral.d.ts +2 -0
- package/dist/compile/collateral.js +50 -9
- package/dist/compile/ownership.d.ts +7 -0
- package/dist/compile/ownership.js +59 -22
- package/dist/config/config.d.ts +23 -8
- package/dist/config/config.js +42 -28
- package/dist/config/fleet-overlay.d.ts +3 -9
- package/dist/config/fleet-overlay.js +68 -11
- package/dist/config/fleet-why.d.ts +7 -0
- package/dist/config/fleet-why.js +5 -0
- package/dist/drivers/herdr.d.ts +5 -0
- package/dist/drivers/herdr.js +14 -3
- package/dist/drivers/index.d.ts +15 -1
- package/dist/drivers/index.js +38 -10
- package/dist/drivers/orca.d.ts +99 -10
- package/dist/drivers/orca.js +586 -97
- package/dist/drivers/types.d.ts +1 -0
- package/dist/gates/baseline.d.ts +3 -0
- package/dist/gates/baseline.js +2 -1
- package/dist/gates/cache.d.ts +100 -0
- package/dist/gates/cache.js +389 -0
- package/dist/gates/review.d.ts +35 -2
- package/dist/gates/review.js +64 -16
- package/dist/gates/run-gates.d.ts +5 -0
- package/dist/gates/run-gates.js +134 -18
- package/dist/gates/test-manifest.d.ts +99 -0
- package/dist/gates/test-manifest.js +389 -0
- package/dist/gates/test-reporter.d.ts +4 -0
- package/dist/gates/test-reporter.js +49 -0
- package/dist/route/preference.d.ts +22 -1
- package/dist/route/preference.js +123 -25
- package/dist/route/router.d.ts +13 -0
- package/dist/route/router.js +95 -17
- package/dist/run/daemon.d.ts +13 -0
- package/dist/run/daemon.js +398 -96
- package/dist/run/git.d.ts +35 -1
- package/dist/run/git.js +111 -10
- package/dist/run/journal.d.ts +14 -2
- package/dist/run/journal.js +78 -10
- package/dist/run/lease.d.ts +14 -0
- package/dist/run/lease.js +87 -0
- package/dist/run/merge.d.ts +2 -0
- package/dist/run/merge.js +91 -3
- package/dist/run/operator-state.d.ts +11 -0
- package/dist/run/operator-state.js +17 -3
- package/dist/tui/cockpit/board.d.ts +96 -0
- package/dist/tui/cockpit/board.js +346 -0
- package/dist/tui/cockpit/decision-actions.js +2 -0
- package/dist/tui/cockpit/layout.d.ts +5 -1
- package/dist/tui/cockpit/layout.js +8 -3
- package/dist/tui/cockpit/live-runtime.js +83 -31
- package/dist/tui/cockpit/run-view.d.ts +7 -5
- package/dist/tui/cockpit/run-view.js +12 -11
- package/dist/tui/ink/fleet-app.d.ts +41 -27
- package/dist/tui/ink/fleet-app.js +204 -31
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +180 -99
package/dist/run/git.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { ROUTING_ENV_SEAMS };
|
|
|
4
4
|
export declare const FORK_CAP_ENV = "VITEST_MAX_FORKS";
|
|
5
5
|
export declare const SUITE_PARENT_ENV = "TICKMARKR_SUITE_PARENT";
|
|
6
6
|
export declare const DEFAULT_FORK_CAP = "6";
|
|
7
|
-
/** Verification
|
|
7
|
+
/** Verification uses command leases; workers keep their concurrency budget. */
|
|
8
8
|
export declare const runWithVerificationBudget: <T>(capacity: RunCapacity, fn: () => Promise<T>) => Promise<T>;
|
|
9
9
|
/**
|
|
10
10
|
* OBS-618: how many PROCESSES one vitest fork can hold at its peak — the fork, a daemon it spawns,
|
|
@@ -89,6 +89,7 @@ export declare const resolvedCapacity: () => RunCapacity;
|
|
|
89
89
|
export declare const DEFAULT_SHELL_TIMEOUT_MS = 600000;
|
|
90
90
|
export interface ShResult {
|
|
91
91
|
code: number;
|
|
92
|
+
signalExit?: boolean;
|
|
92
93
|
stdout: string;
|
|
93
94
|
stderr: string;
|
|
94
95
|
timedOut?: boolean;
|
|
@@ -104,6 +105,14 @@ export declare const setSpawnForTests: (fn: typeof spawn) => void;
|
|
|
104
105
|
export declare const resetSpawnForTests: () => void;
|
|
105
106
|
export declare const SPAWN_ATTEMPT_LIMIT = 4;
|
|
106
107
|
export declare const SPAWN_RETRY_BACKOFF_MS = 50;
|
|
108
|
+
export interface ShellOptions {
|
|
109
|
+
env?: NodeJS.ProcessEnv;
|
|
110
|
+
signal?: AbortSignal;
|
|
111
|
+
onSpawn?: (pid: number | undefined) => void;
|
|
112
|
+
onTimeout?: () => void;
|
|
113
|
+
}
|
|
114
|
+
/** Shared command seam, including invocation-bound manifested runners. */
|
|
115
|
+
export declare function shell(cmd: string, cwd: string, timeoutMs: number, login?: boolean, options?: ShellOptions): Promise<ShResult>;
|
|
107
116
|
export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
|
|
108
117
|
export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
|
|
109
118
|
export declare function shOk(cmd: string, cwd: string): Promise<string>;
|
|
@@ -179,3 +188,28 @@ export type BaseContainment = {
|
|
|
179
188
|
*/
|
|
180
189
|
export declare function declaredBaseContainment(cwd: string, declaredRef: string, targetRef?: string): Promise<BaseContainment>;
|
|
181
190
|
export declare function removeWorktree(repo: string, dir: string): Promise<void>;
|
|
191
|
+
export declare const REFS_PREFLIGHT_PREFIX = "refs/tickmarkr/preflight";
|
|
192
|
+
export declare const REFS_PROBE_REMEDY = "run from the main repository or a full clone; a sandbox that denies writes under that path cannot host a run";
|
|
193
|
+
export interface RefsProbeOk {
|
|
194
|
+
ok: true;
|
|
195
|
+
path: string;
|
|
196
|
+
refsDir: string;
|
|
197
|
+
}
|
|
198
|
+
export interface RefsProbeRefusal {
|
|
199
|
+
ok: false;
|
|
200
|
+
path: string;
|
|
201
|
+
refsDir: string;
|
|
202
|
+
error: string;
|
|
203
|
+
}
|
|
204
|
+
export type RefsProbeResult = RefsProbeOk | RefsProbeRefusal;
|
|
205
|
+
export declare function refsRefusalMessage(action: "run" | "resume", path: string, error: string): string;
|
|
206
|
+
/**
|
|
207
|
+
* OBS-983/984: prove the repository's common git directory accepts a ref write before starting
|
|
208
|
+
* the daemon. In a linked worktree, git resolves refs to the main repository's git directory.
|
|
209
|
+
* Creates and deletes a real ref under refs/tickmarkr/preflight/ through git itself, leaving
|
|
210
|
+
* nothing behind on success.
|
|
211
|
+
*/
|
|
212
|
+
export declare function probeRefsWritable(cwd?: string): Promise<RefsProbeResult>;
|
|
213
|
+
export declare const probeRefs: typeof probeRefsWritable;
|
|
214
|
+
export declare const refsProbe: typeof probeRefsWritable;
|
|
215
|
+
export declare function assertRefsWritable(cwd?: string, action?: "run" | "resume"): Promise<RefsProbeResult>;
|
package/dist/run/git.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { commandLeaseEnvironment, withCommandLease } from "./lease.js";
|
|
1
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { availableParallelism, tmpdir } from "node:os";
|
|
5
6
|
import { join, resolve } from "node:path";
|
|
6
7
|
import { StringDecoder } from "node:string_decoder";
|
|
@@ -18,7 +19,7 @@ export const DEFAULT_FORK_CAP = "6";
|
|
|
18
19
|
/** Worker concurrency is resolved once per run and isolated from other async runs. */
|
|
19
20
|
const forkBudget = new AsyncLocalStorage();
|
|
20
21
|
const verificationBudget = new AsyncLocalStorage();
|
|
21
|
-
/** Verification
|
|
22
|
+
/** Verification uses command leases; workers keep their concurrency budget. */
|
|
22
23
|
export const runWithVerificationBudget = (capacity, fn) => verificationBudget.run(capacity, fn);
|
|
23
24
|
/**
|
|
24
25
|
* OBS-618: how many PROCESSES one vitest fork can hold at its peak — the fork, a daemon it spawns,
|
|
@@ -128,14 +129,17 @@ export const SPAWN_RETRY_BACKOFF_MS = 50;
|
|
|
128
129
|
// RULING-P99-28: SHIP — every gate user can hit the inherited-pipe hang at this product seam.
|
|
129
130
|
/** Give a normally-exited shell's descendants this long to close their inherited pipes themselves. */
|
|
130
131
|
const SHELL_REAP_GRACE_MS = 2000;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
/** Shared command seam, including invocation-bound manifested runners. */
|
|
133
|
+
export function shell(cmd, cwd, timeoutMs, login = false, options = {}) {
|
|
134
|
+
return withCommandLease(cmd, () => executeShell(cmd, cwd, timeoutMs, login, options));
|
|
135
|
+
}
|
|
136
|
+
function executeShell(cmd, cwd, timeoutMs, login, options) {
|
|
137
|
+
options.signal?.throwIfAborted();
|
|
134
138
|
// OBS-74: scrub tickmarkr's own routing env seams from every child — a daemon carrying
|
|
135
139
|
// TICKMARKR_QUALITY leaked it into baseline/gate/tip-verify children, turning a dogfood
|
|
136
140
|
// repo's route() tests red inside the gates. Scrub a copy at this one choke point so
|
|
137
141
|
// children are hermetic by construction; the daemon's own process.env stays unchanged.
|
|
138
|
-
const env =
|
|
142
|
+
const env = commandLeaseEnvironment(options.env ?? process.env);
|
|
139
143
|
for (const k of ROUTING_ENV_SEAMS)
|
|
140
144
|
delete env[k];
|
|
141
145
|
// A run freezes the operator override and cores at startup; admission can lower the round cap.
|
|
@@ -163,17 +167,22 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
163
167
|
const stderrDecoder = new StringDecoder("utf8");
|
|
164
168
|
let timedOut = false, reapedGroup = false, done = false, started = false, outputSeen = false;
|
|
165
169
|
let reapError, exitedCode;
|
|
170
|
+
let signalExit = false;
|
|
166
171
|
let reapTimer;
|
|
172
|
+
let drainTimer;
|
|
167
173
|
const finish = (code, err) => {
|
|
168
174
|
if (done)
|
|
169
175
|
return;
|
|
170
176
|
done = true;
|
|
171
177
|
clearTimeout(timer);
|
|
172
178
|
clearTimeout(reapTimer);
|
|
179
|
+
clearTimeout(drainTimer);
|
|
180
|
+
options.signal?.removeEventListener("abort", abort);
|
|
173
181
|
stdout += stdoutDecoder.end();
|
|
174
182
|
stderr += stderrDecoder.end();
|
|
175
183
|
resolve({
|
|
176
184
|
code,
|
|
185
|
+
...(signalExit ? { signalExit: true } : {}),
|
|
177
186
|
stdout,
|
|
178
187
|
stderr: err ?? stderr,
|
|
179
188
|
timedOut,
|
|
@@ -183,8 +192,18 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
183
192
|
...(reapError ? { reapError } : {}),
|
|
184
193
|
});
|
|
185
194
|
};
|
|
195
|
+
// Escaped descendants can retain pipes even after the shell and its group are dead.
|
|
196
|
+
const boundDrain = () => {
|
|
197
|
+
if (!done && !drainTimer)
|
|
198
|
+
drainTimer = setTimeout(() => {
|
|
199
|
+
p.stdout?.destroy();
|
|
200
|
+
p.stderr?.destroy();
|
|
201
|
+
finish(exitedCode ?? 1);
|
|
202
|
+
}, 100);
|
|
203
|
+
};
|
|
186
204
|
const timer = setTimeout(() => {
|
|
187
205
|
timedOut = true;
|
|
206
|
+
options.onTimeout?.();
|
|
188
207
|
try {
|
|
189
208
|
process.kill(-p.pid, "SIGKILL");
|
|
190
209
|
}
|
|
@@ -198,7 +217,21 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
198
217
|
if (code !== "ESRCH")
|
|
199
218
|
finish(exitedCode ?? 1);
|
|
200
219
|
}
|
|
220
|
+
boundDrain();
|
|
201
221
|
}, timeoutMs);
|
|
222
|
+
const abort = () => {
|
|
223
|
+
try {
|
|
224
|
+
process.kill(-p.pid, "SIGKILL");
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
p.kill("SIGKILL");
|
|
228
|
+
}
|
|
229
|
+
boundDrain();
|
|
230
|
+
};
|
|
231
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
232
|
+
options.onSpawn?.(p.pid);
|
|
233
|
+
if (options.signal?.aborted)
|
|
234
|
+
abort();
|
|
202
235
|
p.on("spawn", () => { started = true; }); // the command exists from here on — never retryable past it
|
|
203
236
|
// OBS-716: one stateful decoder per stream carries an incomplete UTF-8 sequence into that
|
|
204
237
|
// stream's next pipe chunk; decoding each chunk through string concatenation corrupts bytes at
|
|
@@ -219,22 +252,23 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
219
252
|
if (!done && !started && !outputSeen && e.code === RETRYABLE_SPAWN_CODE) {
|
|
220
253
|
done = true;
|
|
221
254
|
clearTimeout(timer);
|
|
255
|
+
options.signal?.removeEventListener("abort", abort);
|
|
222
256
|
resolve({ refused: e });
|
|
223
257
|
return;
|
|
224
258
|
}
|
|
225
259
|
finish(127, String(e));
|
|
226
260
|
});
|
|
227
|
-
p.on("close", (code) => finish(code ?? 1));
|
|
261
|
+
p.on("close", (code) => { signalExit = code === null; finish(code ?? 1); });
|
|
228
262
|
// "close" waits for stdio to drain. Once bash exits normally, give descendants a bounded grace
|
|
229
263
|
// to exit with it; a survivor still in bash's detached group is then reaped so its inherited pipe
|
|
230
264
|
// cannot hold this promise until the command ceiling. A real timeout wins first and is never
|
|
231
265
|
// reclassified as a grace reap.
|
|
232
266
|
p.on("exit", (code) => {
|
|
267
|
+
signalExit = code === null;
|
|
233
268
|
exitedCode = code ?? 1;
|
|
234
|
-
|
|
235
|
-
|
|
269
|
+
// Allow a short pipe drain after killing, bounded even for an escaped descendant.
|
|
270
|
+
if (timedOut)
|
|
236
271
|
return;
|
|
237
|
-
}
|
|
238
272
|
reapTimer = setTimeout(() => {
|
|
239
273
|
if (done)
|
|
240
274
|
return;
|
|
@@ -565,3 +599,70 @@ export async function removeWorktree(repo, dir) {
|
|
|
565
599
|
await shGit(`rm -rf ${shq(dir)}`, repo);
|
|
566
600
|
await shGit("git worktree prune", repo);
|
|
567
601
|
}
|
|
602
|
+
export const REFS_PREFLIGHT_PREFIX = "refs/tickmarkr/preflight";
|
|
603
|
+
export const REFS_PROBE_REMEDY = "run from the main repository or a full clone; a sandbox that denies writes under that path cannot host a run";
|
|
604
|
+
export function refsRefusalMessage(action, path, error) {
|
|
605
|
+
return `refusing to ${action}: repository refs directory ${path} is not writable (${error}). `
|
|
606
|
+
+ `Remedy: ${REFS_PROBE_REMEDY}.`;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* OBS-983/984: prove the repository's common git directory accepts a ref write before starting
|
|
610
|
+
* the daemon. In a linked worktree, git resolves refs to the main repository's git directory.
|
|
611
|
+
* Creates and deletes a real ref under refs/tickmarkr/preflight/ through git itself, leaving
|
|
612
|
+
* nothing behind on success.
|
|
613
|
+
*/
|
|
614
|
+
export async function probeRefsWritable(cwd = process.cwd()) {
|
|
615
|
+
const refsPathRes = await shGit("git rev-parse --git-path refs", cwd);
|
|
616
|
+
if (refsPathRes.code !== 0) {
|
|
617
|
+
const error = (refsPathRes.stderr || refsPathRes.stdout).trim() || "git rev-parse --git-path refs failed";
|
|
618
|
+
return { ok: false, path: "", refsDir: "", error };
|
|
619
|
+
}
|
|
620
|
+
const raw = refsPathRes.stdout.trim();
|
|
621
|
+
let refsDir = resolve(cwd, raw);
|
|
622
|
+
try {
|
|
623
|
+
refsDir = realpathSync(refsDir);
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
// keep resolved path if realpath fails
|
|
627
|
+
}
|
|
628
|
+
const headRes = await shGit("git rev-parse HEAD", cwd);
|
|
629
|
+
const target = headRes.code === 0 && headRes.stdout.trim()
|
|
630
|
+
? headRes.stdout.trim()
|
|
631
|
+
: "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
632
|
+
const probeId = `probe-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
633
|
+
const ref = `${REFS_PREFLIGHT_PREFIX}/${probeId}`;
|
|
634
|
+
const createRes = await shGit(`git update-ref ${shq(ref)} ${shq(target)}`, cwd);
|
|
635
|
+
if (createRes.code !== 0) {
|
|
636
|
+
const error = (createRes.stderr || createRes.stdout).trim() || `git update-ref failed (${createRes.code})`;
|
|
637
|
+
return { ok: false, path: refsDir, refsDir, error };
|
|
638
|
+
}
|
|
639
|
+
const delRes = await shGit(`git update-ref -d ${shq(ref)}`, cwd);
|
|
640
|
+
if (delRes.code !== 0) {
|
|
641
|
+
const error = (delRes.stderr || delRes.stdout).trim() || `git update-ref -d failed (${delRes.code})`;
|
|
642
|
+
return { ok: false, path: refsDir, refsDir, error };
|
|
643
|
+
}
|
|
644
|
+
// Clean up empty directories left behind by git
|
|
645
|
+
try {
|
|
646
|
+
const preflightDir = join(refsDir, "tickmarkr", "preflight");
|
|
647
|
+
if (existsSync(preflightDir) && readdirSync(preflightDir).length === 0) {
|
|
648
|
+
rmSync(preflightDir, { recursive: true, force: true });
|
|
649
|
+
}
|
|
650
|
+
const tmDir = join(refsDir, "tickmarkr");
|
|
651
|
+
if (existsSync(tmDir) && readdirSync(tmDir).length === 0) {
|
|
652
|
+
rmSync(tmDir, { recursive: true, force: true });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
// best-effort cleanup of empty dirs
|
|
657
|
+
}
|
|
658
|
+
return { ok: true, path: refsDir, refsDir };
|
|
659
|
+
}
|
|
660
|
+
export const probeRefs = probeRefsWritable;
|
|
661
|
+
export const refsProbe = probeRefsWritable;
|
|
662
|
+
export async function assertRefsWritable(cwd = process.cwd(), action = "run") {
|
|
663
|
+
const probe = await probeRefsWritable(cwd);
|
|
664
|
+
if (!probe.ok) {
|
|
665
|
+
throw new Error(refsRefusalMessage(action, probe.path, probe.error));
|
|
666
|
+
}
|
|
667
|
+
return probe;
|
|
668
|
+
}
|
package/dist/run/journal.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type Assignment } from "../adapters/types.js";
|
|
3
3
|
import type { TickmarkrConfig } from "../config/config.js";
|
|
4
|
-
import { type GateName, type Task, type TaskStatus } from "../graph/schema.js";
|
|
4
|
+
import { type GateName, type RunGraph, type Task, type TaskStatus } from "../graph/schema.js";
|
|
5
5
|
import { type ProfileDiscount, type RoutingProfile } from "../route/profile.js";
|
|
6
6
|
import { type DecisionEventWrite, type TrackedJournalRow } from "./protocol.js";
|
|
7
7
|
export interface JournalEvent {
|
|
@@ -192,7 +192,7 @@ export declare function pendingRepairFindings(events: JournalEvent[], taskId: st
|
|
|
192
192
|
* a channel it is no longer using, and a later unrelated failure is not parked under a stale reason.
|
|
193
193
|
*/
|
|
194
194
|
export declare function activeRetryBan(events: JournalEvent[], taskId: string, channel: string): string | undefined;
|
|
195
|
-
export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch", "authoring"];
|
|
195
|
+
export declare const PARK_KINDS: readonly ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota", "reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch", "authoring", "scope-request"];
|
|
196
196
|
export type ParkKind = (typeof PARK_KINDS)[number];
|
|
197
197
|
export declare const RETRY_MODES: readonly ["resume", "fresh", "repair"];
|
|
198
198
|
export type RetryMode = (typeof RETRY_MODES)[number];
|
|
@@ -243,6 +243,7 @@ export declare const TelemetryRowSchema: z.ZodObject<{
|
|
|
243
243
|
"merge-conflict": "merge-conflict";
|
|
244
244
|
"tip-moved": "tip-moved";
|
|
245
245
|
authoring: "authoring";
|
|
246
|
+
"scope-request": "scope-request";
|
|
246
247
|
}>>;
|
|
247
248
|
tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
248
249
|
input: z.ZodNumber;
|
|
@@ -297,6 +298,17 @@ export declare function gateResultJournalData(gate: string, pass: boolean, detai
|
|
|
297
298
|
signalQuality: number;
|
|
298
299
|
} & Record<string, unknown>;
|
|
299
300
|
export declare function recordedGraphDefinitionHash(events: JournalEvent[]): string | undefined;
|
|
301
|
+
/** An approval is the durable authority; graph.json is only its materialized projection. */
|
|
302
|
+
export declare const ScopeAmendmentSchema: z.ZodObject<{
|
|
303
|
+
from: z.ZodString;
|
|
304
|
+
to: z.ZodString;
|
|
305
|
+
beforeFiles: z.ZodArray<z.ZodString>;
|
|
306
|
+
files: z.ZodArray<z.ZodString>;
|
|
307
|
+
parkLine: z.ZodNumber;
|
|
308
|
+
}, z.core.$strip>;
|
|
309
|
+
export declare function replayScopeAmendments(graph: RunGraph, events: JournalEvent[]): RunGraph;
|
|
310
|
+
/** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
|
|
311
|
+
export declare function applyScopeAmendments(graph: RunGraph, journal: Journal, auditReplay?: boolean): RunGraph;
|
|
300
312
|
export type EngagementCompare = {
|
|
301
313
|
comparable: true;
|
|
302
314
|
recorded: string;
|
package/dist/run/journal.js
CHANGED
|
@@ -3,7 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
|
|
6
|
-
import { stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
|
|
6
|
+
import { graphDefinitionHash, stateDirName, taskContentDigest, tickmarkrDir } from "../graph/graph.js";
|
|
7
7
|
import { GATE_NAMES, TIERS } from "../graph/schema.js";
|
|
8
8
|
import { channelRouteIdentity } from "../route/preference.js";
|
|
9
9
|
import { buildProfile, classify } from "../route/profile.js";
|
|
@@ -773,7 +773,7 @@ const DispatchAssignmentSchema = z.object({
|
|
|
773
773
|
// routing profile's QUALITY_FAIL_PARKS (route/profile.ts): the channel did nothing wrong.
|
|
774
774
|
export const PARK_KINDS = ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota",
|
|
775
775
|
"reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch",
|
|
776
|
-
"authoring"];
|
|
776
|
+
"authoring", "scope-request"];
|
|
777
777
|
// v1.85 T3: "repair" is a third dispatch mode beside the v1.29 session pair — a fix-only attempt that
|
|
778
778
|
// carries the failing findings and the diff CONTENT of the work already landed, instead of re-buying
|
|
779
779
|
// ~20m of onboarding to rediscover them (62 of 68 measured re-dispatches were fresh).
|
|
@@ -971,6 +971,63 @@ export function recordedGraphDefinitionHash(events) {
|
|
|
971
971
|
}
|
|
972
972
|
return recorded ?? undefined;
|
|
973
973
|
}
|
|
974
|
+
/** An approval is the durable authority; graph.json is only its materialized projection. */
|
|
975
|
+
export const ScopeAmendmentSchema = z.object({
|
|
976
|
+
from: z.string(), to: z.string(), beforeFiles: z.array(z.string()), files: z.array(z.string()),
|
|
977
|
+
parkLine: z.number().int().positive(),
|
|
978
|
+
});
|
|
979
|
+
export function replayScopeAmendments(graph, events) {
|
|
980
|
+
const amendments = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
|
|
981
|
+
.map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
|
|
982
|
+
if (!amendments.length)
|
|
983
|
+
return graph;
|
|
984
|
+
let result = graph;
|
|
985
|
+
// Accept any materialized revision, including a fresh compilation of the original definition.
|
|
986
|
+
// Undo only recognized files[] values. Everything else must still hash to the approved definition.
|
|
987
|
+
const taskIds = new Set(amendments.map(({ event }) => event.taskId));
|
|
988
|
+
for (const id of taskIds) {
|
|
989
|
+
const history = amendments.filter(({ event }) => event.taskId === id);
|
|
990
|
+
const task = result.tasks.find((t) => t.id === id);
|
|
991
|
+
const original = history[0].amendment.beforeFiles;
|
|
992
|
+
if (!task || ![original, ...history.map(({ amendment }) => amendment.files)]
|
|
993
|
+
.some((files) => JSON.stringify(files) === JSON.stringify(task.files))) {
|
|
994
|
+
throw new Error(`refusing scope amendment replay: ${id} files[] changed beyond the approved amendment`);
|
|
995
|
+
}
|
|
996
|
+
result = { ...result, tasks: result.tasks.map((t) => t.id === id ? { ...t, files: original } : t) };
|
|
997
|
+
}
|
|
998
|
+
for (const { event, amendment } of amendments) {
|
|
999
|
+
if (graphDefinitionHash(result) !== amendment.from) {
|
|
1000
|
+
throw new Error(`refusing scope amendment replay: graph task definition changed beyond approval for ${event.taskId}`);
|
|
1001
|
+
}
|
|
1002
|
+
result = { ...result, tasks: result.tasks.map((t) => t.id === event.taskId ? { ...t, files: amendment.files } : t) };
|
|
1003
|
+
if (graphDefinitionHash(result) !== amendment.to)
|
|
1004
|
+
throw new Error("refusing invalid scope amendment hash");
|
|
1005
|
+
}
|
|
1006
|
+
return result;
|
|
1007
|
+
}
|
|
1008
|
+
/** Publish/recover the audit before dispatch, even after a crash between approval and rehash. */
|
|
1009
|
+
export function applyScopeAmendments(graph, journal, auditReplay = false) {
|
|
1010
|
+
const events = journal.read();
|
|
1011
|
+
const result = replayScopeAmendments(graph, events); // validate all before moving any identity
|
|
1012
|
+
const approvals = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
|
|
1013
|
+
for (const approval of approvals) {
|
|
1014
|
+
const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
|
|
1015
|
+
if (!events.some((e) => e.event === "graph-rehash" && e.data.approval === approval.ts && e.taskId === approval.taskId
|
|
1016
|
+
&& e.data.from === amendment.from && e.data.to === amendment.to)) {
|
|
1017
|
+
journal.append("graph-rehash", approval.taskId, {
|
|
1018
|
+
from: amendment.from, to: amendment.to, approval: approval.ts, by: approval.data.by, source: "approval",
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (auditReplay && graphDefinitionHash(graph) !== graphDefinitionHash(result) && approvals.length) {
|
|
1023
|
+
const recorded = recordedGraphDefinitionHash(journal.read());
|
|
1024
|
+
journal.append("graph-rehash", approvals.at(-1).taskId, {
|
|
1025
|
+
from: recorded, to: graphDefinitionHash(result), approval: approvals.at(-1).ts, source: "approval", replay: true,
|
|
1026
|
+
loaded: graphDefinitionHash(graph),
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
return result;
|
|
1030
|
+
}
|
|
974
1031
|
// THE shared comparator (criterion: status and resume decide through one comparator). status reads
|
|
975
1032
|
// .comparable; resume reads .comparable plus .reason/.recorded for its refusal message and the release.
|
|
976
1033
|
export function engagementComparable(events, loadedHash) {
|
|
@@ -1243,13 +1300,24 @@ export class Journal {
|
|
|
1243
1300
|
: { ts: new Date().toISOString(), event, ...(taskId ? { taskId } : {}), data: persistedData };
|
|
1244
1301
|
// T3 secret redaction: only the persisted bytes are masked — the caller's data stays untouched in
|
|
1245
1302
|
// memory. The narrator receives the persisted (masked) row so a pane sink never shows a credential.
|
|
1246
|
-
const
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1303
|
+
const rows = [row];
|
|
1304
|
+
if (event === "task-approved" && row.data.release === "scope-request") {
|
|
1305
|
+
const amendment = ScopeAmendmentSchema.parse(row.data.amendment);
|
|
1306
|
+
// Publish permission and its graph audit in one append. Otherwise a live sweep can read
|
|
1307
|
+
// permission between the two writes, race the CLI's rehash, and audit the revision twice.
|
|
1308
|
+
rows.push({ ts: row.ts, event: "graph-rehash", taskId: row.taskId, data: {
|
|
1309
|
+
from: amendment.from, to: amendment.to, approval: row.ts, by: row.data.by, source: "approval",
|
|
1310
|
+
} });
|
|
1250
1311
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1312
|
+
const lines = rows.map((entry) => redactSecrets(JSON.stringify(entry)));
|
|
1313
|
+
appendFileSync(this.journalPath, lines.join("\n") + "\n");
|
|
1314
|
+
for (const line of lines) {
|
|
1315
|
+
try {
|
|
1316
|
+
this.narrate?.(JSON.parse(line));
|
|
1317
|
+
}
|
|
1318
|
+
catch {
|
|
1319
|
+
// narration is observational; a broken sink must not affect the journal or run
|
|
1320
|
+
}
|
|
1253
1321
|
}
|
|
1254
1322
|
if (evidence && !evidence.written && (event === "judge-retry" || event === "gate-result")) {
|
|
1255
1323
|
evidence.written = true;
|
|
@@ -1337,7 +1405,7 @@ export class Journal {
|
|
|
1337
1405
|
outstanding = parsed.success ? { assignment: parsed.data } : {}; // malformed: closable, unattributable
|
|
1338
1406
|
classified = null;
|
|
1339
1407
|
}
|
|
1340
|
-
else if (e.event === "scope-authoring" && outstanding) {
|
|
1408
|
+
else if ((e.event === "scope-authoring" || e.event === "scope-request") && outstanding) {
|
|
1341
1409
|
classified = outstanding; // a duplicate classification closes nothing more (same rule as the replay)
|
|
1342
1410
|
outstanding = null;
|
|
1343
1411
|
}
|
|
@@ -1391,7 +1459,7 @@ export class Journal {
|
|
|
1391
1459
|
st.lastAssignment = undefined;
|
|
1392
1460
|
}
|
|
1393
1461
|
}
|
|
1394
|
-
else if (e.event === "scope-authoring") {
|
|
1462
|
+
else if (e.event === "scope-authoring" || e.event === "scope-request") {
|
|
1395
1463
|
// OBS-547: the dispatch this event closes was an authoring defect — the spec is missing a
|
|
1396
1464
|
// files[] line and the worker was never at fault. Rewind its accounting so a resume neither
|
|
1397
1465
|
// charges the attempt nor treats its channel as burned.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function isRunnerCommand(command: string): boolean;
|
|
2
|
+
export declare const COMMAND_LEASE_TOKEN_ENV = "TICKMARKR_LEASE_TOKEN";
|
|
3
|
+
export type CommandLease = <T>(command: string, run: (token?: string) => Promise<T>) => Promise<T>;
|
|
4
|
+
/** Async descendants share the reservation; a shell's descendants inherit the same token. */
|
|
5
|
+
export declare function currentCommandLeaseToken(): string | undefined;
|
|
6
|
+
/** Never export a command's token through process.env: concurrent sibling commands must queue. */
|
|
7
|
+
export declare function commandLeaseEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
8
|
+
export declare const runWithCommandLease: <T>(lease: CommandLease, run: () => Promise<T>) => Promise<T>;
|
|
9
|
+
export declare const withCommandLease: <T>(command: string, run: () => Promise<T>) => Promise<T>;
|
|
10
|
+
/** Each reservation releases only itself, including an aborted waiter. */
|
|
11
|
+
export declare class CommandLeases {
|
|
12
|
+
private readonly owners;
|
|
13
|
+
run<T>(run: () => Promise<T>, waiting: (count: number) => void, pollMs: number, signal?: AbortSignal): Promise<T>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
// The process census and command admission deliberately use the same command-head classifier.
|
|
4
|
+
export function isRunnerCommand(command) {
|
|
5
|
+
// Inspect executable positions only: inference prompts and shell script bodies may name runners.
|
|
6
|
+
let head = command.trim().replace(/['"]/g, "");
|
|
7
|
+
for (;;) {
|
|
8
|
+
const unwrapped = head
|
|
9
|
+
.replace(/^(?:\S*\/)?(?:ba|z)?sh\s+-[a-z]*c\s+/i, "")
|
|
10
|
+
.replace(/^cd\s+[^;&|]+\s*&&\s*/, "")
|
|
11
|
+
.replace(/^(?:env\s+)?(?:[A-Za-z_]\w*=\S+\s+)+/, "");
|
|
12
|
+
if (unwrapped === head)
|
|
13
|
+
break;
|
|
14
|
+
head = unwrapped;
|
|
15
|
+
}
|
|
16
|
+
const binary = "(?:\\S*/)?";
|
|
17
|
+
const flags = "(?:\\s+(?:--?[\\w-]+(?:=[^\\s]+)?|--))*";
|
|
18
|
+
const manager = `${binary}(?:npm|pnpm|yarn|bun)${flags}`;
|
|
19
|
+
const jsRunner = `${binary}(?:vitest(?:\\.mjs)?|jest|mocha)(?:\\s|$)`;
|
|
20
|
+
// Test scripts may be scoped (test:unit); runner binaries may be launched through a manager.
|
|
21
|
+
// Keep each match anchored to that executable position so prompt/argument mentions stay out.
|
|
22
|
+
return new RegExp(`^${manager}\\s+(?:run${flags}\\s+)?test[\\w:.-]*(?:\\s|$)`, "i").test(head)
|
|
23
|
+
|| new RegExp(`^${manager}\\s+(?:(?:exec|x|dlx)${flags}\\s+)?${jsRunner}`, "i").test(head)
|
|
24
|
+
|| new RegExp(`^${binary}npx${flags}\\s+${jsRunner}`, "i").test(head)
|
|
25
|
+
|| /^(?:\S*\/)?(?:go|make|cargo)\s+test(?:\s|$)/i.test(head)
|
|
26
|
+
|| /^(?:(?:\S*\/)?(?:node|npx)\s+)?(?:\S*\/)?(?:vitest(?:\.mjs)?|jest|mocha|pytest(?:\d+(?:\.\d+)*)?)(?:\s|$)/i.test(head)
|
|
27
|
+
|| /^(?:\S*\/)?python[\d.]*\s+-m\s+pytest(?:\s|$)/i.test(head);
|
|
28
|
+
}
|
|
29
|
+
export const COMMAND_LEASE_TOKEN_ENV = "TICKMARKR_LEASE_TOKEN";
|
|
30
|
+
const context = new AsyncLocalStorage();
|
|
31
|
+
const ownership = new AsyncLocalStorage();
|
|
32
|
+
/** Async descendants share the reservation; a shell's descendants inherit the same token. */
|
|
33
|
+
export function currentCommandLeaseToken() {
|
|
34
|
+
const owner = ownership.getStore();
|
|
35
|
+
return owner ? (owner.active ? owner.token : undefined) : process.env[COMMAND_LEASE_TOKEN_ENV] || undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Never export a command's token through process.env: concurrent sibling commands must queue. */
|
|
38
|
+
export function commandLeaseEnvironment(env) {
|
|
39
|
+
const child = { ...env };
|
|
40
|
+
const token = currentCommandLeaseToken();
|
|
41
|
+
if (token)
|
|
42
|
+
child[COMMAND_LEASE_TOKEN_ENV] = token;
|
|
43
|
+
else
|
|
44
|
+
delete child[COMMAND_LEASE_TOKEN_ENV];
|
|
45
|
+
return child;
|
|
46
|
+
}
|
|
47
|
+
export const runWithCommandLease = (lease, run) => context.run(lease, run);
|
|
48
|
+
export const withCommandLease = (command, run) => {
|
|
49
|
+
const lease = context.getStore();
|
|
50
|
+
if (!lease || !isRunnerCommand(command) || currentCommandLeaseToken())
|
|
51
|
+
return run();
|
|
52
|
+
return lease(command, token => {
|
|
53
|
+
const owner = { token: token ?? randomUUID(), active: true };
|
|
54
|
+
return ownership.run(owner, async () => {
|
|
55
|
+
try {
|
|
56
|
+
return await run();
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
owner.active = false;
|
|
60
|
+
} // A continuation after release must acquire a new lease.
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
/** Each reservation releases only itself, including an aborted waiter. */
|
|
65
|
+
export class CommandLeases {
|
|
66
|
+
owners = new Set();
|
|
67
|
+
async run(run, waiting, pollMs, signal) {
|
|
68
|
+
const owner = Symbol("command");
|
|
69
|
+
this.owners.add(owner);
|
|
70
|
+
try {
|
|
71
|
+
let reported = false;
|
|
72
|
+
while (this.owners.values().next().value !== owner) {
|
|
73
|
+
signal?.throwIfAborted();
|
|
74
|
+
if (!reported) {
|
|
75
|
+
waiting(this.owners.size - 1);
|
|
76
|
+
reported = true;
|
|
77
|
+
}
|
|
78
|
+
await new Promise((wake) => setTimeout(wake, pollMs));
|
|
79
|
+
}
|
|
80
|
+
signal?.throwIfAborted();
|
|
81
|
+
return await run();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
this.owners.delete(owner);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
package/dist/run/merge.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface TipVerifyResult {
|
|
|
8
8
|
fingerprints: string[];
|
|
9
9
|
details: string;
|
|
10
10
|
artifact?: string;
|
|
11
|
+
reportPath?: string;
|
|
12
|
+
spawnedCommand?: string;
|
|
11
13
|
/** Q121s: nonzero exit whose failures are ALL baseline-recorded — forgiven exactly as the battery forgives. */
|
|
12
14
|
forgiven?: boolean;
|
|
13
15
|
/**
|