tickmarkr 2.5.5 → 2.5.6
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 +3 -1
- package/dist/cli/commands/approve.js +64 -3
- package/dist/cli/commands/doctor.d.ts +10 -0
- package/dist/cli/commands/fleet.js +4 -0
- package/dist/cli/commands/plan.js +20 -4
- package/dist/cli/commands/verify.js +102 -81
- package/dist/config/config.d.ts +11 -0
- package/dist/config/config.js +21 -12
- package/dist/config/fleet-overlay.js +55 -17
- package/dist/drivers/index.js +2 -1
- package/dist/drivers/orca.d.ts +21 -1
- package/dist/drivers/orca.js +209 -27
- package/dist/gates/baseline.d.ts +14 -3
- package/dist/gates/baseline.js +61 -14
- package/dist/gates/cache.d.ts +14 -13
- package/dist/gates/cache.js +17 -5
- package/dist/gates/llm.d.ts +3 -0
- package/dist/gates/llm.js +11 -0
- package/dist/gates/review.d.ts +28 -3
- package/dist/gates/review.js +106 -14
- package/dist/gates/run-gates.d.ts +6 -1
- package/dist/gates/run-gates.js +299 -101
- package/dist/gates/test-manifest.d.ts +30 -1
- package/dist/gates/test-manifest.js +113 -39
- package/dist/gates/test-reporter.js +14 -6
- package/dist/graph/graph.d.ts +2 -0
- package/dist/graph/graph.js +45 -1
- package/dist/run/consult.js +5 -4
- package/dist/run/daemon.d.ts +2 -0
- package/dist/run/daemon.js +480 -99
- package/dist/run/execution-budget.d.ts +25 -0
- package/dist/run/execution-budget.js +142 -0
- package/dist/run/git.d.ts +40 -0
- package/dist/run/git.js +89 -4
- package/dist/run/journal.d.ts +3 -1
- package/dist/run/journal.js +38 -16
- package/dist/run/lease.d.ts +44 -0
- package/dist/run/lease.js +226 -3
- package/dist/run/recovery.d.ts +8 -0
- package/dist/run/recovery.js +25 -0
- package/dist/run/repair-selection.d.ts +12 -0
- package/dist/run/repair-selection.js +56 -0
- package/dist/run/stall.d.ts +6 -1
- package/dist/run/stall.js +60 -3
- package/dist/tui/ink/fleet-app.d.ts +10 -2
- package/dist/tui/ink/fleet-app.js +33 -15
- package/package.json +2 -2
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare class ExecutionBudgetExceeded extends Error {
|
|
2
|
+
readonly taskId: string;
|
|
3
|
+
constructor(taskId: string, reason?: string);
|
|
4
|
+
}
|
|
5
|
+
export interface ExecutionBudgetEvent {
|
|
6
|
+
event: string;
|
|
7
|
+
taskId?: string;
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface ExecutionBudgetOptions {
|
|
11
|
+
limitMs: number;
|
|
12
|
+
taskId: string;
|
|
13
|
+
readEvents: () => readonly ExecutionBudgetEvent[];
|
|
14
|
+
append: (event: string, taskId: string, data: Record<string, unknown>) => void;
|
|
15
|
+
}
|
|
16
|
+
export declare const executionSignal: () => AbortSignal | undefined;
|
|
17
|
+
/** Cleanup only: the caller must bound cleanup and must not authorize task work here. */
|
|
18
|
+
export declare const withoutExecutionBudget: <T>(run: () => T) => T;
|
|
19
|
+
export declare const remainingExecutionMs: () => number | undefined;
|
|
20
|
+
/** One wall-clock interval around task execution, shared by parallel children. Cancellation is
|
|
21
|
+
* cooperative: callers must stop on executionSignal(), and this wrapper waits for their cleanup.
|
|
22
|
+
* Durable slices are bought before work: at least one minute or 1% of the task ceiling, capped
|
|
23
|
+
* by what remains. This avoids a per-second journal stream. An interrupted slice stays fully
|
|
24
|
+
* charged, conservatively consuming its unused portion; daemon-offline time is never charged. */
|
|
25
|
+
export declare function withExecutionBudget<T>(opts: ExecutionBudgetOptions, run: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
export class ExecutionBudgetExceeded extends Error {
|
|
4
|
+
taskId;
|
|
5
|
+
constructor(taskId, reason = "automated execution ceiling exhausted") {
|
|
6
|
+
super(`${taskId}: ${reason}`);
|
|
7
|
+
this.taskId = taskId;
|
|
8
|
+
this.name = "ExecutionBudgetExceeded";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const context = new AsyncLocalStorage();
|
|
12
|
+
const MIN_SLICE_MS = 60_000;
|
|
13
|
+
const finite = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
14
|
+
function consumed(opts) {
|
|
15
|
+
const reservations = new Map();
|
|
16
|
+
for (const e of opts.readEvents()) {
|
|
17
|
+
if (e.taskId !== opts.taskId || !["execution-budget-reserved", "execution-budget-settled"].includes(e.event))
|
|
18
|
+
continue;
|
|
19
|
+
const d = e.data;
|
|
20
|
+
if (!d || d.limitMs !== opts.limitMs || typeof d.id !== "string" || !d.id) {
|
|
21
|
+
throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution budget accounting");
|
|
22
|
+
}
|
|
23
|
+
if (e.event === "execution-budget-reserved") {
|
|
24
|
+
if (!finite(d.reservedMs) || d.reservedMs === 0 || reservations.has(d.id)) {
|
|
25
|
+
throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution reservation");
|
|
26
|
+
}
|
|
27
|
+
reservations.set(d.id, { reserved: d.reservedMs });
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const prior = reservations.get(d.id);
|
|
31
|
+
if (!prior || prior.used !== undefined || !finite(d.usedMs) || d.usedMs > prior.reserved) {
|
|
32
|
+
throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution settlement");
|
|
33
|
+
}
|
|
34
|
+
prior.used = d.usedMs;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const total = [...reservations.values()].reduce((sum, r) => sum + (r.used ?? r.reserved), 0);
|
|
38
|
+
if (!finite(total) || total > opts.limitMs)
|
|
39
|
+
throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution total");
|
|
40
|
+
return total;
|
|
41
|
+
}
|
|
42
|
+
export const executionSignal = () => {
|
|
43
|
+
const budget = context.getStore();
|
|
44
|
+
budget?.check();
|
|
45
|
+
return budget?.signal;
|
|
46
|
+
};
|
|
47
|
+
/** Cleanup only: the caller must bound cleanup and must not authorize task work here. */
|
|
48
|
+
export const withoutExecutionBudget = (run) => context.exit(run);
|
|
49
|
+
export const remainingExecutionMs = () => context.getStore()?.remaining();
|
|
50
|
+
/** One wall-clock interval around task execution, shared by parallel children. Cancellation is
|
|
51
|
+
* cooperative: callers must stop on executionSignal(), and this wrapper waits for their cleanup.
|
|
52
|
+
* Durable slices are bought before work: at least one minute or 1% of the task ceiling, capped
|
|
53
|
+
* by what remains. This avoids a per-second journal stream. An interrupted slice stays fully
|
|
54
|
+
* charged, conservatively consuming its unused portion; daemon-offline time is never charged. */
|
|
55
|
+
export async function withExecutionBudget(opts, run) {
|
|
56
|
+
if (!finite(opts.limitMs) || opts.limitMs === 0 || !opts.taskId)
|
|
57
|
+
throw new ExecutionBudgetExceeded(opts.taskId, "invalid execution ceiling");
|
|
58
|
+
const parent = context.getStore();
|
|
59
|
+
if (parent) {
|
|
60
|
+
if (parent.taskId !== opts.taskId || parent.limitMs !== opts.limitMs)
|
|
61
|
+
throw new ExecutionBudgetExceeded(opts.taskId, "nested execution budget mismatch");
|
|
62
|
+
parent.check();
|
|
63
|
+
parent.signal.throwIfAborted();
|
|
64
|
+
return run();
|
|
65
|
+
}
|
|
66
|
+
const prior = consumed(opts);
|
|
67
|
+
const available = opts.limitMs - prior;
|
|
68
|
+
const sliceMs = Math.max(MIN_SLICE_MS, opts.limitMs / 100);
|
|
69
|
+
if (available <= 0)
|
|
70
|
+
throw new ExecutionBudgetExceeded(opts.taskId);
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const start = performance.now();
|
|
73
|
+
let reserved = 0;
|
|
74
|
+
let current;
|
|
75
|
+
let timer;
|
|
76
|
+
const elapsed = () => Math.max(0, performance.now() - start);
|
|
77
|
+
const remaining = () => Math.max(0, available - elapsed());
|
|
78
|
+
const stop = (error) => controller.abort(error instanceof Error ? error : new ExecutionBudgetExceeded(opts.taskId));
|
|
79
|
+
const buy = (amount) => {
|
|
80
|
+
if (amount <= 0)
|
|
81
|
+
return;
|
|
82
|
+
const next = { id: randomUUID(), before: reserved, amount };
|
|
83
|
+
opts.append("execution-budget-reserved", opts.taskId, { id: next.id, limitMs: opts.limitMs, reservedMs: amount });
|
|
84
|
+
current = next;
|
|
85
|
+
reserved += amount;
|
|
86
|
+
};
|
|
87
|
+
// JS timers cannot preempt a blocked event loop. Charge elapsed overruns before another
|
|
88
|
+
// observable operation or completion; expiration consumes the whole allowance across resume.
|
|
89
|
+
const check = () => {
|
|
90
|
+
try {
|
|
91
|
+
const used = Math.min(available, elapsed());
|
|
92
|
+
if (used > reserved)
|
|
93
|
+
buy(used - reserved);
|
|
94
|
+
if (used >= available)
|
|
95
|
+
stop(new ExecutionBudgetExceeded(opts.taskId));
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
stop(error);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const reserve = () => {
|
|
102
|
+
if (elapsed() >= available)
|
|
103
|
+
check();
|
|
104
|
+
if (controller.signal.aborted)
|
|
105
|
+
return;
|
|
106
|
+
buy(Math.min(available - reserved, Math.max(sliceMs, elapsed() - reserved + sliceMs)));
|
|
107
|
+
timer = setTimeout(() => {
|
|
108
|
+
try {
|
|
109
|
+
reserve();
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
stop(error);
|
|
113
|
+
}
|
|
114
|
+
}, Math.max(0, reserved - elapsed()));
|
|
115
|
+
};
|
|
116
|
+
reserve();
|
|
117
|
+
controller.signal.throwIfAborted();
|
|
118
|
+
const budget = { taskId: opts.taskId, limitMs: opts.limitMs, signal: controller.signal, remaining, check };
|
|
119
|
+
try {
|
|
120
|
+
return await context.run(budget, async () => {
|
|
121
|
+
controller.signal.throwIfAborted();
|
|
122
|
+
const result = await run();
|
|
123
|
+
check();
|
|
124
|
+
controller.signal.throwIfAborted();
|
|
125
|
+
return result;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
check();
|
|
130
|
+
controller.signal.throwIfAborted();
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
check();
|
|
136
|
+
if (current) {
|
|
137
|
+
const usedMs = Math.min(current.amount, Math.max(0, elapsed() - current.before));
|
|
138
|
+
opts.append("execution-budget-settled", opts.taskId, { id: current.id, limitMs: opts.limitMs, usedMs });
|
|
139
|
+
}
|
|
140
|
+
controller.signal.throwIfAborted();
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/run/git.d.ts
CHANGED
|
@@ -76,6 +76,46 @@ export declare function readCapacity(value: unknown): CapacityRead;
|
|
|
76
76
|
* different capacity, or a current capacity the caller could not state → no.
|
|
77
77
|
*/
|
|
78
78
|
export declare function sameCapacity(recorded: unknown, current: RunCapacity | undefined): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* R41: the verification PROTOCOL a verdict was produced under — which implementation of the manifest
|
|
81
|
+
* discovery/report contract judged it, and the npm lifecycle policy its runner children ran with.
|
|
82
|
+
* Two verdicts are comparable only when both match: a manifest built from module collection ("vl1.1",
|
|
83
|
+
* the pre-stamp implementation) and one built from file specifications ("vl1.2") certify different
|
|
84
|
+
* sets, and a suite whose `pretest` hook ran (ignore-scripts=false) is a different measurement from
|
|
85
|
+
* one where npm skipped it. Bump VERIFICATION_PROTOCOL whenever discovery or report semantics change.
|
|
86
|
+
*/
|
|
87
|
+
export declare const VERIFICATION_PROTOCOL = "vl1.2";
|
|
88
|
+
export type RunnerLifecycle = "hooks" | "ignore-scripts" | "unknown";
|
|
89
|
+
export interface VerificationProtocol {
|
|
90
|
+
protocol: string;
|
|
91
|
+
/** The EFFECTIVE npm lifecycle policy a runner child receives: `hooks` (pretest/prebuild run),
|
|
92
|
+
* `ignore-scripts` (npm skips them), or `unknown` (could not be measured — never comparable). */
|
|
93
|
+
lifecycle: RunnerLifecycle;
|
|
94
|
+
/** Provenance only, never part of compatibility: an explicit process-scoped env export, or npm's
|
|
95
|
+
* own resolved config for that checkout (user/project npmrc). */
|
|
96
|
+
source: "explicit" | "npm-config" | "unknown";
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* R41: the lifecycle policy a runner child spawned from `env` in `cwd` receives. An explicit
|
|
100
|
+
* process-scoped `npm_config_ignore_scripts` export decides (npm reads env over every npmrc) and is
|
|
101
|
+
* recorded as such; otherwise the policy is MEASURED from npm's own resolved config for that checkout
|
|
102
|
+
* (`npm config get ignore-scripts` — user npmrc, project npmrc, env), so the identity binds what
|
|
103
|
+
* npm will actually do rather than a guess. A policy that cannot be measured is `unknown`, and an
|
|
104
|
+
* unknown policy is never compatible with anything, itself included. No env or npmrc is written here.
|
|
105
|
+
*/
|
|
106
|
+
export declare function runnerLifecycle(env?: NodeJS.ProcessEnv, cwd?: string): Pick<VerificationProtocol, "lifecycle" | "source">;
|
|
107
|
+
export declare function verificationProtocol(env?: NodeJS.ProcessEnv, cwd?: string): VerificationProtocol;
|
|
108
|
+
/**
|
|
109
|
+
* May a verdict recorded under `recorded` be reused — cached, replayed — by a session running
|
|
110
|
+
* `current`? Unlike capacity, ABSENT is NOT compatible: a row with no protocol stamp was produced by
|
|
111
|
+
* the pre-stamp implementation (module-collection discovery, lifecycle unstated), which is exactly
|
|
112
|
+
* the evidence a changed protocol must not inherit — positive or negative. Malformed → no. An
|
|
113
|
+
* `unknown` lifecycle on either side → no: two unmeasured policies are not the same policy.
|
|
114
|
+
* `source` is provenance and never enters the comparison — an explicit `false` and an npmrc `false`
|
|
115
|
+
* are the same effective policy.
|
|
116
|
+
*/
|
|
117
|
+
export declare function sameVerification(recorded: unknown, current: VerificationProtocol): boolean;
|
|
118
|
+
export declare const describeVerification: (value: unknown) => string;
|
|
79
119
|
export declare const describeCapacity: (value: unknown) => string;
|
|
80
120
|
/**
|
|
81
121
|
* The capacity a child spawned on THIS async context would receive: the same precedence `shell`
|
package/dist/run/git.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { executionSignal } from "./execution-budget.js";
|
|
1
2
|
import { commandLeaseEnvironment, withCommandLease } from "./lease.js";
|
|
2
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
|
-
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { availableParallelism, tmpdir } from "node:os";
|
|
4
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
5
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { availableParallelism, homedir, tmpdir } from "node:os";
|
|
6
7
|
import { join, resolve } from "node:path";
|
|
7
8
|
import { StringDecoder } from "node:string_decoder";
|
|
8
9
|
import { shq } from "../adapters/types.js";
|
|
@@ -82,6 +83,86 @@ export function sameCapacity(recorded, current) {
|
|
|
82
83
|
return false;
|
|
83
84
|
return read.capacity.forkCap === current.forkCap && read.capacity.cores === current.cores;
|
|
84
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* R41: the verification PROTOCOL a verdict was produced under — which implementation of the manifest
|
|
88
|
+
* discovery/report contract judged it, and the npm lifecycle policy its runner children ran with.
|
|
89
|
+
* Two verdicts are comparable only when both match: a manifest built from module collection ("vl1.1",
|
|
90
|
+
* the pre-stamp implementation) and one built from file specifications ("vl1.2") certify different
|
|
91
|
+
* sets, and a suite whose `pretest` hook ran (ignore-scripts=false) is a different measurement from
|
|
92
|
+
* one where npm skipped it. Bump VERIFICATION_PROTOCOL whenever discovery or report semantics change.
|
|
93
|
+
*/
|
|
94
|
+
export const VERIFICATION_PROTOCOL = "vl1.2";
|
|
95
|
+
const lifecycleOf = (raw) => {
|
|
96
|
+
const v = raw?.trim().toLowerCase();
|
|
97
|
+
return v === "false" ? "hooks" : v === "true" ? "ignore-scripts" : undefined;
|
|
98
|
+
};
|
|
99
|
+
// Measured effective policy, keyed on everything npm resolves it from that this process can see:
|
|
100
|
+
// the checkout, its project npmrc and the user npmrc (path + mtime), and the explicit env. A
|
|
101
|
+
// rewritten npmrc changes the key, so a later battery in the same process re-measures.
|
|
102
|
+
const measuredLifecycle = new Map();
|
|
103
|
+
const mtimeOf = (p) => { try {
|
|
104
|
+
return String(statSync(p).mtimeMs);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return "-";
|
|
108
|
+
} };
|
|
109
|
+
/**
|
|
110
|
+
* R41: the lifecycle policy a runner child spawned from `env` in `cwd` receives. An explicit
|
|
111
|
+
* process-scoped `npm_config_ignore_scripts` export decides (npm reads env over every npmrc) and is
|
|
112
|
+
* recorded as such; otherwise the policy is MEASURED from npm's own resolved config for that checkout
|
|
113
|
+
* (`npm config get ignore-scripts` — user npmrc, project npmrc, env), so the identity binds what
|
|
114
|
+
* npm will actually do rather than a guess. A policy that cannot be measured is `unknown`, and an
|
|
115
|
+
* unknown policy is never compatible with anything, itself included. No env or npmrc is written here.
|
|
116
|
+
*/
|
|
117
|
+
export function runnerLifecycle(env = process.env, cwd = process.cwd()) {
|
|
118
|
+
const explicit = lifecycleOf(env.npm_config_ignore_scripts ?? env.NPM_CONFIG_IGNORE_SCRIPTS);
|
|
119
|
+
if (explicit)
|
|
120
|
+
return { lifecycle: explicit, source: "explicit" };
|
|
121
|
+
const userconfig = env.NPM_CONFIG_USERCONFIG ?? env.npm_config_userconfig ?? join(homedir(), ".npmrc");
|
|
122
|
+
const key = [cwd, mtimeOf(join(cwd, ".npmrc")), userconfig, mtimeOf(userconfig),
|
|
123
|
+
env.npm_config_ignore_scripts ?? env.NPM_CONFIG_IGNORE_SCRIPTS ?? ""].join("\0");
|
|
124
|
+
let lifecycle = measuredLifecycle.get(key);
|
|
125
|
+
if (lifecycle === undefined) {
|
|
126
|
+
try {
|
|
127
|
+
const out = execFileSync("npm", ["config", "get", "ignore-scripts"], {
|
|
128
|
+
cwd: existsSync(cwd) ? cwd : process.cwd(), env, encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "ignore"],
|
|
129
|
+
});
|
|
130
|
+
lifecycle = lifecycleOf(out) ?? "unknown";
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
lifecycle = "unknown";
|
|
134
|
+
}
|
|
135
|
+
measuredLifecycle.set(key, lifecycle);
|
|
136
|
+
}
|
|
137
|
+
return { lifecycle, source: lifecycle === "unknown" ? "unknown" : "npm-config" };
|
|
138
|
+
}
|
|
139
|
+
export function verificationProtocol(env = process.env, cwd = process.cwd()) {
|
|
140
|
+
return { protocol: VERIFICATION_PROTOCOL, ...runnerLifecycle(env, cwd) };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* May a verdict recorded under `recorded` be reused — cached, replayed — by a session running
|
|
144
|
+
* `current`? Unlike capacity, ABSENT is NOT compatible: a row with no protocol stamp was produced by
|
|
145
|
+
* the pre-stamp implementation (module-collection discovery, lifecycle unstated), which is exactly
|
|
146
|
+
* the evidence a changed protocol must not inherit — positive or negative. Malformed → no. An
|
|
147
|
+
* `unknown` lifecycle on either side → no: two unmeasured policies are not the same policy.
|
|
148
|
+
* `source` is provenance and never enters the comparison — an explicit `false` and an npmrc `false`
|
|
149
|
+
* are the same effective policy.
|
|
150
|
+
*/
|
|
151
|
+
export function sameVerification(recorded, current) {
|
|
152
|
+
if (recorded === null || typeof recorded !== "object")
|
|
153
|
+
return false;
|
|
154
|
+
const { protocol, lifecycle } = recorded;
|
|
155
|
+
if (lifecycle === "unknown" || current.lifecycle === "unknown")
|
|
156
|
+
return false;
|
|
157
|
+
return protocol === current.protocol && lifecycle === current.lifecycle;
|
|
158
|
+
}
|
|
159
|
+
export const describeVerification = (value) => {
|
|
160
|
+
if (value === null || typeof value !== "object")
|
|
161
|
+
return "an unrecorded verification protocol";
|
|
162
|
+
const { protocol, lifecycle, source } = value;
|
|
163
|
+
return typeof protocol === "string" && typeof lifecycle === "string"
|
|
164
|
+
? `protocol ${protocol}, lifecycle ${lifecycle}${typeof source === "string" ? ` (${source})` : ""}` : "a malformed verification protocol";
|
|
165
|
+
};
|
|
85
166
|
export const describeCapacity = (value) => {
|
|
86
167
|
const read = readCapacity(value);
|
|
87
168
|
return read.state === "present"
|
|
@@ -131,7 +212,10 @@ export const SPAWN_RETRY_BACKOFF_MS = 50;
|
|
|
131
212
|
const SHELL_REAP_GRACE_MS = 2000;
|
|
132
213
|
/** Shared command seam, including invocation-bound manifested runners. */
|
|
133
214
|
export function shell(cmd, cwd, timeoutMs, login = false, options = {}) {
|
|
134
|
-
|
|
215
|
+
const inherited = executionSignal();
|
|
216
|
+
const signal = inherited && options.signal ? AbortSignal.any([inherited, options.signal]) : inherited ?? options.signal;
|
|
217
|
+
signal?.throwIfAborted();
|
|
218
|
+
return withCommandLease(cmd, () => executeShell(cmd, cwd, timeoutMs, login, { ...options, signal }));
|
|
135
219
|
}
|
|
136
220
|
function executeShell(cmd, cwd, timeoutMs, login, options) {
|
|
137
221
|
options.signal?.throwIfAborted();
|
|
@@ -289,6 +373,7 @@ function executeShell(cmd, cwd, timeoutMs, login, options) {
|
|
|
289
373
|
return (async () => {
|
|
290
374
|
const startedAt = Date.now();
|
|
291
375
|
for (let n = 1;; n++) {
|
|
376
|
+
options.signal?.throwIfAborted();
|
|
292
377
|
const r = await attempt();
|
|
293
378
|
if (!("refused" in r))
|
|
294
379
|
return r;
|
package/dist/run/journal.d.ts
CHANGED
|
@@ -60,6 +60,7 @@ export interface ConsultGuidanceCarry {
|
|
|
60
60
|
*/
|
|
61
61
|
export declare function outstandingConsultGuidance(events: JournalEvent[], taskId: string): ConsultGuidanceCarry | undefined;
|
|
62
62
|
export declare const UNIDENTIFIED = "<unidentified>";
|
|
63
|
+
export declare function boundedSymbol(note: string): string;
|
|
63
64
|
/**
|
|
64
65
|
* Structured findings for a BLOCKING review/judge gate result, parsed from the details the gate
|
|
65
66
|
* already writes (D-03: no gate-module change, so an older gate's prose degrades to one unclassified
|
|
@@ -192,7 +193,7 @@ export declare function pendingRepairFindings(events: JournalEvent[], taskId: st
|
|
|
192
193
|
* a channel it is no longer using, and a later unrelated failure is not parked under a stale reason.
|
|
193
194
|
*/
|
|
194
195
|
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", "scope-request"];
|
|
196
|
+
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", "diff-cap"];
|
|
196
197
|
export type ParkKind = (typeof PARK_KINDS)[number];
|
|
197
198
|
export declare const RETRY_MODES: readonly ["resume", "fresh", "repair"];
|
|
198
199
|
export type RetryMode = (typeof RETRY_MODES)[number];
|
|
@@ -244,6 +245,7 @@ export declare const TelemetryRowSchema: z.ZodObject<{
|
|
|
244
245
|
"tip-moved": "tip-moved";
|
|
245
246
|
authoring: "authoring";
|
|
246
247
|
"scope-request": "scope-request";
|
|
248
|
+
"diff-cap": "diff-cap";
|
|
247
249
|
}>>;
|
|
248
250
|
tokens: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
249
251
|
input: z.ZodNumber;
|
package/dist/run/journal.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { z } from "zod";
|
|
@@ -60,8 +61,8 @@ export function formatJournalNarration({ event, taskId, data }) {
|
|
|
60
61
|
// replayResumeState(), consumed by the daemon on resume to re-seed demotedChannels.
|
|
61
62
|
export const CHANNEL_EXCLUSION_KINDS = ["dead-channel"];
|
|
62
63
|
// v1.24 OBS-18: explicit data on task-approved when the operator releases an attempt-cap park.
|
|
63
|
-
// Approve stamps this; replayResumeState
|
|
64
|
-
//
|
|
64
|
+
// Approve stamps this; replayResumeState resets both the attempt counter and tried seats for a fresh
|
|
65
|
+
// budget. Absent on pre-v1.24 events ⇒ inert (corpus outcome-identical).
|
|
65
66
|
export const ATTEMPT_CAP_RELEASE = "attempt-cap";
|
|
66
67
|
// OBS-130: task-approved carries the exact failed gate the operator satisfied. The release tag keeps
|
|
67
68
|
// ordinary humanGate and attempt-cap approvals byte-compatible while making this authority explicit.
|
|
@@ -74,8 +75,8 @@ export const REVIEW_UPHELD_RELEASE = "review-upheld";
|
|
|
74
75
|
// gate before it — daemon.ts remainingGates), which is wrong when the gate failed against a stale task
|
|
75
76
|
// DECLARATION rather than a bad diff: amend the spec's files[], recompile, and the gate now passes
|
|
76
77
|
// honestly. This release runs the whole gate suite over the parked commit before any worker and
|
|
77
|
-
// marks no gate satisfied: green merges directly, red returns to the ladder
|
|
78
|
-
//
|
|
78
|
+
// marks no gate satisfied: green merges directly, red returns to the existing ladder with attempts
|
|
79
|
+
// and tried seats intact.
|
|
79
80
|
export const RECHECK_RELEASE = "recheck";
|
|
80
81
|
// OBS-738: one authority for every recovery surface. The ref is accepted only from the row that
|
|
81
82
|
// preservation itself writes; task-human prose, branch heads and commit history are deliberately
|
|
@@ -217,9 +218,20 @@ function identifierIn(note) {
|
|
|
217
218
|
// code identity still has one stable identity of its own: its own words, with the volatile tokens
|
|
218
219
|
// swept out so line/path churn cannot mint a new symbol for the same finding. It is the reviewer's
|
|
219
220
|
// own bytes, never a guess, and it can never fuse two different findings into one.
|
|
221
|
+
// OBS-1013 add.3: the words are BOUNDED — the first sentence, or a short hash when even that is long.
|
|
222
|
+
// A whole-note symbol made a 600-byte fingerprint no reviewer could echo, so a real closure read as
|
|
223
|
+
// malformed and bought a worker. A sentence a reviewer can copy is the identity; the hash is the fallback.
|
|
224
|
+
const SYMBOL_SENTENCE_CAP = 160;
|
|
225
|
+
export function boundedSymbol(note) {
|
|
226
|
+
const words = normalizeGateFailure(note);
|
|
227
|
+
if (!words)
|
|
228
|
+
return "";
|
|
229
|
+
const sentence = words.split(/(?<=[.!?])\s+|\n/)[0].trim();
|
|
230
|
+
return sentence.length <= SYMBOL_SENTENCE_CAP ? sentence : createHash("sha256").update(words).digest("hex").slice(0, 12);
|
|
231
|
+
}
|
|
220
232
|
function toFinding(cls, note, path, symbol, rationale) {
|
|
221
233
|
const p = path || UNIDENTIFIED;
|
|
222
|
-
const s = symbol ||
|
|
234
|
+
const s = symbol || boundedSymbol(note) || UNIDENTIFIED;
|
|
223
235
|
return {
|
|
224
236
|
class: cls, path: p, symbol: s, note,
|
|
225
237
|
...(rationale !== undefined ? { rationale } : {}),
|
|
@@ -773,7 +785,7 @@ const DispatchAssignmentSchema = z.object({
|
|
|
773
785
|
// routing profile's QUALITY_FAIL_PARKS (route/profile.ts): the channel did nothing wrong.
|
|
774
786
|
export const PARK_KINDS = ["human-gate", "ladder-exhausted", "attempt-cap", "gate-fail", "quota",
|
|
775
787
|
"reroute-exhausted", "setup", "stall", "merge-conflict", "tip-moved", "infra", "dispatch",
|
|
776
|
-
"authoring", "scope-request"];
|
|
788
|
+
"authoring", "scope-request", "diff-cap"];
|
|
777
789
|
// v1.85 T3: "repair" is a third dispatch mode beside the v1.29 session pair — a fix-only attempt that
|
|
778
790
|
// carries the failing findings and the diff CONTENT of the work already landed, instead of re-buying
|
|
779
791
|
// ~20m of onboarding to rediscover them (62 of 68 measured re-dispatches were fresh).
|
|
@@ -1483,24 +1495,32 @@ export class Journal {
|
|
|
1483
1495
|
// A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
|
|
1484
1496
|
pendingReroute.add(e.taskId);
|
|
1485
1497
|
}
|
|
1486
|
-
else if (e.event === "task-approved"
|
|
1487
|
-
&& (e.data.release === ATTEMPT_CAP_RELEASE || e.data.release === RECHECK_RELEASE)) {
|
|
1498
|
+
else if (e.event === "task-approved" && e.data.release === ATTEMPT_CAP_RELEASE) {
|
|
1488
1499
|
// v1.24 OBS-18: operator released an attempt-cap park. Pre-v1.24 task-approved events have no
|
|
1489
1500
|
// `release` key ⇒ this branch never fires (corpus criterion: identical statuses + resume state).
|
|
1490
|
-
// attempts
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1501
|
+
// attempts and tried reset so the daemon's attempt-cap check does not re-park in the same tick
|
|
1502
|
+
// and the newly funded engagement starts with a cleared seat list; lastAssignment clears too.
|
|
1503
|
+
// OBS-1028: a RECHECK release is deliberately NOT here — it marks no gate satisfied and funds no
|
|
1504
|
+
// attempt, so the ladder (attempts AND tried) survives it untouched. Only THIS fresh-budget
|
|
1505
|
+
// release resets the ladder, and it resets both halves: a fresh budget on a cleared seat list.
|
|
1494
1506
|
const st = m.get(e.taskId);
|
|
1495
1507
|
if (st) {
|
|
1496
1508
|
st.attempts = 0;
|
|
1509
|
+
st.tried = [];
|
|
1497
1510
|
st.lastAssignment = undefined;
|
|
1498
1511
|
}
|
|
1499
1512
|
}
|
|
1513
|
+
else if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE) {
|
|
1514
|
+
// OBS-1028: the ladder survives a recheck (attempts AND tried); only the seat memory is cleared
|
|
1515
|
+
// so a red battery's next dispatch is picked over the exclusions instead of restoring the parked seat.
|
|
1516
|
+
const st = m.get(e.taskId);
|
|
1517
|
+
if (st)
|
|
1518
|
+
st.lastAssignment = undefined;
|
|
1519
|
+
}
|
|
1500
1520
|
else if (e.event === "task-approved" && e.data.release === REVIEW_UPHELD_RELEASE) {
|
|
1501
|
-
// OBS-189: the operator upheld the reviewer.
|
|
1502
|
-
// (
|
|
1503
|
-
//
|
|
1521
|
+
// OBS-189: the operator upheld the reviewer. It funds fresh attempts without forgetting tried
|
|
1522
|
+
// seats (unlike an attempt-cap fresh-budget release), and carries the newest failed review's
|
|
1523
|
+
// details into the funded attempt as its retry brief.
|
|
1504
1524
|
const st = m.get(e.taskId);
|
|
1505
1525
|
if (st) {
|
|
1506
1526
|
st.attempts = 0;
|
|
@@ -1608,7 +1628,9 @@ export class Journal {
|
|
|
1608
1628
|
replayExcludedChannels() {
|
|
1609
1629
|
const excluded = new Set();
|
|
1610
1630
|
for (const e of this.read()) {
|
|
1611
|
-
|
|
1631
|
+
// OBS-1010: an in-run no-trailer demotion is a WORKER exclusion too; review-pool-demotion is
|
|
1632
|
+
// the reviewer seat's own fold (daemon demotedReviewers) and never benches the worker channel.
|
|
1633
|
+
if ((e.event === "channel-exclusion" || e.event === "channel-demotion") && typeof e.data.channel === "string") {
|
|
1612
1634
|
excluded.add(e.data.channel);
|
|
1613
1635
|
}
|
|
1614
1636
|
else if (e.event === "dead-channel-failover" && typeof e.data.from === "string") {
|
package/dist/run/lease.d.ts
CHANGED
|
@@ -12,3 +12,47 @@ export declare class CommandLeases {
|
|
|
12
12
|
private readonly owners;
|
|
13
13
|
run<T>(run: () => Promise<T>, waiting: (count: number) => void, pollMs: number, signal?: AbortSignal): Promise<T>;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* OBS-1042: the standalone battery's cross-process lease. Two `tickmarkr verify` runs in two linked
|
|
17
|
+
* worktrees of one clone share NO daemon and NO state directory, so the in-process `CommandLeases`
|
|
18
|
+
* above never saw each other and both full suites ran on one host. This reservation is keyed on the
|
|
19
|
+
* repository's COMMON git dir (`git rev-parse --git-common-dir`), which every linked worktree of a
|
|
20
|
+
* clone shares and an unrelated repository never does. One file, created exclusively; the holder
|
|
21
|
+
* writes its pid and cwd so a waiter can journal who it waits on and reclaim a reservation whose
|
|
22
|
+
* holder is dead. Each reservation releases only itself: a waiter that never acquired removes nothing,
|
|
23
|
+
* so cancelling it never frees the holder. The daemon's adoption is deferred to 2.5.7.
|
|
24
|
+
*/
|
|
25
|
+
export interface RepositoryLeaseHolder {
|
|
26
|
+
pid: number;
|
|
27
|
+
cwd: string;
|
|
28
|
+
at: number;
|
|
29
|
+
token: string;
|
|
30
|
+
}
|
|
31
|
+
export interface RepositoryLeaseOptions {
|
|
32
|
+
pollMs?: number;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
/** Called once per distinct holder the waiter is queued behind. */
|
|
35
|
+
onWait?: (holder: RepositoryLeaseHolder) => void;
|
|
36
|
+
}
|
|
37
|
+
export declare function repositoryLeasePath(cwd: string): Promise<string>;
|
|
38
|
+
/** OBS-1057: dev+ino alone is not an identity across unlink/recreate — Linux hands the freed inode
|
|
39
|
+
* number straight to the next file, so a stale observation of a dead or corrupt reservation matched
|
|
40
|
+
* the fresh reservation that replaced it and reclaimDead removed the live winner (public CI, ubuntu).
|
|
41
|
+
* The bytes are the generation: a fragment never hashes like a complete record, and two complete
|
|
42
|
+
* records differ by token. Size and mtime are not enough (a same-length reservation written within
|
|
43
|
+
* the filesystem's timestamp resolution — Leg-2 review of the first cut). */
|
|
44
|
+
export declare const inodeIdentity: (st: {
|
|
45
|
+
dev: number | bigint;
|
|
46
|
+
ino: number | bigint;
|
|
47
|
+
}, bytes: string | Buffer) => string;
|
|
48
|
+
/** The identity a reclaimer would observe at a path right now, or undefined when nothing is there. */
|
|
49
|
+
export declare const observeIdentity: (path: string) => string | undefined;
|
|
50
|
+
export declare const readHolder: (path: string) => RepositoryLeaseHolder | undefined;
|
|
51
|
+
/** Publish `mine` at `path` atomically: the record is written whole to a private file and linked
|
|
52
|
+
* into place — `link` is exclusive (EEXIST when the path is taken) and a reader never sees a partial
|
|
53
|
+
* record, so a process killed at any point of its acquisition leaves either nothing or a complete
|
|
54
|
+
* record naming a pid a waiter can check. Returns false when the path is taken. */
|
|
55
|
+
export declare function tryReserve(path: string, mine: RepositoryLeaseHolder): boolean;
|
|
56
|
+
/** Exposed for recovery regressions; the standalone verify uses the combined critical section. */
|
|
57
|
+
export declare const reclaimDead: (path: string, identity: string, pollMs?: number) => Promise<boolean>;
|
|
58
|
+
export declare function withRepositoryLease<T>(cwd: string, run: () => Promise<T>, opts?: RepositoryLeaseOptions): Promise<T>;
|