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
package/dist/run/lease.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { closeSync, constants, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
3
7
|
// The process census and command admission deliberately use the same command-head classifier.
|
|
4
8
|
export function isRunnerCommand(command) {
|
|
5
9
|
// Inspect executable positions only: inference prompts and shell script bodies may name runners.
|
|
@@ -17,9 +21,11 @@ export function isRunnerCommand(command) {
|
|
|
17
21
|
const flags = "(?:\\s+(?:--?[\\w-]+(?:=[^\\s]+)?|--))*";
|
|
18
22
|
const manager = `${binary}(?:npm|pnpm|yarn|bun)${flags}`;
|
|
19
23
|
const jsRunner = `${binary}(?:vitest(?:\\.mjs)?|jest|mocha)(?:\\s|$)`;
|
|
20
|
-
// Test scripts may be scoped (test:unit); runner binaries may be launched
|
|
21
|
-
// Keep each match anchored to that executable position so prompt/argument
|
|
22
|
-
|
|
24
|
+
// Test scripts may be scoped (test:unit, test.integration); runner binaries may be launched
|
|
25
|
+
// through a manager. Keep each match anchored to that executable position so prompt/argument
|
|
26
|
+
// mentions stay out. OBS-1045: the scope is `test` followed by a `:` or `.` separator — the npm
|
|
27
|
+
// scoping convention — so a differently named script such as `test-lint` is never leased.
|
|
28
|
+
return new RegExp(`^${manager}\\s+(?:run${flags}\\s+)?test(?:[:.][\\w.-]+)?(?:\\s|$)`, "i").test(head)
|
|
23
29
|
|| new RegExp(`^${manager}\\s+(?:(?:exec|x|dlx)${flags}\\s+)?${jsRunner}`, "i").test(head)
|
|
24
30
|
|| new RegExp(`^${binary}npx${flags}\\s+${jsRunner}`, "i").test(head)
|
|
25
31
|
|| /^(?:\S*\/)?(?:go|make|cargo)\s+test(?:\s|$)/i.test(head)
|
|
@@ -85,3 +91,220 @@ export class CommandLeases {
|
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
93
|
}
|
|
94
|
+
const LEASE_FILE = "tickmarkr-runner.lease";
|
|
95
|
+
export async function repositoryLeasePath(cwd) {
|
|
96
|
+
const commonDir = await new Promise((ok, fail) => execFile("git", ["rev-parse", "--git-common-dir"], { cwd, encoding: "utf8" }, (error, stdout) => error ? fail(error) : ok(stdout.trim())));
|
|
97
|
+
return join(resolve(cwd, commonDir), LEASE_FILE);
|
|
98
|
+
}
|
|
99
|
+
const alive = (pid) => {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, 0);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return error.code !== "ESRCH";
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const isCode = (error, code) => error.code === code;
|
|
109
|
+
/** OBS-1057: dev+ino alone is not an identity across unlink/recreate — Linux hands the freed inode
|
|
110
|
+
* number straight to the next file, so a stale observation of a dead or corrupt reservation matched
|
|
111
|
+
* the fresh reservation that replaced it and reclaimDead removed the live winner (public CI, ubuntu).
|
|
112
|
+
* The bytes are the generation: a fragment never hashes like a complete record, and two complete
|
|
113
|
+
* records differ by token. Size and mtime are not enough (a same-length reservation written within
|
|
114
|
+
* the filesystem's timestamp resolution — Leg-2 review of the first cut). */
|
|
115
|
+
export const inodeIdentity = (st, bytes) => `${st.dev}-${st.ino}-${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}`;
|
|
116
|
+
/** The identity a reclaimer would observe at a path right now, or undefined when nothing is there. */
|
|
117
|
+
export const observeIdentity = (path) => readOccupant(path)?.identity;
|
|
118
|
+
const readOccupant = (path) => {
|
|
119
|
+
let fd;
|
|
120
|
+
try {
|
|
121
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
122
|
+
const st = fstatSync(fd);
|
|
123
|
+
const bytes = readFileSync(fd, "utf8");
|
|
124
|
+
const identity = inodeIdentity(st, bytes);
|
|
125
|
+
try {
|
|
126
|
+
const holder = JSON.parse(bytes);
|
|
127
|
+
if (typeof holder.pid === "number" && typeof holder.cwd === "string" && typeof holder.token === "string")
|
|
128
|
+
return { holder, identity };
|
|
129
|
+
}
|
|
130
|
+
catch { /* incomplete/foreign bytes are reclaimable under the mutation lock */ }
|
|
131
|
+
return { holder: undefined, identity };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (isCode(error, "ENOENT"))
|
|
135
|
+
return undefined;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
if (fd !== undefined)
|
|
140
|
+
closeSync(fd);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
export const readHolder = (path) => readOccupant(path)?.holder;
|
|
144
|
+
/** Crash-recoverable serialization for lease-path mutations. The highest atomic owner-generation
|
|
145
|
+
* symlink is authoritative. A process killed in the critical section leaves its pid behind, so one
|
|
146
|
+
* contender can exclusively publish the next generation and finish the interrupted operation. */
|
|
147
|
+
const mutationLock = (path) => `${path}.lock`;
|
|
148
|
+
const lockGeneration = (entry) => {
|
|
149
|
+
const match = /^owner\.(\d+)$/.exec(entry);
|
|
150
|
+
return match ? Number(match[1]) : undefined;
|
|
151
|
+
};
|
|
152
|
+
const mutationHolder = (lock) => {
|
|
153
|
+
let names;
|
|
154
|
+
try {
|
|
155
|
+
names = readdirSync(lock);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (isCode(error, "ENOENT"))
|
|
159
|
+
return undefined;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
const current = names.flatMap((entry) => {
|
|
163
|
+
const gen = lockGeneration(entry);
|
|
164
|
+
return gen === undefined ? [] : [{ entry, gen }];
|
|
165
|
+
}).sort((a, b) => b.gen - a.gen)[0];
|
|
166
|
+
if (!current)
|
|
167
|
+
return undefined;
|
|
168
|
+
try {
|
|
169
|
+
const target = readlinkSync(join(lock, current.entry));
|
|
170
|
+
const pid = Number(/^([1-9]\d*):/.exec(target)?.[1]);
|
|
171
|
+
return Number.isInteger(pid) && pid > 0 ? { ...current, target, pid } : { ...current, target };
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return current;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const releaseMutationLock = (lock, entry, target, inherited) => {
|
|
178
|
+
const holder = mutationHolder(lock);
|
|
179
|
+
if (holder?.entry !== entry || holder.target !== target)
|
|
180
|
+
return;
|
|
181
|
+
// Remove our marker last. A successor published after that point is never in this snapshot.
|
|
182
|
+
for (const name of [...inherited, entry]) {
|
|
183
|
+
try {
|
|
184
|
+
unlinkSync(join(lock, name));
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (!isCode(error, "ENOENT"))
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
rmdirSync(lock);
|
|
193
|
+
}
|
|
194
|
+
catch { /* ENOTEMPTY: a successor owns the directory */ }
|
|
195
|
+
};
|
|
196
|
+
const withMutationLock = async (path, pollMs, signal, run) => {
|
|
197
|
+
const lock = mutationLock(path);
|
|
198
|
+
const target = `${process.pid}:${randomUUID()}`;
|
|
199
|
+
let entry = "";
|
|
200
|
+
let inherited = [];
|
|
201
|
+
for (;;) {
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
try {
|
|
204
|
+
mkdirSync(lock);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (!isCode(error, "EEXIST"))
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
const holder = mutationHolder(lock);
|
|
211
|
+
if (holder?.pid !== undefined && alive(holder.pid)) {
|
|
212
|
+
await new Promise((wake) => setTimeout(wake, pollMs));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
entry = `owner.${(holder?.gen ?? -1) + 1}`;
|
|
216
|
+
try {
|
|
217
|
+
symlinkSync(target, join(lock, entry));
|
|
218
|
+
inherited = readdirSync(lock).filter((name) => name !== entry && lockGeneration(name) !== undefined);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (!isCode(error, "EEXIST") && !isCode(error, "ENOENT"))
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
return await run();
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
releaseMutationLock(lock, entry, target, inherited);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
/** Publish `mine` at `path` atomically: the record is written whole to a private file and linked
|
|
234
|
+
* into place — `link` is exclusive (EEXIST when the path is taken) and a reader never sees a partial
|
|
235
|
+
* record, so a process killed at any point of its acquisition leaves either nothing or a complete
|
|
236
|
+
* record naming a pid a waiter can check. Returns false when the path is taken. */
|
|
237
|
+
export function tryReserve(path, mine) {
|
|
238
|
+
const draft = `${path}.${mine.token}.draft`;
|
|
239
|
+
writeFileSync(draft, JSON.stringify(mine) + "\n");
|
|
240
|
+
try {
|
|
241
|
+
linkSync(draft, path);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (isCode(error, "EEXIST"))
|
|
246
|
+
return false;
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
rmSync(draft, { force: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Link the inspected dead inode to its stable tombstone, then remove the canonical name. This runs
|
|
254
|
+
* only under the mutation lock. EEXIST means an earlier reclaimer died after link: recovery finishes
|
|
255
|
+
* only if both names still bind that exact inode, never a replacement holder. */
|
|
256
|
+
const reclaimDeadLocked = (path, identity) => {
|
|
257
|
+
const tombstone = `${path}.dead-${identity}`;
|
|
258
|
+
try {
|
|
259
|
+
linkSync(path, tombstone);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (!isCode(error, "EEXIST") && !isCode(error, "ENOENT"))
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
const dead = readOccupant(tombstone);
|
|
266
|
+
const current = readOccupant(path);
|
|
267
|
+
if (dead?.identity !== identity || current?.identity !== identity)
|
|
268
|
+
return false;
|
|
269
|
+
unlinkSync(path);
|
|
270
|
+
return true;
|
|
271
|
+
};
|
|
272
|
+
/** Exposed for recovery regressions; the standalone verify uses the combined critical section. */
|
|
273
|
+
export const reclaimDead = async (path, identity, pollMs = 10) => withMutationLock(path, pollMs, undefined, () => reclaimDeadLocked(path, identity));
|
|
274
|
+
const inspectAndReserve = (path, mine) => {
|
|
275
|
+
let occupant = readOccupant(path);
|
|
276
|
+
if (occupant && (!occupant.holder || !alive(occupant.holder.pid))) {
|
|
277
|
+
reclaimDeadLocked(path, occupant.identity);
|
|
278
|
+
occupant = readOccupant(path);
|
|
279
|
+
}
|
|
280
|
+
if (!occupant && tryReserve(path, mine))
|
|
281
|
+
return { acquired: true };
|
|
282
|
+
return { acquired: false, holder: occupant?.holder };
|
|
283
|
+
};
|
|
284
|
+
export async function withRepositoryLease(cwd, run, opts = {}) {
|
|
285
|
+
const path = await repositoryLeasePath(cwd);
|
|
286
|
+
const pollMs = opts.pollMs ?? 1_000;
|
|
287
|
+
const mine = { pid: process.pid, cwd, at: Date.now(), token: randomUUID() };
|
|
288
|
+
let waitingOn;
|
|
289
|
+
for (;;) {
|
|
290
|
+
opts.signal?.throwIfAborted();
|
|
291
|
+
const attempt = await withMutationLock(path, pollMs, opts.signal, () => inspectAndReserve(path, mine));
|
|
292
|
+
if (attempt.acquired)
|
|
293
|
+
break;
|
|
294
|
+
if (attempt.holder && attempt.holder.pid !== waitingOn) {
|
|
295
|
+
waitingOn = attempt.holder.pid;
|
|
296
|
+
opts.onWait?.(attempt.holder);
|
|
297
|
+
}
|
|
298
|
+
await new Promise((wake) => setTimeout(wake, pollMs));
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
return await run();
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
// Release is serialized with reclamation/acquisition and removes only this generation.
|
|
305
|
+
await withMutationLock(path, pollMs, undefined, () => {
|
|
306
|
+
if (readHolder(path)?.token === mine.token)
|
|
307
|
+
unlinkSync(path);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GateResult } from "../gates/types.js";
|
|
2
|
+
import type { JournalEvent } from "./journal.js";
|
|
3
|
+
export type FailureDisposition = "infrastructure" | "behavioral" | "unknown";
|
|
4
|
+
export type VerificationRetryCause = "infrastructure" | "host-starved";
|
|
5
|
+
/** Structured verifier evidence wins; neither an exit code nor prose proves infrastructure. */
|
|
6
|
+
export declare function failureDisposition(result: Pick<GateResult, "pass" | "meta">): FailureDisposition;
|
|
7
|
+
/** Reserve before re-execution. Interrupted reservations and new subjects never refill the task cap. */
|
|
8
|
+
export declare function reserveInfrastructureRetry(events: readonly JournalEvent[], taskId: string, subject: string, append: (event: string, taskId: string, data: Record<string, unknown>) => void, cause?: VerificationRetryCause): boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Structured verifier evidence wins; neither an exit code nor prose proves infrastructure. */
|
|
2
|
+
export function failureDisposition(result) {
|
|
3
|
+
if (result.pass)
|
|
4
|
+
return "unknown";
|
|
5
|
+
if (result.meta?.classification === "regression")
|
|
6
|
+
return "behavioral";
|
|
7
|
+
if (result.meta?.infra === true && result.meta?.classification === "infra")
|
|
8
|
+
return "infrastructure";
|
|
9
|
+
return "unknown";
|
|
10
|
+
}
|
|
11
|
+
/** Reserve before re-execution. Interrupted reservations and new subjects never refill the task cap. */
|
|
12
|
+
export function reserveInfrastructureRetry(events, taskId, subject, append, cause = "infrastructure") {
|
|
13
|
+
const used = events.filter((e) => e.taskId === taskId && e.event === "infra-retry-reserved");
|
|
14
|
+
const malformed = used.some((e) => typeof e.data.subject !== "string" || !e.data.subject
|
|
15
|
+
|| (e.data.cause !== "infrastructure" && e.data.cause !== "host-starved"));
|
|
16
|
+
const reason = !subject ? "unknown-subject" : malformed ? "invalid-accounting"
|
|
17
|
+
: used.some((e) => e.data.subject === subject) ? "subject-allowance-exhausted"
|
|
18
|
+
: used.length >= 2 ? "task-allowance-exhausted" : undefined;
|
|
19
|
+
if (reason) {
|
|
20
|
+
append("infra-retry-denied", taskId, { subject, cause, reason, consumed: used.length });
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
append("infra-retry-reserved", taskId, { subject, cause, consumed: used.length + 1 });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { JournalEvent } from "./journal.js";
|
|
2
|
+
export interface RepairSelectionDecision {
|
|
3
|
+
selectTests: boolean;
|
|
4
|
+
requiredFiles: string[];
|
|
5
|
+
reason: "no-test-failure" | "known-failing-files" | "legacy-test-failure" | "unattributed-test-failure" | "full-suite-failure";
|
|
6
|
+
}
|
|
7
|
+
type SelectionEvent = Pick<JournalEvent, "event" | "taskId" | "data">;
|
|
8
|
+
/** Diagnostic selection only: neither a narrow pass nor this decision can authorize integration.
|
|
9
|
+
* Distrust and known failing files survive later green results, approvals and resume. A historical
|
|
10
|
+
* ambiguous failure is never reinterpreted using a newly enabled policy. */
|
|
11
|
+
export declare function repairSelectionDecision(events: readonly SelectionEvent[], taskId: string, enabled: boolean): RepairSelectionDecision;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** These are literal repository-relative identities. Existence and selection support are checked
|
|
2
|
+
* against the current worktree by the gate; this journal fold cannot establish either. */
|
|
3
|
+
function fileIdentities(value) {
|
|
4
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((file) => typeof file !== "string" || !file || /[\\\x00-\x1f\x7f]/.test(file)
|
|
5
|
+
|| /^[A-Za-z]:/.test(file) || file.split("/").some((part) => !part || part === "." || part === ".."))) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
return [...new Set(value)].sort();
|
|
9
|
+
}
|
|
10
|
+
/** Diagnostic selection only: neither a narrow pass nor this decision can authorize integration.
|
|
11
|
+
* Distrust and known failing files survive later green results, approvals and resume. A historical
|
|
12
|
+
* ambiguous failure is never reinterpreted using a newly enabled policy. */
|
|
13
|
+
export function repairSelectionDecision(events, taskId, enabled) {
|
|
14
|
+
const required = new Set();
|
|
15
|
+
let distrust;
|
|
16
|
+
for (const event of events) {
|
|
17
|
+
if (event.taskId !== taskId || event.event !== "gate-result"
|
|
18
|
+
|| event.data.gate !== "test" || event.data.pass !== false)
|
|
19
|
+
continue;
|
|
20
|
+
const data = event.data;
|
|
21
|
+
if (!enabled) {
|
|
22
|
+
distrust ??= "legacy-test-failure";
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
// Only the recorded closed disposition can exempt infrastructure. Contradictory structured
|
|
26
|
+
// failure evidence cannot turn a behavioral failure into a harmless infrastructure event.
|
|
27
|
+
if (data.disposition === "infrastructure" && data.classification !== "regression"
|
|
28
|
+
&& (data.failingFiles === undefined || (Array.isArray(data.failingFiles) && data.failingFiles.length === 0)))
|
|
29
|
+
continue;
|
|
30
|
+
if (data.disposition !== "behavioral" || typeof data.commit !== "string"
|
|
31
|
+
|| !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(data.commit)) {
|
|
32
|
+
distrust ??= "unattributed-test-failure";
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// A replacement full-suite failure follows a passing screen in the current pipeline. No
|
|
36
|
+
// ordinary full-suite failure establishes that a selector safely covers the failed behavior.
|
|
37
|
+
if (data.fullSuite === true) {
|
|
38
|
+
distrust ??= "full-suite-failure";
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const selected = fileIdentities(data.selectedTests);
|
|
42
|
+
const failing = fileIdentities(data.failingFiles);
|
|
43
|
+
if (!selected || !failing || failing.some((file) => !selected.includes(file))
|
|
44
|
+
|| (data.fullSuite !== undefined && data.fullSuite !== false)) {
|
|
45
|
+
distrust ??= "unattributed-test-failure";
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
for (const file of failing)
|
|
49
|
+
required.add(file);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
selectTests: distrust === undefined,
|
|
53
|
+
requiredFiles: [...required].sort(),
|
|
54
|
+
reason: distrust ?? (required.size ? "known-failing-files" : "no-test-failure"),
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/run/stall.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ export declare function harvestCpuFlatWindowMs(resolutionMs: number): number;
|
|
|
10
10
|
/** Test seam — pin the quantization-aware flat window without changing production policy. */
|
|
11
11
|
export declare function setHarvestCpuFlatMsForTests(ms: number): void;
|
|
12
12
|
export declare function resetHarvestCpuFlatMsForTests(): void;
|
|
13
|
+
/** The dispatch shell records its foreground group in an attempt-unique artifact. */
|
|
14
|
+
export declare function readOwnedProcessGroup(path: string): number | undefined;
|
|
15
|
+
/** Never infer ownership from a task name or scan another attempt's marker when reaping. */
|
|
16
|
+
export declare function reapOwnedProcessGroup(group: number | undefined, cwd: string): Promise<number[] | null>;
|
|
13
17
|
export declare function workerTreeCpuMs(marker: string, cwd: string): Promise<{
|
|
14
18
|
ms: number;
|
|
15
19
|
resolutionMs: number;
|
|
@@ -17,6 +21,7 @@ export declare function workerTreeCpuMs(marker: string, cwd: string): Promise<{
|
|
|
17
21
|
export declare class WorkerTreeCpuAccountant {
|
|
18
22
|
private marker;
|
|
19
23
|
private cwd;
|
|
24
|
+
private group?;
|
|
20
25
|
private active;
|
|
21
26
|
private loop;
|
|
22
27
|
private live;
|
|
@@ -24,7 +29,7 @@ export declare class WorkerTreeCpuAccountant {
|
|
|
24
29
|
private gaps;
|
|
25
30
|
private consecutiveGaps;
|
|
26
31
|
private latest;
|
|
27
|
-
constructor(marker: string, cwd: string);
|
|
32
|
+
constructor(marker: string, cwd: string, group?: (() => number | undefined) | undefined);
|
|
28
33
|
private sample;
|
|
29
34
|
start(): Promise<void>;
|
|
30
35
|
read(): {
|
package/dist/run/stall.js
CHANGED
|
@@ -52,6 +52,51 @@ function parsePsCpu(raw) {
|
|
|
52
52
|
+ Math.round(Number(m[4]) * 1000);
|
|
53
53
|
return { ms, frac: m[4].includes(".") };
|
|
54
54
|
}
|
|
55
|
+
/** The dispatch shell records its foreground group in an attempt-unique artifact. */
|
|
56
|
+
export function readOwnedProcessGroup(path) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = readFileSync(path, "utf8").trim();
|
|
59
|
+
const group = /^\d+$/.test(raw) ? Number(raw) : 0;
|
|
60
|
+
return Number.isSafeInteger(group) && group > 1 ? group : undefined;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Never infer ownership from a task name or scan another attempt's marker when reaping. */
|
|
67
|
+
export async function reapOwnedProcessGroup(group, cwd) {
|
|
68
|
+
if (group === undefined)
|
|
69
|
+
return null;
|
|
70
|
+
const own = await shGit(`ps -o pgid= -p ${process.pid}`, cwd, 5_000);
|
|
71
|
+
// An in-process driver fixture (or a non-isolating host) can share the daemon's
|
|
72
|
+
// group. It is not an owned worker group: let the driver's close retire that slot.
|
|
73
|
+
if (own.code !== 0 || !/^\d+$/.test(own.stdout.trim()) || Number(own.stdout.trim()) === group)
|
|
74
|
+
return null;
|
|
75
|
+
try {
|
|
76
|
+
process.kill(-group, "SIGKILL");
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code !== "ESRCH")
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
let survivors = [];
|
|
83
|
+
// SIGKILL delivery and process retirement are asynchronous. Bound the confirmation
|
|
84
|
+
// grace, retaining names if a process is still present after it.
|
|
85
|
+
for (let probe = 0; probe < 5; probe++) {
|
|
86
|
+
const snapshot = await shGit("ps -Awwo pid=,pgid=,stat=", cwd, 5_000);
|
|
87
|
+
if (snapshot.code !== 0)
|
|
88
|
+
return null;
|
|
89
|
+
survivors = snapshot.stdout.split("\n").flatMap((line) => {
|
|
90
|
+
const row = /^\s*(\d+)\s+(\d+)\s+(\S+)/.exec(line);
|
|
91
|
+
return row && Number(row[2]) === group && !row[3].startsWith("Z") ? [Number(row[1])] : [];
|
|
92
|
+
});
|
|
93
|
+
if (survivors.length === 0)
|
|
94
|
+
break;
|
|
95
|
+
if (probe < 4)
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
97
|
+
}
|
|
98
|
+
return survivors;
|
|
99
|
+
}
|
|
55
100
|
let linuxClockTickMs;
|
|
56
101
|
function linuxProcessCpuMs(pid, cwd) {
|
|
57
102
|
if (!existsSync("/proc/self/stat"))
|
|
@@ -81,7 +126,7 @@ function linuxProcessCpuMs(pid, cwd) {
|
|
|
81
126
|
// Every non-seeded worker process descends from its attempt-unique dispatch script. One ps snapshot
|
|
82
127
|
// finds that root and its descendants. An empty tree is measurable zero; a failed or unparseable
|
|
83
128
|
// snapshot is undefined because missing evidence can never prove inactivity.
|
|
84
|
-
async function workerTreeCpuSnapshot(marker, cwd) {
|
|
129
|
+
async function workerTreeCpuSnapshot(marker, cwd, group) {
|
|
85
130
|
const snapshot = await shGit("ps -Awwo pid=,ppid=,time=,command=", cwd, 15_000);
|
|
86
131
|
if (snapshot.code !== 0)
|
|
87
132
|
return undefined;
|
|
@@ -97,6 +142,16 @@ async function workerTreeCpuSnapshot(marker, cwd) {
|
|
|
97
142
|
if (rows.length === 0)
|
|
98
143
|
return undefined;
|
|
99
144
|
const tree = new Set(rows.filter((p) => p.cmd.includes(marker)).map((p) => p.pid));
|
|
145
|
+
if (group !== undefined) {
|
|
146
|
+
const groups = await shGit("ps -Awwo pid=,pgid=", cwd, 15_000);
|
|
147
|
+
if (groups.code !== 0)
|
|
148
|
+
return undefined;
|
|
149
|
+
for (const line of groups.stdout.split("\n")) {
|
|
150
|
+
const row = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
|
|
151
|
+
if (row && Number(row[2]) === group)
|
|
152
|
+
tree.add(row[1]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
100
155
|
// ps output is not topologically ordered; relax the parent -> child closure until stable.
|
|
101
156
|
for (let grew = true; grew;) {
|
|
102
157
|
grew = false;
|
|
@@ -141,6 +196,7 @@ export async function workerTreeCpuMs(marker, cwd) {
|
|
|
141
196
|
export class WorkerTreeCpuAccountant {
|
|
142
197
|
marker;
|
|
143
198
|
cwd;
|
|
199
|
+
group;
|
|
144
200
|
active = false;
|
|
145
201
|
loop;
|
|
146
202
|
live = new Map();
|
|
@@ -148,12 +204,13 @@ export class WorkerTreeCpuAccountant {
|
|
|
148
204
|
gaps = 0;
|
|
149
205
|
consecutiveGaps = 0;
|
|
150
206
|
latest;
|
|
151
|
-
constructor(marker, cwd) {
|
|
207
|
+
constructor(marker, cwd, group) {
|
|
152
208
|
this.marker = marker;
|
|
153
209
|
this.cwd = cwd;
|
|
210
|
+
this.group = group;
|
|
154
211
|
}
|
|
155
212
|
async sample() {
|
|
156
|
-
const snapshot = await workerTreeCpuSnapshot(this.marker, this.cwd);
|
|
213
|
+
const snapshot = await workerTreeCpuSnapshot(this.marker, this.cwd, this.group?.());
|
|
157
214
|
if (snapshot === undefined) {
|
|
158
215
|
this.gaps++;
|
|
159
216
|
this.live.clear();
|
|
@@ -5,6 +5,8 @@ export type FleetSteeringKey = "review" | "consult";
|
|
|
5
5
|
export type FleetEditorState = {
|
|
6
6
|
denyAdapters: string[];
|
|
7
7
|
denyModels: string[];
|
|
8
|
+
/** OBS-1046: the staged routing.allow complement, distinct from the authored deny lists above */
|
|
9
|
+
allowOut?: string[];
|
|
8
10
|
denyWorkersAdapters: string[];
|
|
9
11
|
denyWorkersModels: string[];
|
|
10
12
|
classifications: FleetClassification[];
|
|
@@ -67,6 +69,8 @@ export type FleetStagedDeny = {
|
|
|
67
69
|
models: string[];
|
|
68
70
|
workersAdapters: string[];
|
|
69
71
|
workersModels: string[];
|
|
72
|
+
/** OBS-1046: the staged routing.allow complement; absent ⇒ the session's initial one */
|
|
73
|
+
allowOut?: string[];
|
|
70
74
|
};
|
|
71
75
|
export type FleetModelGroup = {
|
|
72
76
|
adapter: string;
|
|
@@ -119,11 +123,13 @@ export type FleetStagedRouting = {
|
|
|
119
123
|
ok: false;
|
|
120
124
|
error: string;
|
|
121
125
|
};
|
|
122
|
-
export declare function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels, initialDenyWorkersAdapters, initialDenyWorkersModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, stagedRouting, entry, initialJudge, judgeSeats, viewRows, frameColumns, frameRows, }: {
|
|
126
|
+
export declare function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels, initialAllowOut, initialDenyWorkersAdapters, initialDenyWorkersModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, stagedRouting, entry, initialJudge, judgeSeats, viewRows, frameColumns, frameRows, }: {
|
|
123
127
|
ageMs: number | null;
|
|
124
128
|
agents: AgentCli[];
|
|
125
129
|
initialDenyAdapters: string[];
|
|
126
130
|
initialDenyModels: string[];
|
|
131
|
+
/** OBS-1046: adapter ids and adapter:model keys routing.allow leaves out of the fleet */
|
|
132
|
+
initialAllowOut?: string[];
|
|
127
133
|
initialDenyWorkersAdapters?: string[];
|
|
128
134
|
initialDenyWorkersModels?: string[];
|
|
129
135
|
modelGroups: FleetModelGroup[];
|
|
@@ -157,12 +163,14 @@ export declare function FleetApp({ ageMs, agents, initialDenyAdapters, initialDe
|
|
|
157
163
|
/** terminal height — set only on a real TTY so the footer sticks to the bottom */
|
|
158
164
|
frameRows?: number;
|
|
159
165
|
}): import("react").JSX.Element;
|
|
160
|
-
export declare function runFleetInkEditor({ ageMs, adapters, health, initialDenyAdapters, initialDenyModels, initialDenyWorkersAdapters, initialDenyWorkersModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, stagedRouting, entry, initialJudge, judgeSeats, initialInput, input, output, debug, }: {
|
|
166
|
+
export declare function runFleetInkEditor({ ageMs, adapters, health, initialDenyAdapters, initialDenyModels, initialAllowOut, initialDenyWorkersAdapters, initialDenyWorkersModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, stagedRouting, entry, initialJudge, judgeSeats, initialInput, input, output, debug, }: {
|
|
161
167
|
ageMs: number | null;
|
|
162
168
|
adapters: WorkerAdapter[];
|
|
163
169
|
health: Record<string, AuthHealth>;
|
|
164
170
|
initialDenyAdapters: string[];
|
|
165
171
|
initialDenyModels: string[];
|
|
172
|
+
/** OBS-1046: adapter ids and adapter:model keys routing.allow leaves out of the fleet */
|
|
173
|
+
initialAllowOut?: string[];
|
|
166
174
|
initialDenyWorkersAdapters?: string[];
|
|
167
175
|
initialDenyWorkersModels?: string[];
|
|
168
176
|
modelGroups: FleetModelGroup[];
|