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.
Files changed (48) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/commands/approve.js +64 -3
  3. package/dist/cli/commands/doctor.d.ts +10 -0
  4. package/dist/cli/commands/fleet.js +4 -0
  5. package/dist/cli/commands/plan.js +20 -4
  6. package/dist/cli/commands/verify.js +102 -81
  7. package/dist/config/config.d.ts +11 -0
  8. package/dist/config/config.js +21 -12
  9. package/dist/config/fleet-overlay.js +55 -17
  10. package/dist/drivers/index.js +2 -1
  11. package/dist/drivers/orca.d.ts +21 -1
  12. package/dist/drivers/orca.js +209 -27
  13. package/dist/gates/baseline.d.ts +14 -3
  14. package/dist/gates/baseline.js +61 -14
  15. package/dist/gates/cache.d.ts +14 -13
  16. package/dist/gates/cache.js +17 -5
  17. package/dist/gates/llm.d.ts +3 -0
  18. package/dist/gates/llm.js +11 -0
  19. package/dist/gates/review.d.ts +28 -3
  20. package/dist/gates/review.js +106 -14
  21. package/dist/gates/run-gates.d.ts +6 -1
  22. package/dist/gates/run-gates.js +299 -101
  23. package/dist/gates/test-manifest.d.ts +30 -1
  24. package/dist/gates/test-manifest.js +113 -39
  25. package/dist/gates/test-reporter.js +14 -6
  26. package/dist/graph/graph.d.ts +2 -0
  27. package/dist/graph/graph.js +45 -1
  28. package/dist/run/consult.js +5 -4
  29. package/dist/run/daemon.d.ts +2 -0
  30. package/dist/run/daemon.js +480 -99
  31. package/dist/run/execution-budget.d.ts +25 -0
  32. package/dist/run/execution-budget.js +142 -0
  33. package/dist/run/git.d.ts +40 -0
  34. package/dist/run/git.js +89 -4
  35. package/dist/run/journal.d.ts +3 -1
  36. package/dist/run/journal.js +38 -16
  37. package/dist/run/lease.d.ts +44 -0
  38. package/dist/run/lease.js +226 -3
  39. package/dist/run/recovery.d.ts +8 -0
  40. package/dist/run/recovery.js +25 -0
  41. package/dist/run/repair-selection.d.ts +12 -0
  42. package/dist/run/repair-selection.js +56 -0
  43. package/dist/run/stall.d.ts +6 -1
  44. package/dist/run/stall.js +60 -3
  45. package/dist/tui/ink/fleet-app.d.ts +10 -2
  46. package/dist/tui/ink/fleet-app.js +33 -15
  47. package/package.json +2 -2
  48. 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 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)
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
+ }
@@ -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[];