tickmarkr 2.5.5 → 2.5.7
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/adapters/prompt.js +21 -1
- package/dist/cli/commands/approve.js +69 -8
- 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/status.js +95 -34
- package/dist/cli/commands/verify.js +108 -85
- 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 +21 -5
- package/dist/gates/baseline.js +67 -17
- 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 +118 -15
- package/dist/gates/run-gates.d.ts +10 -2
- package/dist/gates/run-gates.js +362 -108
- package/dist/gates/test-manifest.d.ts +33 -1
- package/dist/gates/test-manifest.js +132 -40
- package/dist/gates/test-reporter.js +20 -7
- package/dist/graph/graph.d.ts +4 -0
- package/dist/graph/graph.js +50 -1
- package/dist/run/activity.d.ts +28 -0
- package/dist/run/activity.js +194 -0
- package/dist/run/consult.js +5 -4
- package/dist/run/daemon.d.ts +11 -0
- package/dist/run/daemon.js +663 -128
- package/dist/run/execution-budget.d.ts +25 -0
- package/dist/run/execution-budget.js +142 -0
- package/dist/run/git.d.ts +46 -1
- package/dist/run/git.js +149 -12
- package/dist/run/journal.d.ts +23 -5
- package/dist/run/journal.js +98 -25
- package/dist/run/lease.d.ts +44 -0
- package/dist/run/lease.js +226 -3
- package/dist/run/operator-page-summary.d.ts +56 -0
- package/dist/run/operator-page-summary.js +68 -0
- package/dist/run/operator-summary.d.ts +69 -0
- package/dist/run/operator-summary.js +77 -0
- package/dist/run/protocol.d.ts +71 -0
- package/dist/run/protocol.js +32 -0
- 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/cockpit/board.d.ts +9 -0
- package/dist/tui/cockpit/board.js +10 -0
- package/dist/tui/cockpit/derive.d.ts +35 -0
- package/dist/tui/cockpit/derive.js +152 -10
- package/dist/tui/cockpit/evidence-view.d.ts +2 -0
- package/dist/tui/cockpit/evidence-view.js +42 -12
- package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
- package/dist/tui/cockpit/run-cockpit.js +95 -1
- package/dist/tui/cockpit/run-view.d.ts +37 -0
- package/dist/tui/cockpit/run-view.js +189 -2
- 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/SKILL.md +55 -3
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
|
@@ -6,13 +6,15 @@ import { parseArgs } from "node:util";
|
|
|
6
6
|
import { allAdapters, probeAll, readDoctor, rolePools } from "../../adapters/registry.js";
|
|
7
7
|
import { channelKey } from "../../adapters/types.js";
|
|
8
8
|
import { loadConfig } from "../../config/config.js";
|
|
9
|
-
import { captureBaseline, detectGateCommands } from "../../gates/baseline.js";
|
|
9
|
+
import { captureBaseline, detectGateCommands, staleFileCountCommands } from "../../gates/baseline.js";
|
|
10
10
|
import { modelProvider } from "../../gates/review.js";
|
|
11
11
|
import { runGates } from "../../gates/run-gates.js";
|
|
12
12
|
import { getTask, loadGraph } from "../../graph/graph.js";
|
|
13
13
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
14
|
+
import { executionSignal } from "../../run/execution-budget.js";
|
|
14
15
|
import { linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.js";
|
|
15
16
|
import { Journal } from "../../run/journal.js";
|
|
17
|
+
import { withRepositoryLease } from "../../run/lease.js";
|
|
16
18
|
/**
|
|
17
19
|
* tickmarkr verify — the gate battery as a standalone command (OPERATING-MODEL-2026-08-11 item 3).
|
|
18
20
|
*
|
|
@@ -148,10 +150,12 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
148
150
|
allowPositionals: false,
|
|
149
151
|
});
|
|
150
152
|
const stateRoot = await verifyStateRoot(cwd);
|
|
153
|
+
const fileRoot = (file) => existsSync(join(cwd, ".tickmarkr", file)) ? cwd : stateRoot;
|
|
151
154
|
if (resolve(stateRoot) !== resolve(cwd)) {
|
|
152
|
-
console.error(`verify: state files
|
|
155
|
+
console.error(`verify: state files resolved read-only from ${join(stateRoot, ".tickmarkr")} (linked worktree; per-file origins: `
|
|
156
|
+
+ STATE_FILES.map(file => `${file}: ${join(fileRoot(file), ".tickmarkr", file)} (${fileRoot(file) === cwd ? "local" : "common root"})`).join("; ") + ")");
|
|
153
157
|
}
|
|
154
|
-
const cfg = loadConfig(
|
|
158
|
+
const cfg = loadConfig(fileRoot("config.yaml"));
|
|
155
159
|
const head = (await shGitOk("git rev-parse HEAD", cwd)).trim();
|
|
156
160
|
const baseTip = (await shGitOk(`git rev-parse '${values.base}'`, cwd).catch(() => {
|
|
157
161
|
throw new Error(`--base ${values.base} is not a resolvable ref — pass --base <ref> naming the branch this diff targets`);
|
|
@@ -165,7 +169,7 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
165
169
|
let files = values.files ?? [];
|
|
166
170
|
let goal = `independent verification of the ${values.base}..HEAD diff`;
|
|
167
171
|
if (values.task) {
|
|
168
|
-
const t = getTask(loadGraph(
|
|
172
|
+
const t = getTask(loadGraph(fileRoot("graph.json")), values.task);
|
|
169
173
|
acceptance = t.acceptance;
|
|
170
174
|
if (!files.length)
|
|
171
175
|
files = t.files;
|
|
@@ -221,7 +225,7 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
221
225
|
let author = HUMAN_AUTHOR;
|
|
222
226
|
const adapters = allAdapters();
|
|
223
227
|
if (wantAcceptance || wantReview) {
|
|
224
|
-
const health = readDoctor(
|
|
228
|
+
const health = readDoctor(fileRoot("doctor.json")) ?? (await probeAll(adapters));
|
|
225
229
|
const pools = rolePools(cfg, adapters, health);
|
|
226
230
|
judgeChannels = pools.judge;
|
|
227
231
|
channels = pools.review;
|
|
@@ -249,95 +253,114 @@ export async function verify(argv, cwd = process.cwd()) {
|
|
|
249
253
|
// ponytail: tmpdir means the baseline cache dies on reboot/cleanup — worst case is one re-capture.
|
|
250
254
|
const stateDir = verifyStateDir(cwd);
|
|
251
255
|
mkdirSync(stateDir, { recursive: true });
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
if (cached) {
|
|
271
|
-
console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
|
|
272
|
-
baseline = cached;
|
|
256
|
+
const recordJournal = values.record ? Journal.open(stateRoot, values.record) : undefined;
|
|
257
|
+
// OBS-1042: one runner root per repository across every linked worktree — the capture and the
|
|
258
|
+
// battery below run under a reservation keyed on the common git dir, and a waiter says who holds it.
|
|
259
|
+
return withRepositoryLease(cwd, () => leasedVerify(), {
|
|
260
|
+
signal: executionSignal(),
|
|
261
|
+
onWait: (holder) => {
|
|
262
|
+
console.error(`verify: waiting for the repository's runner lease held by pid ${holder.pid} in ${holder.cwd}`);
|
|
263
|
+
recordJournal?.append("suite-wait", values.task ?? "VERIFY", { holderPid: holder.pid, holderCwd: holder.cwd });
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
async function leasedVerify() {
|
|
267
|
+
let baseline;
|
|
268
|
+
const cachePath = baselineCachePath(cwd, mergeBase, commands);
|
|
269
|
+
const verdictlessMarker = `${cachePath}.verdictless`;
|
|
270
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
271
|
+
if (values.baseline) {
|
|
272
|
+
baseline = JSON.parse(readFileSync(values.baseline, "utf8"));
|
|
273
273
|
}
|
|
274
274
|
else {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
|
|
280
|
-
}
|
|
281
|
-
const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
|
|
282
|
-
await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
|
|
283
|
-
await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
|
|
284
|
-
try {
|
|
285
|
-
linkNodeModules(cwd, baseDir, { force: true });
|
|
286
|
-
baseline = await captureBaseline(baseDir, commands);
|
|
287
|
-
const missing = verdictlessCommands(baseline, commands);
|
|
275
|
+
let cached;
|
|
276
|
+
if (existsSync(cachePath)) {
|
|
277
|
+
cached = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
278
|
+
const missing = verdictlessCommands(cached, commands);
|
|
288
279
|
if (missing.length) {
|
|
289
|
-
|
|
280
|
+
console.error(`verify: cached baseline recorded no verdict for ${missing.join(", ")}; it was not reusable and will be recaptured`);
|
|
290
281
|
rmSync(cachePath, { force: true });
|
|
282
|
+
cached = undefined;
|
|
291
283
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
284
|
+
// OBS-1044: a cached vitest count that is a stdout sum may be inflated by nested runner echo;
|
|
285
|
+
// applying it would manufacture a deficit. Only a manifest-derived count is a floor.
|
|
286
|
+
const stale = cached ? staleFileCountCommands(cached, commands, cwd) : [];
|
|
287
|
+
if (stale.length) {
|
|
288
|
+
console.error(`verify: cached baseline's file count for ${stale.join(", ")} is not manifest-derived; it will be recaptured`);
|
|
289
|
+
rmSync(cachePath, { force: true });
|
|
290
|
+
cached = undefined;
|
|
295
291
|
}
|
|
296
292
|
}
|
|
297
|
-
|
|
298
|
-
|
|
293
|
+
if (cached) {
|
|
294
|
+
console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${cachePath})`);
|
|
295
|
+
baseline = cached;
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
if (existsSync(verdictlessMarker)) {
|
|
299
|
+
console.error(`verify: prior baseline recorded no verdict and was not cached; recapturing merge-base ${mergeBase.slice(0, 12)}`);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} (cached by base, lockfile and command hashes at ${cachePath})`);
|
|
303
|
+
}
|
|
304
|
+
const baseDir = join(stateDir, `base-${mergeBase.slice(0, 12)}`);
|
|
305
|
+
await shGit(`git worktree remove --force '${baseDir}'`, cwd); // stale leftover from an interrupted run
|
|
306
|
+
await shGitOk(`git worktree add --detach '${baseDir}' '${mergeBase}'`, cwd);
|
|
307
|
+
try {
|
|
308
|
+
linkNodeModules(cwd, baseDir, { force: true });
|
|
309
|
+
baseline = await captureBaseline(baseDir, commands);
|
|
310
|
+
const missing = verdictlessCommands(baseline, commands);
|
|
311
|
+
if (missing.length) {
|
|
312
|
+
writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
|
|
313
|
+
rmSync(cachePath, { force: true });
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
writeFileSync(cachePath, JSON.stringify(baseline, null, 2));
|
|
317
|
+
rmSync(verdictlessMarker, { force: true });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
await removeWorktree(cwd, baseDir);
|
|
322
|
+
}
|
|
299
323
|
}
|
|
300
324
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
|
|
317
|
-
},
|
|
318
|
-
});
|
|
319
|
-
const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
|
|
320
|
-
const reviewRows = results.filter((result) => result.gate === "review");
|
|
321
|
-
const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
|
|
322
|
-
const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
|
|
323
|
-
return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
|
|
324
|
-
}));
|
|
325
|
-
const artifactPath = join(artifactDir, "verify-results.json");
|
|
326
|
-
writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
|
|
327
|
-
console.error(`verify: artifacts written to ${artifactPath}`);
|
|
328
|
-
const review = reviewRows.at(-1);
|
|
329
|
-
if (recordJournal && review) {
|
|
330
|
-
recordJournal.append("review-leg2", values.task ?? "VERIFY", {
|
|
331
|
-
base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
|
|
332
|
-
...review,
|
|
325
|
+
const artifactDir = join(stateDir, new Date().toISOString().replace(/[:.]/g, "-"));
|
|
326
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
327
|
+
const { results } = await runGates(task, {
|
|
328
|
+
worktree: cwd, baseRef: mergeBase,
|
|
329
|
+
result: { ok: true, summary: "standalone verify — no worker claims to trust", deviations: [], raw: "" },
|
|
330
|
+
author, commands, baseline, channels, ...(judgeChannels ? { judgeChannels } : {}), adapters, cfg,
|
|
331
|
+
verificationScope: "standalone", artifactDir, stateDir: join(stateRoot, ".tickmarkr"),
|
|
332
|
+
onGate: (e) => {
|
|
333
|
+
if (e.phase === "start")
|
|
334
|
+
console.error(`verify: → ${e.gate} (${e.index}/${e.total})`);
|
|
335
|
+
else if (e.phase === "note")
|
|
336
|
+
console.error(`verify: note ${e.gate} ${e.name} ${JSON.stringify(e.payload)}`);
|
|
337
|
+
else
|
|
338
|
+
console.error(`verify: ${e.result.pass ? "✓" : "✗"} ${e.result.gate} — ${e.result.details.split("\n")[0] ?? ""}`);
|
|
339
|
+
},
|
|
333
340
|
});
|
|
341
|
+
const green = results.length > 0 && results.every((r) => r.pass || r.meta?.skipped === true);
|
|
342
|
+
const reviewRows = results.filter((result) => result.gate === "review");
|
|
343
|
+
const reviewFindings = reviewRows.flatMap((result) => result.details.split("\n").flatMap((line) => {
|
|
344
|
+
const match = /^- \[([^\]]+)\] (.*)$/.exec(line);
|
|
345
|
+
return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
|
|
346
|
+
}));
|
|
347
|
+
const artifactPath = join(artifactDir, "verify-results.json");
|
|
348
|
+
writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
|
|
349
|
+
console.error(`verify: artifacts written to ${artifactPath}`);
|
|
350
|
+
const review = reviewRows.at(-1);
|
|
351
|
+
if (recordJournal && review) {
|
|
352
|
+
recordJournal.append("review-leg2", values.task ?? "VERIFY", {
|
|
353
|
+
base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
|
|
354
|
+
...review,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (values.json) {
|
|
358
|
+
return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
|
|
359
|
+
}
|
|
360
|
+
const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
|
|
361
|
+
const verdict = green
|
|
362
|
+
? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
|
|
363
|
+
: `verify RED — first failure decides; artifacts: ${artifactPath}`;
|
|
364
|
+
return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
|
|
334
365
|
}
|
|
335
|
-
if (values.json) {
|
|
336
|
-
return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
|
|
337
|
-
}
|
|
338
|
-
const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
|
|
339
|
-
const verdict = green
|
|
340
|
-
? `verify GREEN — ${results.length} gate(s) passed on ${mergeBase.slice(0, 12)}..${head.slice(0, 12)} (merge is a human decision; artifacts: ${artifactPath})`
|
|
341
|
-
: `verify RED — first failure decides; artifacts: ${artifactPath}`;
|
|
342
|
-
return { out: [...lines, "", verdict].join("\n"), code: green ? 0 : 2 };
|
|
343
366
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -132,7 +132,17 @@ export declare function effectiveReviewPolicy(files: ReadonlyArray<string>, revi
|
|
|
132
132
|
* under-flagged one skips the only gate that exists for it.
|
|
133
133
|
*/
|
|
134
134
|
export declare function criticalPathHits(files: ReadonlyArray<string>, criticalPaths?: ReadonlyArray<string>): string[];
|
|
135
|
+
export declare const ExecutionPolicySchema: z.ZodObject<{
|
|
136
|
+
boundedInfrastructure: z.ZodDefault<z.ZodBoolean>;
|
|
137
|
+
repairSelection: z.ZodDefault<z.ZodBoolean>;
|
|
138
|
+
taskExecutionLimitMs: z.ZodNumber;
|
|
139
|
+
}, z.core.$strict>;
|
|
135
140
|
export declare const TickmarkrConfigSchema: z.ZodObject<{
|
|
141
|
+
executionPolicy: z.ZodOptional<z.ZodObject<{
|
|
142
|
+
boundedInfrastructure: z.ZodDefault<z.ZodBoolean>;
|
|
143
|
+
repairSelection: z.ZodDefault<z.ZodBoolean>;
|
|
144
|
+
taskExecutionLimitMs: z.ZodNumber;
|
|
145
|
+
}, z.core.$strict>>;
|
|
136
146
|
concurrency: z.ZodNumber;
|
|
137
147
|
driver: z.ZodEnum<{
|
|
138
148
|
auto: "auto";
|
|
@@ -366,6 +376,7 @@ export type FleetEditable = {
|
|
|
366
376
|
denyModels: string[];
|
|
367
377
|
denyWorkersAdapters?: string[];
|
|
368
378
|
denyWorkersModels?: string[];
|
|
379
|
+
allowOut?: string[];
|
|
369
380
|
tiers: Record<string, Record<string, FleetTierAssignment | null>>;
|
|
370
381
|
map: Record<string, MapEntry>;
|
|
371
382
|
floors: Record<string, Tier>;
|
package/dist/config/config.js
CHANGED
|
@@ -264,7 +264,14 @@ const ShapeGateParticipationSchema = z
|
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
266
|
});
|
|
267
|
+
export const ExecutionPolicySchema = z.object({
|
|
268
|
+
boundedInfrastructure: z.boolean().default(false),
|
|
269
|
+
repairSelection: z.boolean().default(false),
|
|
270
|
+
taskExecutionLimitMs: z.number().int().positive(),
|
|
271
|
+
}).strict();
|
|
267
272
|
export const TickmarkrConfigSchema = z.object({
|
|
273
|
+
// Opt-in recovery experiment. The original run snapshot owns this across resumes.
|
|
274
|
+
executionPolicy: ExecutionPolicySchema.optional(),
|
|
268
275
|
concurrency: z.number().int().positive(),
|
|
269
276
|
driver: z.enum(["auto", "herdr", "subprocess", "orca"]),
|
|
270
277
|
integrationBranchPrefix: z
|
|
@@ -902,12 +909,13 @@ export function universeEntryMatches(row, model, entry) {
|
|
|
902
909
|
export function universeCovers(universe, entry) {
|
|
903
910
|
return universe.some((row) => entry === row.adapter || row.models.some((m) => entry === m || entry === `${row.adapter}:${m}`));
|
|
904
911
|
}
|
|
905
|
-
// v1.92 fleet membership: with a discovered `universe` (classified models only) the
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
//
|
|
912
|
+
// v1.92 fleet membership: with a discovered `universe` (classified models only) the routing.allow
|
|
913
|
+
// complement (fail-closed) rides as `allowOut` — adapter ids and adapter:model keys — BESIDE the
|
|
914
|
+
// authored routing.deny adapters/models entries, which ride VERBATIM in their own lists (never
|
|
915
|
+
// workers). LEG2-T3 round 2 finding 2: authored entries are never collapsed into a membership key;
|
|
916
|
+
// OBS-1046: nor is an allow exclusion folded into a deny list — the two reasons stay distinct so
|
|
917
|
+
// one Space press clears one; finding 3: allow admission honours recorded identity.
|
|
918
|
+
// Absent universe ⇒ deny arrays verbatim and no allowOut, so non-UI callers stay byte-identical.
|
|
911
919
|
export function fleetEditableFromConfig(cfg, universe) {
|
|
912
920
|
const tiers = {};
|
|
913
921
|
for (const [adapter, entry] of Object.entries(cfg.tiers)) {
|
|
@@ -916,8 +924,9 @@ export function fleetEditableFromConfig(cfg, universe) {
|
|
|
916
924
|
tiers[adapter][model] = { tier };
|
|
917
925
|
}
|
|
918
926
|
}
|
|
919
|
-
|
|
920
|
-
|
|
927
|
+
const denyAdapters = [...(cfg.routing.deny?.adapters ?? [])].sort();
|
|
928
|
+
const denyModels = [...(cfg.routing.deny?.models ?? [])].sort();
|
|
929
|
+
let allowOut;
|
|
921
930
|
if (universe !== undefined) {
|
|
922
931
|
const { allow } = cfg.routing;
|
|
923
932
|
const allowEntries = allow ? [...(allow.adapters ?? []), ...(allow.models ?? [])] : null;
|
|
@@ -933,14 +942,14 @@ export function fleetEditableFromConfig(cfg, universe) {
|
|
|
933
942
|
modelsOut.push(...out.map((m) => `${row.adapter}:${m}`));
|
|
934
943
|
}
|
|
935
944
|
}
|
|
936
|
-
// OBS-517: authored deny entries — covered by the probe universe or not — ride verbatim,
|
|
937
|
-
// transient probe failure can never un-deny one on the next write (fail-open on the ban list).
|
|
938
|
-
|
|
939
|
-
denyModels = [...new Set([...modelsOut, ...denyModels])].sort();
|
|
945
|
+
// OBS-517: authored deny entries — covered by the probe universe or not — ride verbatim (above),
|
|
946
|
+
// so a transient probe failure can never un-deny one on the next write (fail-open on the ban list).
|
|
947
|
+
allowOut = [...new Set([...adaptersOut, ...modelsOut])].sort();
|
|
940
948
|
}
|
|
941
949
|
return {
|
|
942
950
|
denyAdapters,
|
|
943
951
|
denyModels,
|
|
952
|
+
...(allowOut !== undefined ? { allowOut } : {}),
|
|
944
953
|
// OBS-994: workers-only deny is never folded into the all-seats complement above — it is
|
|
945
954
|
// returned verbatim beside the flat scopes so the fleet browser can show and toggle it.
|
|
946
955
|
denyWorkersAdapters: [...(cfg.routing.deny?.workers?.adapters ?? [])].sort(),
|
|
@@ -23,7 +23,8 @@ function allowFormFromExclusions(universe, edited) {
|
|
|
23
23
|
}
|
|
24
24
|
// LEG2-T3 round 2 finding 1: every staged entry excludes what it NAMES — a bare model id every
|
|
25
25
|
// adapter serving it, an identity its alias — never only an adapter id or an adapter:model key.
|
|
26
|
-
|
|
26
|
+
// OBS-1046: the staged allow complement is a reason of its own, beside the authored deny lists.
|
|
27
|
+
const entries = [...edited.denyAdapters, ...edited.denyModels, ...(edited.allowOut ?? [])];
|
|
27
28
|
const adapters = [];
|
|
28
29
|
const models = [];
|
|
29
30
|
let excluded = false;
|
|
@@ -56,9 +57,12 @@ function residualDeny(universe, entries) {
|
|
|
56
57
|
// list keeps its node. A staged entry the allow form cannot express as a membership key (outside the
|
|
57
58
|
// probe universe, or a bare-model/identity spelling) is written verbatim too; canonical keys the
|
|
58
59
|
// session added ride the allow form alone.
|
|
59
|
-
function
|
|
60
|
+
function authoredEntries(doc, path) {
|
|
60
61
|
const node = doc.getIn(path, true);
|
|
61
|
-
|
|
62
|
+
return isSeq(node) ? node.items.flatMap((item) => (isScalar(item) ? [String(item.value)] : [])) : [];
|
|
63
|
+
}
|
|
64
|
+
function flatDenyAfterWrite(doc, path, after, universe) {
|
|
65
|
+
const authored = authoredEntries(doc, path);
|
|
62
66
|
const canonical = (entry) => universe.some((row) => entry === row.adapter || row.models.some((m) => entry === `${row.adapter}:${m}`));
|
|
63
67
|
const kept = authored.filter((entry) => after.includes(entry));
|
|
64
68
|
const verbatim = sortedUnique(after.filter((entry) => !kept.includes(entry)
|
|
@@ -183,9 +187,12 @@ export function renderFleetOverlayWrite(priorBytes, write) {
|
|
|
183
187
|
if (doc.errors.length)
|
|
184
188
|
throw doc.errors[0];
|
|
185
189
|
const { initial, edited } = write;
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
// OBS-1046: each flat scope (and the allow complement) is compared on its own — a scope the
|
|
191
|
+
// session never edited keeps its node byte for byte, whatever its sibling did.
|
|
192
|
+
const changed = (before = [], after = []) => sortedUnique(before).join() !== sortedUnique(after).join();
|
|
193
|
+
const adaptersChanged = changed(initial.denyAdapters, edited.denyAdapters);
|
|
194
|
+
const modelsChanged = changed(initial.denyModels, edited.denyModels);
|
|
195
|
+
if (adaptersChanged || modelsChanged || changed(initial.allowOut, edited.allowOut)) {
|
|
189
196
|
if (write.universe) {
|
|
190
197
|
// Membership write: the allow form IS the fleet; deny adapters/models scopes are tombstoned
|
|
191
198
|
// so a lower layer can never re-exclude behind the operator's back (workers untouched).
|
|
@@ -215,18 +222,44 @@ export function renderFleetOverlayWrite(priorBytes, write) {
|
|
|
215
222
|
// Whole fleet in: no restriction to express — the allow block goes away entirely.
|
|
216
223
|
deleteAt(doc, ["routing", "allow"]);
|
|
217
224
|
}
|
|
218
|
-
// Authored and non-canonical entries stay in deny (LEG2-T3 finding 4, round 2 finding 2)
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
225
|
+
// Authored and non-canonical entries stay in deny (LEG2-T3 finding 4, round 2 finding 2).
|
|
226
|
+
// OBS-1046: only an EDITED scope is rewritten, and only when its bytes must change — an
|
|
227
|
+
// addition the allow form carries leaves the list (an explicit `[]` included) untouched; a
|
|
228
|
+
// scope the press CLEARED down to nothing is tombstoned so a lower layer can never
|
|
229
|
+
// re-exclude behind the operator's back (workers untouched).
|
|
230
|
+
// LEG2-T3 finding 4: an authored entry the edit admits (staged nowhere any more) must go
|
|
231
|
+
// even from a scope whose own set did not change, or the admitted channel stays excluded
|
|
232
|
+
// behind the preserved bytes.
|
|
233
|
+
const stagedAfter = new Set([...edited.denyAdapters, ...edited.denyModels, ...(edited.allowOut ?? [])]);
|
|
234
|
+
const admitted = [...initial.denyAdapters, ...initial.denyModels, ...(initial.allowOut ?? [])]
|
|
235
|
+
.filter((entry) => !stagedAfter.has(entry));
|
|
236
|
+
const scopes = [
|
|
237
|
+
["adapters", initial.denyAdapters, edited.denyAdapters, adaptersChanged],
|
|
238
|
+
["models", initial.denyModels, edited.denyModels, modelsChanged],
|
|
239
|
+
];
|
|
240
|
+
for (const [scope, before, after, touched] of scopes) {
|
|
222
241
|
const path = ["routing", "deny", scope];
|
|
242
|
+
const authored = authoredEntries(doc, path);
|
|
243
|
+
const stale = authored.some((entry) => admitted.includes(entry));
|
|
244
|
+
if (!touched && !stale)
|
|
245
|
+
continue;
|
|
223
246
|
const remaining = flatDenyAfterWrite(doc, path, after, write.universe);
|
|
224
|
-
|
|
247
|
+
if (remaining.length) {
|
|
248
|
+
if (remaining.join("\n") !== authored.join("\n"))
|
|
249
|
+
setStringSequencePreservingComments(doc, path, remaining);
|
|
250
|
+
}
|
|
251
|
+
else if (stale || before.some((entry) => !after.includes(entry))) {
|
|
252
|
+
setStringSequencePreservingComments(doc, path, null);
|
|
253
|
+
}
|
|
225
254
|
}
|
|
226
255
|
}
|
|
227
256
|
else {
|
|
228
|
-
|
|
229
|
-
|
|
257
|
+
if (adaptersChanged) {
|
|
258
|
+
setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
|
|
259
|
+
}
|
|
260
|
+
if (modelsChanged) {
|
|
261
|
+
setStringSequencePreservingComments(doc, ["routing", "deny", "models"], edited.denyModels.length ? sortedUnique(edited.denyModels) : null);
|
|
262
|
+
}
|
|
230
263
|
}
|
|
231
264
|
}
|
|
232
265
|
// OBS-994/FL-1: routing.deny.workers is a literal deny list, never a universe-derived
|
|
@@ -360,15 +393,19 @@ export function renderFleetOverlayWrite(priorBytes, write) {
|
|
|
360
393
|
// operator reviews. commentString has no position context, but this writer owns the document:
|
|
361
394
|
// scalar-trailing single-line comments (the only inline form fleet emits) are marked with a
|
|
362
395
|
// private-use sentinel (the FIRST_TOUCH envelope precedent above), everything else renders
|
|
363
|
-
// byte-identical to yaml's own stringifyComment.
|
|
396
|
+
// byte-identical to yaml's own stringifyComment. OBS-1046: a scalar parsed from the prior bytes
|
|
397
|
+
// keeps the exact whitespace it had before its hash sign (yaml itself emits one space, so the
|
|
398
|
+
// sentinel carries the rest); only a comment fleet authored gets the two-space style.
|
|
364
399
|
visit(doc, (_key, node) => {
|
|
365
400
|
if (isScalar(node) && typeof node.comment === "string" && !node.comment.includes("\n")) {
|
|
366
|
-
node.
|
|
401
|
+
const tail = node.range ? priorBytes.slice(node.range[1], node.range[2]) : "";
|
|
402
|
+
const gap = /^([ \t]+)#/.exec(tail)?.[1] ?? " ";
|
|
403
|
+
node.comment = `${INLINE_COMMENT_SENTINEL}${gap.slice(1)}#${node.comment}`;
|
|
367
404
|
}
|
|
368
405
|
});
|
|
369
406
|
return doc.toString({
|
|
370
407
|
commentString: (comment) => comment.startsWith(INLINE_COMMENT_SENTINEL)
|
|
371
|
-
?
|
|
408
|
+
? comment.slice(1)
|
|
372
409
|
: comment.replace(/^(?!$)(?: $)?/gm, "#"),
|
|
373
410
|
// OBS-518: yaml's default pads flow collections (`[kimi]` → `[ kimi ]`), churning untouched
|
|
374
411
|
// lines on the one confirmation surface an operator reviews. Hand-written overlays use the
|
|
@@ -384,7 +421,8 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, fi
|
|
|
384
421
|
const routing = { ...out.routing };
|
|
385
422
|
let routingTouched = false;
|
|
386
423
|
const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
|
|
387
|
-
|| sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join()
|
|
424
|
+
|| sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join()
|
|
425
|
+
|| sortedUnique(initial.allowOut ?? []).join() !== sortedUnique(edited.allowOut ?? []).join();
|
|
388
426
|
if (denyChanged) {
|
|
389
427
|
if (universe) {
|
|
390
428
|
const form = allowFormFromExclusions(universe, edited);
|
package/dist/drivers/index.js
CHANGED
|
@@ -15,7 +15,8 @@ const hostByDriver = new WeakMap();
|
|
|
15
15
|
export function classifyHost(env = process.env) {
|
|
16
16
|
if (env.HERDR_ENV === "1")
|
|
17
17
|
return "herdr";
|
|
18
|
-
|
|
18
|
+
// A whitespace-only handle is no handle: the narrator trims it and would refuse the split.
|
|
19
|
+
if (env.TERM_PROGRAM === "Orca" && (env.ORCA_TERMINAL_HANDLE ?? "").trim() !== "")
|
|
19
20
|
return "orca";
|
|
20
21
|
return "none";
|
|
21
22
|
}
|
package/dist/drivers/orca.d.ts
CHANGED
|
@@ -124,19 +124,28 @@ export declare function mapAgentState(term: Record<string, unknown>, tuiIdle: bo
|
|
|
124
124
|
* is proven gone. Everything else — a reservation or claim with no bound pane, a failed cleanup, a
|
|
125
125
|
* record another driver holds with a live observer — refuses and keeps the record exactly as it is.
|
|
126
126
|
*/
|
|
127
|
+
/** `placer` is the pid of the narrator that wrote the reservation: a reserved or claimed record is a
|
|
128
|
+
* normal intermediate state while that pid lives (another driver awaiting listing or bind) and a
|
|
129
|
+
* crash to recover only once it is dead. */
|
|
127
130
|
type BoardRecord = WatchBoardOwner & {
|
|
128
131
|
retired?: true;
|
|
129
132
|
runtimeId?: string;
|
|
133
|
+
placer?: number;
|
|
130
134
|
};
|
|
131
135
|
/**
|
|
132
136
|
* Whole-record compare-and-swap. The canonical path stays readable until commit: a mkdir lock
|
|
133
137
|
* excludes other writers, then create-only `link`s the new inode (fails if anything exists) or
|
|
134
138
|
* `rename`s the new file over the live path (POSIX atomic replace — readers see old or new, never
|
|
135
139
|
* absence). A crash that leaves a `.tmp` or `.lock` does not drop the previous record.
|
|
140
|
+
* Each lock generation publishes its pid and nonce atomically in a symlink target. A holder that died
|
|
141
|
+
* between taking and releasing it is recovered at once (its lock is taken over in place); a live
|
|
142
|
+
* holder bounds the wait on injected time and is then refused with the record untouched. A legacy
|
|
143
|
+
* pid-less or malformed generation is recoverable too: a contender atomically creates the next owner
|
|
144
|
+
* generation, and release removes the directory only while that exact generation is still current.
|
|
136
145
|
* ponytail: observeNamedRun (supervision.ts) renames without CAS. It cannot interleave with a swap
|
|
137
146
|
* because the narrator writes nothing between reserve and claim, and no transition here swaps one.
|
|
138
147
|
*/
|
|
139
|
-
export declare function casBoard(family: string, path: string, expected: string | undefined, next: BoardRecord): string
|
|
148
|
+
export declare function casBoard(family: string, path: string, expected: string | undefined, next: BoardRecord, time?: OrcaTimeSource): Promise<string>;
|
|
140
149
|
/**
|
|
141
150
|
* The renderer hard-wraps long lines, paints margin chrome, and a cursor page boundary splits a
|
|
142
151
|
* marker exactly like a wrap does. `parseWorkerResult` (src/adapters/prompt.ts) already de-wraps
|
|
@@ -146,6 +155,8 @@ export declare function casBoard(family: string, path: string, expected: string
|
|
|
146
155
|
* never depends on this.
|
|
147
156
|
*/
|
|
148
157
|
export declare function joinWrapped(raw: string): string;
|
|
158
|
+
/** The OBS-1011 add.1 capture: `status:"exited", tail:[], returnedLineCount 0` from a stream read. */
|
|
159
|
+
export declare function isBlindStreamPage(term: Record<string, unknown>): boolean;
|
|
149
160
|
export interface OrcaDriverOpts {
|
|
150
161
|
bin?: string;
|
|
151
162
|
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
@@ -242,6 +253,15 @@ export declare class OrcaDriver implements ExecutorDriver {
|
|
|
242
253
|
private liveShowTerm;
|
|
243
254
|
private tailText;
|
|
244
255
|
private readPage;
|
|
256
|
+
/**
|
|
257
|
+
* OBS-1011 add.1 / OBS-1016: the captured incident shape — a stream page answering status exited
|
|
258
|
+
* with an empty tail on a terminal that accepted a send seconds earlier — is BLIND, not dead, when
|
|
259
|
+
* the same handle's show record on the same runtime reports connected and not orphaned (show carries
|
|
260
|
+
* no status field; none is demanded) and its screen read reports running. Then the rendered frame is
|
|
261
|
+
* the terminal's bytes. Anything less — disconnected, orphaned, another handle or runtime, a screen
|
|
262
|
+
* that is unavailable or exited — is refused as unavailable, exactly as the dead record would be.
|
|
263
|
+
*/
|
|
264
|
+
private screenBehindBlindStream;
|
|
245
265
|
/** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
|
|
246
266
|
private readScreen;
|
|
247
267
|
/** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split
|