tickmarkr 2.6.6 → 2.6.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/dist/adapters/claude-code.js +8 -2
- package/dist/adapters/codex-commit-check.d.ts +3 -2
- package/dist/adapters/codex-commit-check.js +37 -10
- package/dist/adapters/codex.js +10 -4
- package/dist/cli/commands/beat.d.ts +6 -1
- package/dist/cli/commands/beat.js +123 -76
- package/dist/cli/commands/fleet.js +64 -21
- package/dist/cli/commands/init.js +18 -7
- package/dist/cli/help.d.ts +1 -0
- package/dist/cli/help.js +10 -5
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/gates/baseline.d.ts +9 -0
- package/dist/gates/baseline.js +14 -3
- package/dist/gates/cache.js +1 -1
- package/dist/gates/review.js +15 -0
- package/dist/gates/run-gates.d.ts +18 -6
- package/dist/gates/run-gates.js +324 -204
- package/dist/gates/test-manifest.d.ts +8 -0
- package/dist/gates/test-manifest.js +151 -111
- package/dist/run/beat-lifecycle.d.ts +154 -0
- package/dist/run/beat-lifecycle.js +825 -0
- package/dist/run/daemon.js +18 -7
- package/dist/run/git-trust.d.ts +27 -0
- package/dist/run/git-trust.js +178 -0
- package/dist/run/git.js +34 -14
- package/dist/run/journal.js +15 -3
- package/dist/run/lease.d.ts +23 -0
- package/dist/run/lease.js +232 -38
- package/dist/run/merge.js +2 -2
- package/dist/run/stall.js +20 -1
- package/dist/run/supervision.d.ts +8 -0
- package/dist/run/supervision.js +24 -4
- package/dist/run/verification-job.d.ts +51 -0
- package/dist/run/verification-job.js +137 -0
- package/dist/tui/ink/fleet-app.d.ts +9 -0
- package/dist/tui/ink/fleet-app.js +209 -127
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +19 -10
- package/skills/tickmarkr-loop/SKILL.md +19 -10
- package/skills/tickmarkr-overseer/SKILL.md +25 -16
package/dist/run/lease.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
-
import { execFile } from "node:child_process";
|
|
2
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
3
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";
|
|
4
|
+
import { closeSync, constants, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
-
import { join
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { trustedCommonDir } from "./git-trust.js";
|
|
7
8
|
// The process census and command admission deliberately use the same command-head classifier.
|
|
8
9
|
export function isRunnerCommand(command) {
|
|
9
10
|
// Inspect executable positions only: inference prompts and shell script bodies may name runners.
|
|
@@ -119,14 +120,13 @@ export async function reentrantHolder(path, inherited) {
|
|
|
119
120
|
if (!inherited)
|
|
120
121
|
return undefined;
|
|
121
122
|
const holder = readHolder(path);
|
|
122
|
-
if (holder?.token !== inherited || holder.pid === process.pid || !
|
|
123
|
+
if (holder?.token !== inherited || holder.pid === process.pid || !holderAlive(holder))
|
|
123
124
|
return undefined;
|
|
124
125
|
return (await ancestorPids()).has(holder.pid) ? holder : undefined;
|
|
125
126
|
}
|
|
126
127
|
const LEASE_FILE = "tickmarkr-runner.lease";
|
|
127
128
|
export async function repositoryLeasePath(cwd) {
|
|
128
|
-
|
|
129
|
-
return join(resolve(cwd, commonDir), LEASE_FILE);
|
|
129
|
+
return join(trustedCommonDir(cwd), LEASE_FILE);
|
|
130
130
|
}
|
|
131
131
|
const alive = (pid) => {
|
|
132
132
|
try {
|
|
@@ -303,9 +303,9 @@ const reclaimDeadLocked = (path, identity) => {
|
|
|
303
303
|
};
|
|
304
304
|
/** Exposed for recovery regressions; the standalone verify uses the combined critical section. */
|
|
305
305
|
export const reclaimDead = async (path, identity, pollMs = 10) => withMutationLock(path, pollMs, undefined, () => reclaimDeadLocked(path, identity));
|
|
306
|
-
const inspectAndReserve = (path, mine) => {
|
|
306
|
+
const inspectAndReserve = async (path, mine) => {
|
|
307
307
|
let occupant = readOccupant(path);
|
|
308
|
-
if (occupant && (!occupant.holder || !
|
|
308
|
+
if (occupant && (!occupant.holder || (!holderAlive(occupant.holder) && !(occupant.holder.protectOrphans && (await protectedProcesses(occupant.holder)).length)))) {
|
|
309
309
|
reclaimDeadLocked(path, occupant.identity);
|
|
310
310
|
occupant = readOccupant(path);
|
|
311
311
|
}
|
|
@@ -313,43 +313,237 @@ const inspectAndReserve = (path, mine) => {
|
|
|
313
313
|
return { acquired: true };
|
|
314
314
|
return { acquired: false, holder: occupant?.holder };
|
|
315
315
|
};
|
|
316
|
-
|
|
316
|
+
// A durable capability is also a crash owner: census checks the exact generation's inherited
|
|
317
|
+
// environment and recorded process groups, including live orphans reparented after owner death.
|
|
318
|
+
// An unreadable census refuses reclamation/release. No dead-pid shortcut admits over a live tree.
|
|
319
|
+
export async function protectedProcesses(holder, tokenEnv = REPOSITORY_LEASE_TOKEN_ENV, ownedOnly = false) {
|
|
320
|
+
let censusPid;
|
|
321
|
+
const rows = await new Promise((ok, fail) => {
|
|
322
|
+
const census = execFile("ps", ["eww", "-A", "-o", "pid=,ppid=,pgid=,stat=,command="], { encoding: "utf8", timeout: 15_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => error ? fail(error) : ok(stdout));
|
|
323
|
+
censusPid = census.pid;
|
|
324
|
+
});
|
|
325
|
+
const token = `${tokenEnv}=${holder.token}`;
|
|
326
|
+
const lines = rows.split("\n");
|
|
327
|
+
const safeGroups = new Set((holder.roots ?? []).filter(pid => {
|
|
328
|
+
const leader = lines.some(line => new RegExp(`^\\s*${pid}\\s+\\d+\\s+${pid}\\s+`).test(line));
|
|
329
|
+
// A leaderless group still belongs to its recorded root; a reused leader must match its birth.
|
|
330
|
+
return !leader || sameProcessBirth(processBirth(pid), holder.rootBirths?.[pid]);
|
|
331
|
+
}));
|
|
332
|
+
const parsed = lines.flatMap(line => {
|
|
333
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
334
|
+
return match ? [{ pid: Number(match[1]), parent: Number(match[2]), group: Number(match[3]), stat: match[4], command: match[5] }] : [];
|
|
335
|
+
});
|
|
336
|
+
const parents = new Map(parsed.map(row => [row.pid, row.parent]));
|
|
337
|
+
const descendant = (pid) => {
|
|
338
|
+
const seen = new Set();
|
|
339
|
+
for (let at = parents.get(pid) ?? 0; at > 1 && !seen.has(at); at = parents.get(at) ?? 0) {
|
|
340
|
+
if (at === holder.pid)
|
|
341
|
+
return true;
|
|
342
|
+
seen.add(at);
|
|
343
|
+
}
|
|
344
|
+
return false;
|
|
345
|
+
};
|
|
346
|
+
return parsed.flatMap(row => {
|
|
347
|
+
if (row.stat.startsWith("Z") || row.pid === holder.pid || row.pid === censusPid)
|
|
348
|
+
return [];
|
|
349
|
+
const group = safeGroups.has(row.group);
|
|
350
|
+
const capability = row.command.split(/\s+/).includes(token);
|
|
351
|
+
// Copied environment text outside the owned tree can conservatively block reclamation, but
|
|
352
|
+
// can never authorize signalling an unrelated process. Birth-checked groups survive reparenting.
|
|
353
|
+
return group || (capability && (!ownedOnly || descendant(row.pid))) ? [row.pid] : [];
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
const repositoryOwnership = new AsyncLocalStorage();
|
|
357
|
+
export const repositoryLeaseEnvironment = (env) => {
|
|
358
|
+
const own = repositoryOwnership.getStore();
|
|
359
|
+
if (!own)
|
|
360
|
+
return { ...env };
|
|
361
|
+
const child = { ...env };
|
|
362
|
+
if (own.active)
|
|
363
|
+
child[REPOSITORY_LEASE_TOKEN_ENV] = own.holder.token;
|
|
364
|
+
else
|
|
365
|
+
delete child[REPOSITORY_LEASE_TOKEN_ENV];
|
|
366
|
+
return child;
|
|
367
|
+
};
|
|
368
|
+
/** Called synchronously at spawn, before any user callback, and only on an owned generation. */
|
|
369
|
+
export function registerRepositoryChild(pid) {
|
|
370
|
+
const own = repositoryOwnership.getStore();
|
|
371
|
+
if (!own?.active || !own.holder.protectOrphans || pid === undefined)
|
|
372
|
+
return;
|
|
373
|
+
if (readHolder(own.path)?.token !== own.holder.token)
|
|
374
|
+
throw new Error("verification reservation superseded before child registration");
|
|
375
|
+
own.holder.roots = [...new Set([...(readHolder(own.path)?.roots ?? []), pid])];
|
|
376
|
+
const birth = processBirth(pid);
|
|
377
|
+
own.holder.rootBirths = { ...readHolder(own.path)?.rootBirths, ...(birth ? { [pid]: birth } : {}) };
|
|
378
|
+
const draft = `${own.path}.${own.holder.token}.${process.pid}.roots`;
|
|
379
|
+
try {
|
|
380
|
+
writeFileSync(draft, JSON.stringify(own.holder) + "\n");
|
|
381
|
+
renameSync(draft, own.path);
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
rmSync(draft, { force: true });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
export const withFreshCommandLease = (command, run) => ownership.run({ token: "", active: false }, () => withCommandLease(command, run));
|
|
388
|
+
const repositoryQueues = new Map();
|
|
389
|
+
/** The standalone wrapper uses the same command-then-repository order when a scheduler exists.
|
|
390
|
+
* Without one (CLI/global setup), this remains exactly the outer file reservation API. */
|
|
391
|
+
export function withRepositoryLease(cwd, run, opts = {}) {
|
|
392
|
+
return opts.isolated ? repositoryLease(cwd, run, opts)
|
|
393
|
+
: withCommandLease("vitest run", () => repositoryLease(cwd, run, opts));
|
|
394
|
+
}
|
|
395
|
+
export const hasRepositoryLeaseOwnership = () => repositoryOwnership.getStore()?.active === true;
|
|
396
|
+
async function repositoryLease(cwd, run, opts) {
|
|
317
397
|
const path = await repositoryLeasePath(cwd);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
398
|
+
const own = repositoryOwnership.getStore();
|
|
399
|
+
// Only the private async capability can reenter within this process. Environment text cannot.
|
|
400
|
+
if (!opts.independent && own?.active && own.path === path && readHolder(path)?.token === own.holder.token) {
|
|
401
|
+
opts.onAdmission?.(own.holder);
|
|
402
|
+
const value = await run();
|
|
403
|
+
opts.onRelease?.(); // this caller's span ends; the outer reservation stays owned
|
|
404
|
+
return value;
|
|
405
|
+
}
|
|
406
|
+
if (await reentrantHolder(path, opts.inherited ?? process.env[REPOSITORY_LEASE_TOKEN_ENV])) {
|
|
407
|
+
const holder = readHolder(path);
|
|
408
|
+
const inheritedOwner = { path, holder, active: true };
|
|
409
|
+
return repositoryOwnership.run(inheritedOwner, async () => {
|
|
410
|
+
opts.onAdmission?.(holder);
|
|
411
|
+
try {
|
|
412
|
+
const value = await run();
|
|
413
|
+
opts.onRelease?.();
|
|
414
|
+
return value;
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
inheritedOwner.active = false;
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
321
421
|
const pollMs = opts.pollMs ?? 1_000;
|
|
322
|
-
const mine = { pid: process.pid, cwd, at: Date.now(), token: randomUUID()
|
|
422
|
+
const mine = { pid: process.pid, cwd, at: Date.now(), token: randomUUID(),
|
|
423
|
+
...(opts.protectOrphans !== false ? { protectOrphans: true, roots: [], identity: ownerIdentity() } : {}) };
|
|
424
|
+
const queue = repositoryQueues.get(path) ?? new Set();
|
|
425
|
+
repositoryQueues.set(path, queue);
|
|
426
|
+
const ticket = Symbol("repository job");
|
|
427
|
+
queue.add(ticket);
|
|
323
428
|
let waitingOn;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
break;
|
|
329
|
-
if (attempt.holder && attempt.holder.pid !== waitingOn) {
|
|
330
|
-
waitingOn = attempt.holder.pid;
|
|
331
|
-
opts.onWait?.(attempt.holder);
|
|
429
|
+
const waitOn = (holder) => {
|
|
430
|
+
if (holder && holder.token !== waitingOn) {
|
|
431
|
+
waitingOn = holder.token;
|
|
432
|
+
opts.onWait?.(holder);
|
|
332
433
|
}
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
// Exported before run() so every descendant forked or spawned during the hold can reenter; restored
|
|
336
|
-
// at release unless another reservation in this process has since exported its own.
|
|
337
|
-
const exported = process.env[REPOSITORY_LEASE_TOKEN_ENV];
|
|
338
|
-
process.env[REPOSITORY_LEASE_TOKEN_ENV] = mine.token;
|
|
434
|
+
};
|
|
339
435
|
try {
|
|
340
|
-
|
|
436
|
+
for (;;) {
|
|
437
|
+
opts.signal?.throwIfAborted();
|
|
438
|
+
if (queue.values().next().value === ticket) {
|
|
439
|
+
const attempt = await withMutationLock(path, pollMs, opts.signal, () => inspectAndReserve(path, mine));
|
|
440
|
+
if (attempt.acquired)
|
|
441
|
+
break;
|
|
442
|
+
waitOn(attempt.holder);
|
|
443
|
+
}
|
|
444
|
+
else
|
|
445
|
+
waitOn(readHolder(path));
|
|
446
|
+
await new Promise(wake => setTimeout(wake, pollMs));
|
|
447
|
+
}
|
|
448
|
+
const exported = process.env[REPOSITORY_LEASE_TOKEN_ENV];
|
|
449
|
+
if (!opts.isolated)
|
|
450
|
+
process.env[REPOSITORY_LEASE_TOKEN_ENV] = mine.token;
|
|
451
|
+
const owner = { path, holder: mine, active: true };
|
|
452
|
+
try {
|
|
453
|
+
return await repositoryOwnership.run(owner, async () => {
|
|
454
|
+
opts.signal?.throwIfAborted();
|
|
455
|
+
opts.onAdmission?.(mine);
|
|
456
|
+
return run();
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
owner.active = false;
|
|
461
|
+
if (!opts.isolated && process.env[REPOSITORY_LEASE_TOKEN_ENV] === mine.token) {
|
|
462
|
+
if (exported === undefined)
|
|
463
|
+
delete process.env[REPOSITORY_LEASE_TOKEN_ENV];
|
|
464
|
+
else
|
|
465
|
+
process.env[REPOSITORY_LEASE_TOKEN_ENV] = exported;
|
|
466
|
+
}
|
|
467
|
+
// All token-bound children, including escaped descendants, cease before release. Shell has
|
|
468
|
+
// already reaped its group; this closes the inherited-pipe/escaped-child boundary as well.
|
|
469
|
+
if (mine.protectOrphans)
|
|
470
|
+
await reapRepositoryChildren(mine, pollMs).catch(async (error) => {
|
|
471
|
+
// A failed census or reap deadline must not leave a settled job naming a live owner
|
|
472
|
+
// forever. Retain the exact generation and its roots for the orphan rule: admission
|
|
473
|
+
// still requires a successful census proving the protected tree has ceased.
|
|
474
|
+
await withMutationLock(path, pollMs, undefined, () => {
|
|
475
|
+
const holder = readHolder(path);
|
|
476
|
+
if (holder?.token !== mine.token)
|
|
477
|
+
return;
|
|
478
|
+
const draft = `${path}.${mine.token}.${process.pid}.released`;
|
|
479
|
+
try {
|
|
480
|
+
writeFileSync(draft, JSON.stringify({ ...holder, ownerReleased: true }) + "\n");
|
|
481
|
+
renameSync(draft, path);
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
rmSync(draft, { force: true });
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
throw error;
|
|
488
|
+
});
|
|
489
|
+
await withMutationLock(path, pollMs, undefined, () => {
|
|
490
|
+
if (readHolder(path)?.token === mine.token)
|
|
491
|
+
unlinkSync(path);
|
|
492
|
+
});
|
|
493
|
+
opts.onRelease?.();
|
|
494
|
+
}
|
|
341
495
|
}
|
|
342
496
|
finally {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
497
|
+
queue.delete(ticket);
|
|
498
|
+
if (!queue.size)
|
|
499
|
+
repositoryQueues.delete(path);
|
|
500
|
+
// A waiter removes no repository generation, including cancellation before acquisition.
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function processBirth(pid) {
|
|
504
|
+
try {
|
|
505
|
+
if (process.platform === "linux") {
|
|
506
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
507
|
+
return stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[19];
|
|
348
508
|
}
|
|
349
|
-
|
|
350
|
-
await withMutationLock(path, pollMs, undefined, () => {
|
|
351
|
-
if (readHolder(path)?.token === mine.token)
|
|
352
|
-
unlinkSync(path);
|
|
353
|
-
});
|
|
509
|
+
return execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
354
510
|
}
|
|
511
|
+
catch {
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function ownerIdentity() {
|
|
516
|
+
const birth = processBirth(process.pid);
|
|
517
|
+
// Match git.ts's independent identity format without changing recorded raw root births.
|
|
518
|
+
return birth ? `${process.pid}:${canonicalBirth(birth)}` : undefined;
|
|
519
|
+
}
|
|
520
|
+
const canonicalBirth = (birth) => birth.trim().replace(/\s+/g, " ");
|
|
521
|
+
const sameProcessBirth = (actual, expected) => actual !== undefined && expected !== undefined && canonicalBirth(actual) === canonicalBirth(expected);
|
|
522
|
+
export async function reapRepositoryChildren(holder, pollMs, tokenEnv = REPOSITORY_LEASE_TOKEN_ENV) {
|
|
523
|
+
const deadline = Date.now() + 15_000;
|
|
524
|
+
for (const pid of await protectedProcesses(holder, tokenEnv, true)) {
|
|
525
|
+
try {
|
|
526
|
+
process.kill(pid, "SIGKILL");
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
if (!isCode(error, "ESRCH"))
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
while ((await protectedProcesses(holder, tokenEnv)).length) {
|
|
534
|
+
if (Date.now() >= deadline)
|
|
535
|
+
throw new Error("verification children did not cease; reservation retained");
|
|
536
|
+
await new Promise(wake => setTimeout(wake, pollMs));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function holderAlive(holder) {
|
|
540
|
+
if (holder.ownerReleased === true)
|
|
541
|
+
return false;
|
|
542
|
+
if (!alive(holder.pid))
|
|
543
|
+
return false;
|
|
544
|
+
if (!holder.identity)
|
|
545
|
+
return true; // legacy live holder remains protected
|
|
546
|
+
const birth = processBirth(holder.pid);
|
|
547
|
+
// Older live generations may contain ps's space-padded day. Formatting cannot prove owner death.
|
|
548
|
+
return birth === undefined || sameProcessBirth(`${holder.pid}:${birth}`, holder.identity);
|
|
355
549
|
}
|
package/dist/run/merge.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { shq } from "../adapters/types.js";
|
|
4
|
-
import { beginGateEvidence, ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, fingerprint, freshFailures, } from "../gates/baseline.js";
|
|
4
|
+
import { beginGateEvidence, ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, classifiedFileDurations, fingerprint, freshFailures, } from "../gates/baseline.js";
|
|
5
5
|
import { evaluateManifestedTest, isVitestTestCommand } from "../gates/test-manifest.js";
|
|
6
6
|
import { computeVerificationIdentity, formatReusedDetails, getVerdictStore, getWorktreeTree, resolveStateDir, isInfraResult, } from "../gates/cache.js";
|
|
7
7
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
@@ -248,7 +248,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline, ev
|
|
|
248
248
|
const manifestedEvidence = { ...evidenceSetup, artifactDir: evidence.root };
|
|
249
249
|
evidenceRoots.set(gate, evidence.root);
|
|
250
250
|
const outcome = await evaluateManifestedTest(cmd, intWt, {
|
|
251
|
-
baselineDurations: entry
|
|
251
|
+
baselineDurations: classifiedFileDurations(entry),
|
|
252
252
|
longestFile: entry?.longestFile,
|
|
253
253
|
overallCeilingMs: effectiveCeilingMs(entry),
|
|
254
254
|
artifactDir: runDir,
|
package/dist/run/stall.js
CHANGED
|
@@ -286,7 +286,26 @@ async function reapWorkerProcesses(group, cwd, ownership) {
|
|
|
286
286
|
// OBS-1173 add.2: a FAILED recheck (null) is unknown too — it never reads as gone.
|
|
287
287
|
if (!details) {
|
|
288
288
|
const recheck = await workerReapHost.identity(row.pid);
|
|
289
|
-
|
|
289
|
+
if (recheck === null)
|
|
290
|
+
return "unknown";
|
|
291
|
+
if (recheck !== row.identity)
|
|
292
|
+
return "spared";
|
|
293
|
+
// An exiting child loses its cwd before it is reaped yet keeps its birth identity (a zombie, or
|
|
294
|
+
// on its way to one). The snapshot drops Z rows, so a fresh snapshot that no longer lists this
|
|
295
|
+
// pid and identity says it has finished; a still-listed live row with no readable cwd stays
|
|
296
|
+
// unknown, and so does an unreadable snapshot (public CI 37302193795, D-1263). A process still
|
|
297
|
+
// exiting can stay listed for a moment after its cwd is gone, so read up to three fresh
|
|
298
|
+
// snapshots 100 ms apart before calling it unknown (public CI 37324617042, D-1278).
|
|
299
|
+
for (let read = 0; read < 3; read++) {
|
|
300
|
+
if (read)
|
|
301
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
302
|
+
const fresh = await workerReapHost.snapshot();
|
|
303
|
+
if (!fresh)
|
|
304
|
+
return "unknown";
|
|
305
|
+
if (!fresh.some((r) => r.pid === row.pid && r.identity === row.identity))
|
|
306
|
+
return "spared";
|
|
307
|
+
}
|
|
308
|
+
return "unknown";
|
|
290
309
|
}
|
|
291
310
|
if (details.suiteParent === process.pid || excludedPaths.some((path) => below(path, details.cwd)))
|
|
292
311
|
return "spared";
|
|
@@ -13,6 +13,11 @@ export type SupervisionTier = (typeof SUPERVISION_TIERS)[number];
|
|
|
13
13
|
* to create fresh state there or to wander into an enclosing repository. This function is read-only.
|
|
14
14
|
*/
|
|
15
15
|
export declare function resolveSupervisionRoot(cwd: string): string;
|
|
16
|
+
/** Read-only twin for readers: a repository without a state dir is a root with no state, not a refusal. */
|
|
17
|
+
export declare function locateSupervisionRoot(cwd: string): {
|
|
18
|
+
root: string;
|
|
19
|
+
state: boolean;
|
|
20
|
+
};
|
|
16
21
|
export declare const SUPERVISION_SEAT_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context"];
|
|
17
22
|
export type SeatTier = (typeof SUPERVISION_SEAT_TIERS)[number];
|
|
18
23
|
/** Does this tier's record have to name the seat it speaks for? */
|
|
@@ -46,8 +51,11 @@ export interface SupervisionArm {
|
|
|
46
51
|
markerFence: string;
|
|
47
52
|
}
|
|
48
53
|
export declare const supervisionArmPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
54
|
+
/** The durable arm on disk, undefined when none; a torn or invalid arm throws rather than reading as none. */
|
|
55
|
+
export declare function readSupervisionArm(repoRoot: string, tier: SupervisionTier): SupervisionArm | undefined;
|
|
49
56
|
/** Explicit arming acknowledges the current marker without deleting it. */
|
|
50
57
|
export declare function newSupervisionArm(repoRoot: string, tier: SupervisionTier, armId?: string, initial?: boolean): SupervisionArm;
|
|
58
|
+
export declare function publishStandDown(repoRoot: string, tier: SupervisionTier, seat: string): void;
|
|
51
59
|
export declare const supervisionPresencePath: (repoRoot: string, tier: SupervisionTier, id: string) => string;
|
|
52
60
|
export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string, observation?: SupervisionBeatObservation, options?: {
|
|
53
61
|
arm?: SupervisionArm;
|
package/dist/run/supervision.js
CHANGED
|
@@ -56,13 +56,19 @@ const pathKind = (path) => {
|
|
|
56
56
|
* to create fresh state there or to wander into an enclosing repository. This function is read-only.
|
|
57
57
|
*/
|
|
58
58
|
export function resolveSupervisionRoot(cwd) {
|
|
59
|
+
const { root, state } = locateSupervisionRoot(cwd);
|
|
60
|
+
if (!state)
|
|
61
|
+
throw new Error(`missing ${stateDirName(root)}/ state dir at repository root ${root}`);
|
|
62
|
+
return root;
|
|
63
|
+
}
|
|
64
|
+
/** Read-only twin for readers: a repository without a state dir is a root with no state, not a refusal. */
|
|
65
|
+
export function locateSupervisionRoot(cwd) {
|
|
59
66
|
let current = resolve(cwd);
|
|
60
67
|
for (;;) {
|
|
61
68
|
if (pathKind(join(current, stateDirName(current))) === "directory")
|
|
62
|
-
return current;
|
|
63
|
-
if (pathKind(join(current, ".git")) !== "absent")
|
|
64
|
-
|
|
65
|
-
}
|
|
69
|
+
return { root: current, state: true };
|
|
70
|
+
if (pathKind(join(current, ".git")) !== "absent")
|
|
71
|
+
return { root: current, state: false };
|
|
66
72
|
const parent = dirname(current);
|
|
67
73
|
if (parent === current) {
|
|
68
74
|
throw new Error(`missing ${stateDirName(current)}/ state dir — no tickmarkr repository found from ${resolve(cwd)}`);
|
|
@@ -87,6 +93,10 @@ export const supervisionBeatPath = (repoRoot, tier) => join(supervisionDir(repoR
|
|
|
87
93
|
/** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
|
|
88
94
|
export const supervisionStandDownPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.standdown`);
|
|
89
95
|
export const supervisionArmPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.arm`);
|
|
96
|
+
/** The durable arm on disk, undefined when none; a torn or invalid arm throws rather than reading as none. */
|
|
97
|
+
export function readSupervisionArm(repoRoot, tier) {
|
|
98
|
+
return readArm(repoRoot, tier);
|
|
99
|
+
}
|
|
90
100
|
function readArm(repoRoot, tier) {
|
|
91
101
|
try {
|
|
92
102
|
const arm = JSON.parse(readFileSync(supervisionArmPath(repoRoot, tier), "utf8"));
|
|
@@ -143,6 +153,16 @@ export function newSupervisionArm(repoRoot, tier, armId = randomUUID(), initial
|
|
|
143
153
|
}
|
|
144
154
|
return readArm(repoRoot, tier);
|
|
145
155
|
}
|
|
156
|
+
// Stand-down is a RECORDED act, not a silence: the marker tells a reader this watcher left on purpose,
|
|
157
|
+
// so the tier reads DISARMED rather than ageing out as a death. Published atomically — written aside,
|
|
158
|
+
// renamed over — because a torn marker is rejected by the reader, and a rejected stand-down reports a
|
|
159
|
+
// deliberate hand-off as a death. The marker names the seat for the same reason the beat does.
|
|
160
|
+
export function publishStandDown(repoRoot, tier, seat) {
|
|
161
|
+
tickmarkrDir(repoRoot); // the write path DOES create — markers land inside the gitignored state dir
|
|
162
|
+
atomicRecord(supervisionStandDownPath(repoRoot, tier), {
|
|
163
|
+
tier, seat, standDownId: randomUUID(), exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
146
166
|
/** The independent latch a stand-down cannot overwrite or remove. */
|
|
147
167
|
const supervisionObligationPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.clear-owed`);
|
|
148
168
|
// SUP-06: PRESENCE — one file per ARMED WATCHER, because a tier may legitimately have more than one.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ShellReceipt } from "./protocol.js";
|
|
2
|
+
import type { GateEvidenceOptions } from "../gates/baseline.js";
|
|
3
|
+
import { type RepositoryLeaseHolder } from "./lease.js";
|
|
4
|
+
export interface VerificationPhase {
|
|
5
|
+
phase: "discovery" | "first-pass" | "continuation-discovery" | "continuation";
|
|
6
|
+
nonce: string;
|
|
7
|
+
reportPath: string;
|
|
8
|
+
parentNonce?: string;
|
|
9
|
+
startedAt?: number;
|
|
10
|
+
endedAt?: number;
|
|
11
|
+
pid?: number;
|
|
12
|
+
outcome?: ShellReceipt["outcome"];
|
|
13
|
+
state: "pending" | "running" | "completed" | "cancelled" | "failed";
|
|
14
|
+
}
|
|
15
|
+
export interface VerificationJobReport {
|
|
16
|
+
version: 1;
|
|
17
|
+
id: string;
|
|
18
|
+
command: string;
|
|
19
|
+
cwd: string;
|
|
20
|
+
subject: {
|
|
21
|
+
commit: string | null;
|
|
22
|
+
runId: string;
|
|
23
|
+
taskId: string | null;
|
|
24
|
+
attempt: number | null;
|
|
25
|
+
};
|
|
26
|
+
scope: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
queuedAt: number;
|
|
29
|
+
repositoryAdmittedAt?: number;
|
|
30
|
+
admittedAt?: number;
|
|
31
|
+
reapedAt?: number;
|
|
32
|
+
repositoryReleasedAt?: number;
|
|
33
|
+
releasedAt?: number;
|
|
34
|
+
reservationReused?: boolean;
|
|
35
|
+
reservation?: RepositoryLeaseHolder;
|
|
36
|
+
waitingOn?: RepositoryLeaseHolder;
|
|
37
|
+
state: "queued" | "running" | "completed" | "failed" | "cancelled";
|
|
38
|
+
phases: VerificationPhase[];
|
|
39
|
+
}
|
|
40
|
+
export declare const VERIFICATION_JOB_TOKEN_ENV = "TICKMARKR_VERIFICATION_JOB_TOKEN";
|
|
41
|
+
export declare function verificationJobEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
42
|
+
export declare const setVerificationJobClockForTests: (now: () => number) => void;
|
|
43
|
+
export declare const resetVerificationJobClockForTests: () => void;
|
|
44
|
+
export declare function observeVerificationSpawn(pid: number | undefined): void;
|
|
45
|
+
export declare function observeVerificationReceipt(receipt: ShellReceipt): void;
|
|
46
|
+
export declare function verificationPhase<T>(phase: VerificationPhase["phase"], nonce: string, reportPath: string, run: () => Promise<T>, parentNonce?: string): Promise<T>;
|
|
47
|
+
export declare function withVerificationJob<T extends {
|
|
48
|
+
pass: boolean;
|
|
49
|
+
meta: Record<string, unknown>;
|
|
50
|
+
reportPath?: string;
|
|
51
|
+
}>(command: string, cwd: string, reportPath: string, evidence: GateEvidenceOptions | undefined, run: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { assertGitTrust, protectedGitEnv } from "./git-trust.js";
|
|
6
|
+
import { executionSignal } from "./execution-budget.js";
|
|
7
|
+
import { hasRepositoryLeaseOwnership, REPOSITORY_LEASE_TOKEN_ENV, reentrantHolder, repositoryLeasePath, reapRepositoryChildren, withCommandLease, withFreshCommandLease, withRepositoryLease } from "./lease.js";
|
|
8
|
+
const jobs = new AsyncLocalStorage();
|
|
9
|
+
export const VERIFICATION_JOB_TOKEN_ENV = "TICKMARKR_VERIFICATION_JOB_TOKEN";
|
|
10
|
+
export function verificationJobEnvironment(env) {
|
|
11
|
+
const job = jobs.getStore();
|
|
12
|
+
return job ? { ...env, [VERIFICATION_JOB_TOKEN_ENV]: job.report.id } : { ...env };
|
|
13
|
+
}
|
|
14
|
+
let clock = () => Date.now();
|
|
15
|
+
export const setVerificationJobClockForTests = (now) => { clock = now; };
|
|
16
|
+
export const resetVerificationJobClockForTests = () => { clock = () => Date.now(); };
|
|
17
|
+
export function observeVerificationSpawn(pid) {
|
|
18
|
+
const job = jobs.getStore();
|
|
19
|
+
if (!job?.phase)
|
|
20
|
+
return;
|
|
21
|
+
job.phase.startedAt ??= clock();
|
|
22
|
+
job.phase.pid = pid;
|
|
23
|
+
job.phase.state = "running";
|
|
24
|
+
job.persist();
|
|
25
|
+
}
|
|
26
|
+
export function observeVerificationReceipt(receipt) {
|
|
27
|
+
const job = jobs.getStore();
|
|
28
|
+
if (!job?.phase)
|
|
29
|
+
return;
|
|
30
|
+
job.phase.outcome = receipt.outcome;
|
|
31
|
+
job.persist();
|
|
32
|
+
}
|
|
33
|
+
export async function verificationPhase(phase, nonce, reportPath, run, parentNonce) {
|
|
34
|
+
const job = jobs.getStore();
|
|
35
|
+
if (!job)
|
|
36
|
+
return run();
|
|
37
|
+
const span = { phase, nonce, reportPath, state: "pending", ...(parentNonce ? { parentNonce } : {}) };
|
|
38
|
+
job.report.phases.push(span);
|
|
39
|
+
job.phase = span;
|
|
40
|
+
job.persist();
|
|
41
|
+
try {
|
|
42
|
+
const value = await run();
|
|
43
|
+
span.state = executionSignalSafely()?.aborted || span.outcome === "cancelled" ? "cancelled" : span.outcome === "timed-out" || span.outcome === "spawn-failed" ? "failed" : "completed";
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
span.state = executionSignalSafely()?.aborted ? "cancelled" : "failed";
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
span.endedAt = clock();
|
|
52
|
+
job.phase = undefined;
|
|
53
|
+
job.persist();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// executionSignal() checks the budget and can throw during cleanup; the initial signal is retained.
|
|
57
|
+
const signals = new AsyncLocalStorage();
|
|
58
|
+
const executionSignalSafely = () => signals.getStore();
|
|
59
|
+
export async function withVerificationJob(command, cwd, reportPath, evidence, run) {
|
|
60
|
+
const signal = executionSignal();
|
|
61
|
+
let commit = evidence?.subjectCommit ?? null;
|
|
62
|
+
if (!commit) {
|
|
63
|
+
try {
|
|
64
|
+
assertGitTrust(cwd);
|
|
65
|
+
commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd, env: protectedGitEnv(process.env), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
66
|
+
}
|
|
67
|
+
catch { /* unknown remains explicit */ }
|
|
68
|
+
}
|
|
69
|
+
const report = { version: 1, id: randomUUID(), command, cwd,
|
|
70
|
+
subject: { commit, runId: evidence?.runId ?? "standalone", taskId: evidence?.taskId ?? null, attempt: evidence?.attempt ?? null },
|
|
71
|
+
scope: command, reason: "manifest verification", queuedAt: clock(), state: "queued", phases: [] };
|
|
72
|
+
const path = `${reportPath}.job.json`;
|
|
73
|
+
const job = { report, path, persist: () => {
|
|
74
|
+
// Timing persistence is observational, just like gate receipts; failure cannot change proof.
|
|
75
|
+
const draft = `${path}.${report.id}.draft`;
|
|
76
|
+
try {
|
|
77
|
+
writeFileSync(draft, JSON.stringify(report, null, 2) + "\n");
|
|
78
|
+
renameSync(draft, path);
|
|
79
|
+
}
|
|
80
|
+
catch { /* report proof remains authoritative */ }
|
|
81
|
+
finally {
|
|
82
|
+
try {
|
|
83
|
+
rmSync(draft, { force: true });
|
|
84
|
+
}
|
|
85
|
+
catch { /* observational */ }
|
|
86
|
+
}
|
|
87
|
+
} };
|
|
88
|
+
job.persist();
|
|
89
|
+
let outcome;
|
|
90
|
+
try {
|
|
91
|
+
report.reservationReused = hasRepositoryLeaseOwnership() && !jobs.getStore();
|
|
92
|
+
// A process descendant may share its ancestor's scheduler capability only after the file
|
|
93
|
+
// generation and strict ancestry have been verified. A same-pid sibling always queues fresh.
|
|
94
|
+
const inherited = !hasRepositoryLeaseOwnership()
|
|
95
|
+
&& await reentrantHolder(await repositoryLeasePath(cwd), process.env[REPOSITORY_LEASE_TOKEN_ENV]);
|
|
96
|
+
const reserveCommand = report.reservationReused || inherited ? withCommandLease : withFreshCommandLease;
|
|
97
|
+
outcome = await reserveCommand(command, () => withRepositoryLease(cwd, () => {
|
|
98
|
+
signal?.throwIfAborted();
|
|
99
|
+
report.admittedAt = clock();
|
|
100
|
+
report.state = "running";
|
|
101
|
+
job.persist();
|
|
102
|
+
return signals.run(signal, () => jobs.run(job, async () => {
|
|
103
|
+
try {
|
|
104
|
+
return await run();
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await reapRepositoryChildren({ pid: process.pid, cwd, at: report.queuedAt, token: report.id }, 20, VERIFICATION_JOB_TOKEN_ENV);
|
|
108
|
+
report.reapedAt = clock();
|
|
109
|
+
job.persist();
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
112
|
+
}, { isolated: true, protectOrphans: true, independent: !!jobs.getStore(), signal, pollMs: 20,
|
|
113
|
+
onWait: holder => { report.waitingOn = { ...holder }; job.persist(); },
|
|
114
|
+
onAdmission: holder => { report.repositoryAdmittedAt = clock(); report.reservation = { ...holder }; job.persist(); },
|
|
115
|
+
onRelease: () => { report.reapedAt ??= clock(); report.repositoryReleasedAt = clock(); job.persist(); } }));
|
|
116
|
+
report.releasedAt = clock();
|
|
117
|
+
report.state = signal?.aborted ? "cancelled" : outcome.pass ? "completed" : "failed";
|
|
118
|
+
outcome.meta = { ...outcome.meta, verificationJobPath: path, verificationJob: report };
|
|
119
|
+
// Existing tip callers retain only reportPath: its sidecar is the additive producer envelope.
|
|
120
|
+
if (outcome.reportPath && `${outcome.reportPath}.job.json` !== path) {
|
|
121
|
+
try {
|
|
122
|
+
writeFileSync(`${outcome.reportPath}.job.json`, JSON.stringify(report, null, 2) + "\n");
|
|
123
|
+
}
|
|
124
|
+
catch { /* observational */ }
|
|
125
|
+
}
|
|
126
|
+
return outcome;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (report.repositoryReleasedAt !== undefined)
|
|
130
|
+
report.releasedAt = clock();
|
|
131
|
+
report.state = signal?.aborted ? "cancelled" : "failed";
|
|
132
|
+
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { verificationJobPath: path, verificationJob: report });
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
job.persist();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -95,6 +95,13 @@ export type FleetStagedDeny = Record<StagedDenyKey, string[]> & {
|
|
|
95
95
|
};
|
|
96
96
|
/** every scope's staged list, empty — the "every policy scope open" probe the picker lifts against */
|
|
97
97
|
export declare const openStagedDeny: () => FleetStagedDeny;
|
|
98
|
+
/** T5: one real channel a displayed row stands for — a collapsed variant or catalog-fold constituent doctor
|
|
99
|
+
* served, with its own recorded identity and failed-probe reason */
|
|
100
|
+
export type FleetGroupMember = {
|
|
101
|
+
model: string;
|
|
102
|
+
identity?: string;
|
|
103
|
+
unauthed?: string;
|
|
104
|
+
};
|
|
98
105
|
export type FleetModelGroup = {
|
|
99
106
|
adapter: string;
|
|
100
107
|
vendor?: string;
|
|
@@ -109,6 +116,8 @@ export type FleetModelGroup = {
|
|
|
109
116
|
classifyModel?: string;
|
|
110
117
|
variants?: string[];
|
|
111
118
|
foldedModels?: string[];
|
|
119
|
+
/** T5: every real member of a grouped row; absent ⇒ the row's own model is its one member */
|
|
120
|
+
members?: FleetGroupMember[];
|
|
112
121
|
score?: number;
|
|
113
122
|
}>;
|
|
114
123
|
};
|