tickmarkr 2.6.7 → 2.6.9
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/catalog-remote.js +5 -2
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.js +18 -10
- package/dist/adapters/model-lints.js +21 -11
- package/dist/adapters/model-windows.js +0 -1
- package/dist/adapters/registry.js +7 -3
- package/dist/adapters/types.d.ts +2 -0
- package/dist/cli/commands/beat.d.ts +3 -3
- package/dist/cli/commands/beat.js +39 -19
- package/dist/cli/commands/doctor.js +3 -0
- package/dist/cli/commands/fleet.js +2 -0
- package/dist/cli/commands/plan.js +16 -18
- package/dist/cli/commands/verify.js +4 -2
- package/dist/cli/help.js +5 -2
- package/dist/config/config.js +6 -5
- package/dist/drivers/herdr.js +2 -2
- package/dist/drivers/orca.d.ts +12 -4
- package/dist/drivers/orca.js +200 -39
- package/dist/gates/evidence.js +3 -3
- package/dist/gates/review.d.ts +1 -1
- package/dist/gates/review.js +62 -13
- package/dist/gates/run-gates.d.ts +3 -0
- package/dist/gates/run-gates.js +115 -20
- package/dist/gates/test-manifest.d.ts +36 -2
- package/dist/gates/test-manifest.js +55 -10
- package/dist/run/beat-lifecycle.d.ts +109 -24
- package/dist/run/beat-lifecycle.js +1173 -216
- package/dist/run/daemon.d.ts +4 -0
- package/dist/run/daemon.js +111 -33
- package/dist/run/git-trust.d.ts +78 -3
- package/dist/run/git-trust.js +175 -19
- package/dist/run/git.d.ts +24 -3
- package/dist/run/git.js +140 -21
- package/dist/run/merge.js +2 -2
- package/dist/run/verification-job.d.ts +2 -0
- package/dist/run/verification-job.js +32 -10
- package/dist/tui/ink/fleet-app.d.ts +7 -2
- package/dist/tui/ink/fleet-app.js +81 -23
- package/package.json +4 -1
- package/skills/tickmarkr-auto/SKILL.md +58 -14
- package/skills/tickmarkr-loop/SKILL.md +65 -14
- package/skills/tickmarkr-overseer/SKILL.md +97 -28
- package/skills/tickmarkr-overseer/scripts/watch-journal.sh +9 -7
package/dist/gates/run-gates.js
CHANGED
|
@@ -9,7 +9,8 @@ import { GATE_NAMES } from "../graph/schema.js";
|
|
|
9
9
|
import { acceptanceGate } from "./acceptance.js";
|
|
10
10
|
import { classifiedFileDurations, compareToBaseline, effectiveCeilingMs, waitForCalmWindow, calmWindowReady } from "./baseline.js";
|
|
11
11
|
import { evidenceGate } from "./evidence.js";
|
|
12
|
-
import { captureLlmOutput } from "./llm.js";
|
|
12
|
+
import { captureLlmOutput, SeatLaunchError } from "./llm.js";
|
|
13
|
+
import { withLaunchGuard } from "../drivers/orca.js";
|
|
13
14
|
import { disallowedBy, observedSeat } from "../route/preference.js";
|
|
14
15
|
import { marginalCostRank } from "../route/router.js";
|
|
15
16
|
import { carriedAuthorVendors, gateReviewerFloor, pickReviewer, reviewGate } from "./review.js";
|
|
@@ -1243,27 +1244,66 @@ export async function runGates(task, ctx) {
|
|
|
1243
1244
|
// seat spends two invocations, and one blended span cannot tell a slow reviewer from two.
|
|
1244
1245
|
// A pick that found NO eligible seat dispatched nothing, so it contributes no invocation.
|
|
1245
1246
|
const invocations = [];
|
|
1247
|
+
const retiredNow = () => [...(ctx.reviewNoVerdicts ?? [])].filter(([, causes]) => causes.length >= 2).map(([seat]) => seat);
|
|
1248
|
+
// v2.6.8 T1: retirement is live through the LAUNCH, not only the pick. reviewGate awaits its diff read
|
|
1249
|
+
// after it picks, and a pane seat awaits its slot, so another task can retire the picked seat inside
|
|
1250
|
+
// either await. The seat is named when its command is built and checked against the live tally there and
|
|
1251
|
+
// again as its pane is bound (onSlot runs inside llm.ts's launch guard, which closes that pane unrun), and
|
|
1252
|
+
// a driver that still awaits lookups before it submits the terminal (Orca's runtime probe and worktree
|
|
1253
|
+
// lookup) re-reads it at that submission (withLaunchGuard): a retired seat is never launched, and dispatch
|
|
1254
|
+
// picks again under the live exclusions (atPick) — the next eligible seat, or none. Bounded: each
|
|
1255
|
+
// withdrawal is a seat newly retired, and the pool is finite.
|
|
1256
|
+
let launching = {};
|
|
1257
|
+
const refuseRetired = () => {
|
|
1258
|
+
if (launching.seat === undefined || !retiredNow().includes(launching.seat))
|
|
1259
|
+
return;
|
|
1260
|
+
launching.withdrawn = true;
|
|
1261
|
+
throw new SeatLaunchError(launching.seat, new Error("retired for the run before it launched"));
|
|
1262
|
+
};
|
|
1263
|
+
const reviewVia = via && { ...via, onSlot: (slot) => { via.onSlot?.(slot); refuseRetired(); } };
|
|
1264
|
+
const guarded = (adapter) => new Proxy(adapter, {
|
|
1265
|
+
get(target, property) {
|
|
1266
|
+
if (property === "headlessCommand") {
|
|
1267
|
+
return (promptFile, model, effort) => {
|
|
1268
|
+
launching.seat = channelKey({ adapter: target.id, model });
|
|
1269
|
+
refuseRetired();
|
|
1270
|
+
return target.headlessCommand(promptFile, model, effort);
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
const value = Reflect.get(target, property, target);
|
|
1274
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
1275
|
+
},
|
|
1276
|
+
});
|
|
1246
1277
|
// Dispatch is PROVEN, never inferred: captureLlmOutput records one output per runLlm return, so an
|
|
1247
1278
|
// empty capture means reviewGate returned before asking anyone — a policy skip, a pre-dispatch diff
|
|
1248
1279
|
// cap, or no eligible seat. Reading `noEligibleReviewer` alone missed the first two and invented
|
|
1249
1280
|
// an "unknown" span for each.
|
|
1250
1281
|
const dispatch = async (run) => {
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1253
|
-
|
|
1282
|
+
const withdrawn = [];
|
|
1283
|
+
let picked;
|
|
1284
|
+
for (;;) {
|
|
1285
|
+
launching = {};
|
|
1286
|
+
const captured = await withLaunchGuard(refuseRetired, () => captureLlmDispatches(ctx.adapters.map(guarded), run));
|
|
1287
|
+
invocations.push(...captured.invocations);
|
|
1288
|
+
picked = captured.value;
|
|
1289
|
+
if (cancelled || !launching.withdrawn)
|
|
1290
|
+
break;
|
|
1291
|
+
withdrawn.push(launching.seat);
|
|
1292
|
+
}
|
|
1293
|
+
const rv = withdrawn.length === 0 ? picked : { ...picked, details: `${withdrawn.map((seat) => `review dispatch withdrawn: ${seat} was retired for the run (two real no-verdicts) after it was picked, before it launched; picked again`).join("\n")}\n${picked.details}` };
|
|
1254
1294
|
if (cancelled)
|
|
1255
1295
|
return rv; // a cancelled seat's non-answer says nothing about the seat
|
|
1256
1296
|
if (rv.meta?.noVerdict === true || rv.meta?.unparseable === true) {
|
|
1257
1297
|
await ctx.onGate?.({ phase: "note", gate: "review", name: "review-no-verdict", payload: { ...rv.meta }, result: rv });
|
|
1258
|
-
|
|
1298
|
+
// v2.6.8 T1: a seat that never LAUNCHED (any launchCause, checkout-proof-timeout or none) said
|
|
1299
|
+
// nothing about the work or about itself: no run-scoped strike and no soft demotion. It gets one
|
|
1300
|
+
// bounded same-seat launch retry below and is excluded for the rest of THIS round only.
|
|
1301
|
+
if (typeof rv.meta.reviewer === "string" && rv.meta.cause !== "seat-launch-failed") {
|
|
1259
1302
|
const reviewer = rv.meta.reviewer;
|
|
1260
1303
|
const cause = String(rv.meta.cause);
|
|
1261
|
-
//
|
|
1262
|
-
//
|
|
1263
|
-
|
|
1264
|
-
const proofTimeout = cause === "seat-launch-failed" && rv.meta.launchCause === "checkout-proof-timeout";
|
|
1265
|
-
// OBS-1025 add.2: the run-scoped tally; two no-verdicts retire the seat for the rest of the run.
|
|
1266
|
-
const causes = rv.meta.noVerdict === true && ctx.reviewNoVerdicts && !proofTimeout
|
|
1304
|
+
// OBS-1025 add.2: the run-scoped tally of REAL no-verdicts (silent, truncated, closure-mismatch,
|
|
1305
|
+
// no nonce …); two of them retire the seat for the run — a hard exclusion, never a soft ranking.
|
|
1306
|
+
const causes = rv.meta.noVerdict === true && ctx.reviewNoVerdicts
|
|
1267
1307
|
? [...(ctx.reviewNoVerdicts.get(reviewer) ?? []), cause] : undefined;
|
|
1268
1308
|
if (causes)
|
|
1269
1309
|
ctx.reviewNoVerdicts.set(reviewer, causes);
|
|
@@ -1279,15 +1319,34 @@ export async function runGates(task, ctx) {
|
|
|
1279
1319
|
}
|
|
1280
1320
|
return rv;
|
|
1281
1321
|
};
|
|
1282
|
-
// OBS-1025 add.2: seats with two no-verdicts this run
|
|
1283
|
-
|
|
1322
|
+
// OBS-1025 add.2: seats with two REAL no-verdicts this run (launch failures never strike) are RETIRED:
|
|
1323
|
+
// out of every pick below for the rest of the run, and a resumed daemon re-seeds the tally from the
|
|
1324
|
+
// journal. demotedReviewers is only a soft preference, live or replayed on resume — it ranks a seat
|
|
1325
|
+
// last and never excludes or retires it; history or prefer can never resurrect a retired seat.
|
|
1326
|
+
// The tally is shared across the run's tasks, so it is read LIVE at every pick — after every awaited
|
|
1327
|
+
// notification and inside reviewGate after its promotion reads (atPick), since another task can retire a
|
|
1328
|
+
// seat inside any await: a seat retired while this round's dispatch, note or read is pending is out of
|
|
1329
|
+
// the initial pick, the launch retry, the re-emission and the re-route alike — and one retired after its
|
|
1330
|
+
// pick is refused at launch (dispatch, above).
|
|
1331
|
+
const retired = retiredNow();
|
|
1332
|
+
// reviewGate awaits its promotion path reads BEFORE it picks, and another task can retire a seat inside
|
|
1333
|
+
// that await too. So the exclusions every reviewGate call is handed are a live view: each read — the pick's
|
|
1334
|
+
// included — answers from `fixed` plus the tally as it stands at that read, never a copy taken before.
|
|
1335
|
+
// ponytail: a read-through Proxy because review.ts takes a plain list; a live-exclusion parameter there retires it.
|
|
1336
|
+
const atPick = (fixed) => new Proxy([], {
|
|
1337
|
+
get: (_target, prop) => {
|
|
1338
|
+
const now = [...new Set([...fixed, ...retiredNow()])];
|
|
1339
|
+
const value = Reflect.get(now, prop);
|
|
1340
|
+
return typeof value === "function" ? value.bind(now) : value;
|
|
1341
|
+
},
|
|
1342
|
+
});
|
|
1284
1343
|
// RF-1: THIS task's prior reviewers — earlier rounds' seats plus the seats that produced garbage for
|
|
1285
1344
|
// it (excludeReviewers names only dispatched seats). Kept apart from the eligibility exclusions the
|
|
1286
1345
|
// retry below adds for a flaked seat's whole adapter: those sibling channels never reviewed.
|
|
1287
1346
|
const priorReviewers = [...(ctx.priorReviewers ?? []), ...(ctx.excludeReviewers ?? [])];
|
|
1288
1347
|
const carriedAuthors = ctx.carriedAuthors ?? [];
|
|
1289
1348
|
let exclusions = [...(ctx.excludeReviewers ?? []), ...retired];
|
|
1290
|
-
let rv = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg,
|
|
1349
|
+
let rv = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, reviewVia, atPick(exclusions), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, priorReviewers, carriedAuthors, ctx.operatorContext));
|
|
1291
1350
|
// OBS-193/574: an unparseable review verdict retries the REVIEW, preferring a different adapter. Only
|
|
1292
1351
|
// a single-adapter eligible pool may fall back to another channel on the flaked adapter. The flaked
|
|
1293
1352
|
// verdict never enters results; an exhausted pool preserves its cause.
|
|
@@ -1299,19 +1358,50 @@ export async function runGates(task, ctx) {
|
|
|
1299
1358
|
let hop = 0;
|
|
1300
1359
|
// OBS-1196: seats already re-asked after a malformed verdict — once per seat, so never unbounded.
|
|
1301
1360
|
const reemitted = new Set();
|
|
1361
|
+
// v2.6.8 T1: seats already relaunched after a launch failure — once per seat per candidate (the
|
|
1362
|
+
// daemon replays the spent ones), so a launch retry is bounded and never a second malformed allowance.
|
|
1363
|
+
const launchRetried = new Set(ctx.launchRetried ?? []);
|
|
1302
1364
|
while (!cancelled && (rv.meta?.unparseable === true || rv.meta?.noVerdict === true) && typeof rv.meta.reviewer === "string") {
|
|
1303
1365
|
hop++;
|
|
1304
1366
|
const flaked = rv.meta.reviewer;
|
|
1305
|
-
const retryVia =
|
|
1306
|
-
? { ...
|
|
1367
|
+
const retryVia = reviewVia
|
|
1368
|
+
? { ...reviewVia, nameFor: (role, adapter) => reviewVia.nameFor(role, adapter) + `-r${hop}` }
|
|
1307
1369
|
: undefined;
|
|
1370
|
+
// v2.6.8 T1: bounded same-seat launch retry. A seat that could not launch is launched ONCE more
|
|
1371
|
+
// (fresh launch identity, fresh nonce, same subject) before any other seat is picked, journaled as
|
|
1372
|
+
// the review-infra-retry naming that seat; its verdict decides. A second refusal re-routes below. A
|
|
1373
|
+
// seat two real no-verdicts retired — before the note, while it was awaited, or during the relaunch's
|
|
1374
|
+
// own promotion read (atPick) — is never relaunched: the withdrawn retry is said in the round's
|
|
1375
|
+
// details, and the re-route below excludes the seat.
|
|
1376
|
+
if (rv.meta.cause === "seat-launch-failed" && !launchRetried.has(flaked) && !retiredNow().includes(flaked)) {
|
|
1377
|
+
launchRetried.add(flaked);
|
|
1378
|
+
await ctx.onGate?.({ phase: "note", gate: "review", name: "review-infra-retry", payload: { reviewer: flaked, cause: "seat-launch-failed",
|
|
1379
|
+
sameSeat: true, ...(rv.meta.launchCause ? { launchCause: rv.meta.launchCause } : {}) }, result: rv });
|
|
1380
|
+
const others = ctx.channels.map(channelKey).filter((key) => key !== flaked);
|
|
1381
|
+
const again = retiredNow().includes(flaked) ? undefined : await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, atPick(others), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
|
|
1382
|
+
if (again && again.meta?.noEligibleReviewer !== true) {
|
|
1383
|
+
// The relaunch that completes a malformed verdict's re-emission (the re-emission was what failed to
|
|
1384
|
+
// launch) publishes that delivery, so the seat is a delivery flake again, not a bad reviewer.
|
|
1385
|
+
const reemission = rv.meta.reviewReemission?.reviewer === flaked;
|
|
1386
|
+
if (reemission && again.meta?.unparseable !== true && again.meta?.noVerdict !== true) {
|
|
1387
|
+
await ctx.onGate?.({ phase: "note", gate: "review", name: "review-reemission", payload: { reviewer: flaked, cause: "malformed-verdict",
|
|
1388
|
+
delivered: true, launchRetry: true }, result: again });
|
|
1389
|
+
}
|
|
1390
|
+
routes.push(`review launch retry (same seat, fresh launch): ${flaked} failed to launch; launched once more`);
|
|
1391
|
+
rv = { ...again, details: `${routes.join("\n")}\n${again.details}`, meta: { ...again.meta, reviewLaunchRetry: { reviewer: flaked } } };
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
if (retiredNow().includes(flaked)) {
|
|
1395
|
+
routes.push(`review launch retry withdrawn: ${flaked} was retired for the run (two real no-verdicts) while its relaunch was pending`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1308
1398
|
// OBS-1196: a malformed (unparseable) verdict is a delivery defect of THIS seat, not a reason to drop it. Ask the
|
|
1309
1399
|
// same seat once more on the same subject; reviewGate mints a fresh nonce, so only a new, whole,
|
|
1310
1400
|
// nonce-bound verdict can answer — the malformed bytes are never salvaged into one.
|
|
1311
|
-
if (rv.meta.cause === "malformed-verdict" && rv.meta.closureInvalid !== true && !reemitted.has(flaked)) {
|
|
1401
|
+
if (rv.meta.cause === "malformed-verdict" && rv.meta.closureInvalid !== true && !reemitted.has(flaked) && !retiredNow().includes(flaked)) {
|
|
1312
1402
|
reemitted.add(flaked);
|
|
1313
1403
|
const others = ctx.channels.map(channelKey).filter((key) => key !== flaked);
|
|
1314
|
-
const again = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, others, ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
|
|
1404
|
+
const again = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, atPick(others), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
|
|
1315
1405
|
if (again.meta?.noEligibleReviewer !== true) {
|
|
1316
1406
|
await ctx.onGate?.({ phase: "note", gate: "review", name: "review-reemission", payload: { reviewer: flaked, cause: "malformed-verdict",
|
|
1317
1407
|
delivered: again.meta?.unparseable !== true && again.meta?.noVerdict !== true }, result: again });
|
|
@@ -1319,6 +1409,9 @@ export async function runGates(task, ctx) {
|
|
|
1319
1409
|
rv = { ...again, details: `${routes.join("\n")}\n${again.details}`, meta: { ...again.meta, reviewReemission: { reviewer: flaked } } };
|
|
1320
1410
|
continue;
|
|
1321
1411
|
}
|
|
1412
|
+
if (retiredNow().includes(flaked)) {
|
|
1413
|
+
routes.push(`review re-emission withdrawn: ${flaked} was retired for the run (two real no-verdicts) while its re-emission was pending`);
|
|
1414
|
+
}
|
|
1322
1415
|
}
|
|
1323
1416
|
const emptyOutput = rv.meta.cause === "empty-output";
|
|
1324
1417
|
if (emptyOutput) {
|
|
@@ -1334,16 +1427,18 @@ export async function runGates(task, ctx) {
|
|
|
1334
1427
|
// and the prior reviewers' tiers, the flaked seat's own included, so a retry never drops a tier.
|
|
1335
1428
|
retryPrior = [...retryPrior, flaked];
|
|
1336
1429
|
const retryFloor = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, retryPrior).floor;
|
|
1430
|
+
exclusions = [...new Set([...exclusions, ...retiredNow()])];
|
|
1337
1431
|
const crossAdapter = pickReviewer(ctx.author, ctx.channels, [...exclusions, ...adapterExclusions], ctx.cfg.review.prefer ?? [], retryFloor, undefined, undefined, undefined, carriedAuthorVendors(ctx.channels, carriedAuthors));
|
|
1338
1432
|
const exclusion = crossAdapter ? "adapter" : "channel";
|
|
1339
1433
|
exclusions = [...exclusions, ...(crossAdapter ? adapterExclusions : [flaked])];
|
|
1340
|
-
const second = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, exclusions, ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
|
|
1434
|
+
const second = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, atPick(exclusions), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
|
|
1341
1435
|
if (second.meta?.noEligibleReviewer !== true) {
|
|
1342
1436
|
const retried = typeof second.meta?.reviewer === "string" ? second.meta.reviewer : "none";
|
|
1343
1437
|
const route = exclusion === "adapter"
|
|
1344
1438
|
? `different-adapter retry; excluded flaked adapter ${flakedAdapter}`
|
|
1345
1439
|
: `same-adapter fallback; excluded flaked channel ${flaked}`;
|
|
1346
|
-
const produced = emptyOutput ? "EMPTY output" : rv.meta.cause === "closure-mismatch" ? "a verdict closing no carried fingerprint"
|
|
1440
|
+
const produced = emptyOutput ? "EMPTY output" : rv.meta.cause === "closure-mismatch" ? "a verdict closing no carried fingerprint"
|
|
1441
|
+
: rv.meta.cause === "seat-launch-failed" ? "no verdict (it failed to launch)" : "no parseable verdict";
|
|
1347
1442
|
// `details` is lifted onto the journal's gate-result row; meta.reviewRetry is not. Keep every
|
|
1348
1443
|
// re-route visible in the result text a reader actually opens, including on a red retry.
|
|
1349
1444
|
routes.push(`review re-route (${route}): ${flaked} produced ${produced}; replaced by ${retried}`);
|
|
@@ -88,13 +88,16 @@ export declare function verifyManifestReport(opts: {
|
|
|
88
88
|
export declare const FILE_HANG_SLACK = 3;
|
|
89
89
|
export declare const DEFAULT_FILE_HANG_BUDGET_MS = 60000;
|
|
90
90
|
/**
|
|
91
|
-
* No file is a hang before this much active time, whatever its baseline says (the battery ceiling still wins
|
|
92
|
-
* suite's per-file timings are a few ms, so 3 ×
|
|
91
|
+
* No measured-green file is a hang before this much active time, whatever its baseline says (the battery ceiling still wins
|
|
92
|
+
* below it). A fast suite's per-file timings are a few ms, so 3 × its own killed a slower file that would have passed.
|
|
93
93
|
*/
|
|
94
94
|
export declare const MIN_FILE_HANG_BUDGET_MS = 10000;
|
|
95
95
|
/** Tests of the baseline-derived budget's kill mechanics opt out of the floor; production never does. */
|
|
96
96
|
export declare const setHangBudgetFloorForTests: (ms: number) => void;
|
|
97
97
|
export declare const resetHangBudgetFloorForTests: () => void;
|
|
98
|
+
/** v2.6.8 P4 (prospectively superseding v2.5.5's "untimed = 3 × longest"): only the file's OWN usable green duration K
|
|
99
|
+
* buys measured slack, max(3 × K, L) with L the longest usable entry (red included) or legacy longestFile. An untimed,
|
|
100
|
+
* baseline-red or legacy longest-only file has no measure of its runtime and gets the finite battery ceiling. */
|
|
98
101
|
export declare function fileHangBudgetMs(file: string, baselineDurations?: readonly BaselineFileDuration[] | null, ceilingMs?: number, longestFile?: BaselineFileDuration | null): number;
|
|
99
102
|
/**
|
|
100
103
|
* OBS-953 (+add): a per-file hang budget counted in raw wall time charged a lid-close to the file that
|
|
@@ -130,6 +133,37 @@ export declare function classifyPollGap(prev: {
|
|
|
130
133
|
wall: number;
|
|
131
134
|
mono: number;
|
|
132
135
|
}, pollMs: number): HostInterruption | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* v2.6.8 P4: an overdue poll on Darwin asks the kernel when it last slept and woke (`sysctl -n kern.sleeptime
|
|
138
|
+
* kern.waketime`), synchronously and bounded, before that poll's hang decision; the segment's overlap with the gap is
|
|
139
|
+
* credited as `host-suspend`. Unreadable evidence is unknown and credits 0. NAMED RESIDUAL: the kernel keeps only the
|
|
140
|
+
* MOST RECENT segment, so a gap holding several sleeps or maintenance DarkWakes credits only the last one — that
|
|
141
|
+
* under-credit fails closed: the file can still be killed as a hang, as on 2.6.7.
|
|
142
|
+
*/
|
|
143
|
+
export interface SleepSegment {
|
|
144
|
+
sleep: number;
|
|
145
|
+
wake: number;
|
|
146
|
+
}
|
|
147
|
+
export interface SleepEvidence {
|
|
148
|
+
platform: () => NodeJS.Platform;
|
|
149
|
+
/** The synchronous OS-command boundary (spawnSync's shape). */
|
|
150
|
+
command: (file: string, args: string[], opts: {
|
|
151
|
+
encoding: "utf8";
|
|
152
|
+
timeout: number;
|
|
153
|
+
maxBuffer: number;
|
|
154
|
+
killSignal: "SIGKILL";
|
|
155
|
+
}) => {
|
|
156
|
+
status: number | null;
|
|
157
|
+
signal: NodeJS.Signals | null;
|
|
158
|
+
stdout?: string;
|
|
159
|
+
stderr?: string;
|
|
160
|
+
error?: Error;
|
|
161
|
+
};
|
|
162
|
+
/** Replaces the default sysctl reader; production supplies none. */
|
|
163
|
+
reader?: () => SleepSegment | undefined;
|
|
164
|
+
}
|
|
165
|
+
export declare const setSleepEvidenceForTests: (evidence: Partial<SleepEvidence>) => void;
|
|
166
|
+
export declare const resetSleepEvidenceForTests: () => void;
|
|
133
167
|
export declare const runWithInterruptionSink: <T>(sink: (interruption: HostInterruption) => void, run: () => Promise<T>) => Promise<T>;
|
|
134
168
|
export interface ManifestRunResult {
|
|
135
169
|
evidenceReceipt: GateEvidenceReceipt;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
@@ -331,8 +332,8 @@ export function verifyManifestReport(opts) {
|
|
|
331
332
|
export const FILE_HANG_SLACK = 3;
|
|
332
333
|
export const DEFAULT_FILE_HANG_BUDGET_MS = 60_000;
|
|
333
334
|
/**
|
|
334
|
-
* No file is a hang before this much active time, whatever its baseline says (the battery ceiling still wins
|
|
335
|
-
* suite's per-file timings are a few ms, so 3 ×
|
|
335
|
+
* No measured-green file is a hang before this much active time, whatever its baseline says (the battery ceiling still wins
|
|
336
|
+
* below it). A fast suite's per-file timings are a few ms, so 3 × its own killed a slower file that would have passed.
|
|
336
337
|
*/
|
|
337
338
|
export const MIN_FILE_HANG_BUDGET_MS = 10_000;
|
|
338
339
|
let hangBudgetFloorMs = MIN_FILE_HANG_BUDGET_MS;
|
|
@@ -340,17 +341,20 @@ let hangBudgetFloorMs = MIN_FILE_HANG_BUDGET_MS;
|
|
|
340
341
|
export const setHangBudgetFloorForTests = (ms) => { hangBudgetFloorMs = ms; };
|
|
341
342
|
export const resetHangBudgetFloorForTests = () => { hangBudgetFloorMs = MIN_FILE_HANG_BUDGET_MS; };
|
|
342
343
|
const usable = (n) => n !== undefined && Number.isFinite(n) && n > 0;
|
|
344
|
+
/** v2.6.8 P4 (prospectively superseding v2.5.5's "untimed = 3 × longest"): only the file's OWN usable green duration K
|
|
345
|
+
* buys measured slack, max(3 × K, L) with L the longest usable entry (red included) or legacy longestFile. An untimed,
|
|
346
|
+
* baseline-red or legacy longest-only file has no measure of its runtime and gets the finite battery ceiling. */
|
|
343
347
|
export function fileHangBudgetMs(file, baselineDurations, ceilingMs = DEFAULT_FILE_HANG_BUDGET_MS, longestFile) {
|
|
344
348
|
const ceiling = usable(ceilingMs) ? ceilingMs : DEFAULT_FILE_HANG_BUDGET_MS;
|
|
345
349
|
const timings = baselineDurations?.filter((d) => usable(d.durationMs)) ?? [];
|
|
346
|
-
const longest = Math.max(0, ...timings.map((d) => d.durationMs), usable(longestFile?.durationMs) ? longestFile.durationMs : 0);
|
|
347
|
-
if (!longest)
|
|
348
|
-
return ceiling;
|
|
349
350
|
// A file that was RED in the baseline ended early there: its duration is no measure of its green runtime. The
|
|
350
351
|
// failure is read from EVERY entry, before the usable-duration filter can drop a 0 ms or non-finite red one.
|
|
351
352
|
const red = baselineDurations?.some((d) => d.file === file && d.failed) ?? false;
|
|
352
353
|
const known = red ? undefined : timings.find((d) => d.file === file)?.durationMs;
|
|
353
|
-
|
|
354
|
+
if (known === undefined)
|
|
355
|
+
return ceiling;
|
|
356
|
+
const longest = Math.max(...timings.map((d) => d.durationMs), usable(longestFile?.durationMs) ? longestFile.durationMs : 0);
|
|
357
|
+
return Math.min(ceiling, Math.max(FILE_HANG_SLACK * known, longest));
|
|
354
358
|
}
|
|
355
359
|
/**
|
|
356
360
|
* OBS-953 (+add): a per-file hang budget counted in raw wall time charged a lid-close to the file that
|
|
@@ -377,8 +381,29 @@ export function classifyPollGap(prev, now, pollMs) {
|
|
|
377
381
|
return { kind: "unknown", ...gap, subtractedMs: 0 };
|
|
378
382
|
return undefined;
|
|
379
383
|
}
|
|
380
|
-
|
|
381
|
-
|
|
384
|
+
const systemSleepEvidence = { platform: () => process.platform, command: (file, args, opts) => spawnSync(file, args, opts) };
|
|
385
|
+
let sleepEvidence = systemSleepEvidence;
|
|
386
|
+
export const setSleepEvidenceForTests = (evidence) => { sleepEvidence = { ...systemSleepEvidence, ...evidence }; };
|
|
387
|
+
export const resetSleepEvidenceForTests = () => { sleepEvidence = systemSleepEvidence; };
|
|
388
|
+
const SYSCTL_TIMEOUT_MS = 1_000, SYSCTL_MAX_BYTES = 4_096;
|
|
389
|
+
const SYSCTL_STAMP = /^\{ sec = (\d{1,15}), usec = (\d{1,6}) \} [A-Z][a-z]{2} [A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/;
|
|
390
|
+
/** Lazy: runs only from an overdue poll, never at import. A timeout or overrun of 1000 ms, a signal, nonzero exit, more
|
|
391
|
+
* than 4096 combined output bytes, or a truncated, unparseable or reversed pair is unknown. spawnSync SIGKILLs the child
|
|
392
|
+
* at either bound and joins it: a child ignoring SIGTERM cannot hold the poll, so hang enforcement still runs. */
|
|
393
|
+
function readDarwinSleepSegment() {
|
|
394
|
+
const startedMs = hangClocks.mono();
|
|
395
|
+
const r = sleepEvidence.command("sysctl", ["-n", "kern.sleeptime", "kern.waketime"], { encoding: "utf8", timeout: SYSCTL_TIMEOUT_MS, maxBuffer: SYSCTL_MAX_BYTES, killSignal: "SIGKILL" });
|
|
396
|
+
const out = r.stdout ?? "", err = r.stderr ?? "";
|
|
397
|
+
if (hangClocks.mono() - startedMs >= SYSCTL_TIMEOUT_MS || r.error || r.signal || r.status !== 0
|
|
398
|
+
|| Buffer.byteLength(out) + Buffer.byteLength(err) > SYSCTL_MAX_BYTES)
|
|
399
|
+
return undefined;
|
|
400
|
+
const [sleep, wake, ...rest] = out.replace(/\n$/, "").split("\n").map((line) => SYSCTL_STAMP.exec(line))
|
|
401
|
+
.map((m) => m ? Number(m[1]) * 1000 + Number(m[2]) / 1000 : NaN);
|
|
402
|
+
return !rest.length && sleep < wake ? { sleep: sleep, wake: wake } : undefined;
|
|
403
|
+
}
|
|
404
|
+
/** Per gap, a file started at `startedAt` is credited the larger of the offset it sat through and its own overlap with
|
|
405
|
+
* the kernel segment: the same suspend is never counted twice, and nothing before the file started is credited. */
|
|
406
|
+
const suspendedSince = (credits, startedAt) => credits.reduce((sum, c) => sum + Math.max(Math.min(c.offsetMs, Math.max(0, c.to - Math.max(c.from, startedAt))), Math.max(0, c.wake - Math.max(c.sleep, startedAt))), 0);
|
|
382
407
|
// The daemon journals each interruption where it ran (task gate, tip verify); no sink, no journal row.
|
|
383
408
|
const interruptionSink = new AsyncLocalStorage();
|
|
384
409
|
export const runWithInterruptionSink = (sink, run) => interruptionSink.run(sink, run);
|
|
@@ -397,13 +422,33 @@ export function runManifestedTest(cmd, cwd, opts) {
|
|
|
397
422
|
let hangWallMs;
|
|
398
423
|
let poll;
|
|
399
424
|
const interruptions = [];
|
|
425
|
+
const credits = [];
|
|
426
|
+
let creditedUntil = -Infinity;
|
|
400
427
|
const sink = interruptionSink.getStore();
|
|
401
428
|
let last = { wall: hangClocks.wall(), mono: hangClocks.mono() };
|
|
402
429
|
const checkHang = (atCeiling = false) => {
|
|
403
430
|
const at = { wall: hangClocks.wall(), mono: hangClocks.mono() };
|
|
404
|
-
|
|
431
|
+
let gap = classifyPollGap(last, at, pollMs);
|
|
405
432
|
last = at;
|
|
406
433
|
if (gap) {
|
|
434
|
+
let os;
|
|
435
|
+
if (sleepEvidence.platform() === "darwin") {
|
|
436
|
+
try {
|
|
437
|
+
os = (sleepEvidence.reader ?? readDarwinSleepSegment)();
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
os = undefined;
|
|
441
|
+
}
|
|
442
|
+
last = { wall: hangClocks.wall(), mono: hangClocks.mono() }; // the bounded read itself is no poll gap
|
|
443
|
+
}
|
|
444
|
+
// Clipped to this gap and past any segment already credited, so a repeated pair never credits twice.
|
|
445
|
+
const sleep = Math.max(os?.sleep ?? Infinity, gap.from, creditedUntil), wake = Math.min(os?.wake ?? -Infinity, gap.to);
|
|
446
|
+
const osMs = wake > sleep ? wake - sleep : 0;
|
|
447
|
+
if (osMs)
|
|
448
|
+
creditedUntil = wake;
|
|
449
|
+
credits.push({ from: gap.from, to: gap.to, offsetMs: gap.subtractedMs, ...(osMs ? { sleep, wake } : { sleep: 0, wake: 0 }) });
|
|
450
|
+
if (osMs > gap.subtractedMs)
|
|
451
|
+
gap = { ...gap, kind: "host-suspend", subtractedMs: osMs };
|
|
407
452
|
interruptions.push(gap);
|
|
408
453
|
sink?.(gap);
|
|
409
454
|
}
|
|
@@ -416,7 +461,7 @@ export function runManifestedTest(cmd, cwd, opts) {
|
|
|
416
461
|
continue;
|
|
417
462
|
const budget = Math.min(overallCeilingMs, Math.max(hangBudgetFloorMs, fileHangBudgetMs(file, opts.baselineDurations, overallCeilingMs, opts.longestFile)));
|
|
418
463
|
const wallMs = at.wall - startedAt;
|
|
419
|
-
const activeMs = wallMs - suspendedSince(
|
|
464
|
+
const activeMs = wallMs - suspendedSince(credits, startedAt);
|
|
420
465
|
if (activeMs >= budget || (atCeiling && budget === overallCeilingMs)) {
|
|
421
466
|
killedFile = file;
|
|
422
467
|
hangBudgetMs = budget;
|
|
@@ -10,7 +10,13 @@ export interface ProcessIdentity {
|
|
|
10
10
|
cwd: string;
|
|
11
11
|
pgid: number;
|
|
12
12
|
}
|
|
13
|
-
|
|
13
|
+
/** `UNKNOWN — <why>` is UNKNOWN that names its cause (Darwin's lsof missing or unusable): never life, never death. */
|
|
14
|
+
export type IdentityRead = ProcessIdentity | "DEAD" | "UNKNOWN" | `UNKNOWN — ${string}`;
|
|
15
|
+
/** The host the production reader inspects through; Darwin (and every non-Linux host) reads cwd only via lsof. */
|
|
16
|
+
export interface IdentityHost {
|
|
17
|
+
platform?: NodeJS.Platform;
|
|
18
|
+
lsof?: string;
|
|
19
|
+
}
|
|
14
20
|
/** Every boundary the lifecycle crosses, injectable so failures are exercised where they really occur. */
|
|
15
21
|
export interface BeatLifecycleDeps {
|
|
16
22
|
/** Command prefix that re-executes this same CLI (node, its flags, the entry the launcher ran). */
|
|
@@ -23,15 +29,19 @@ export interface BeatLifecycleDeps {
|
|
|
23
29
|
sleep: (ms: number) => Promise<void>;
|
|
24
30
|
env: NodeJS.ProcessEnv;
|
|
25
31
|
}
|
|
26
|
-
export declare function defaultBeatDeps(): BeatLifecycleDeps;
|
|
27
|
-
/** A named, nonzero refusal: dispatch renders the message and exits 1. */
|
|
32
|
+
export declare function defaultBeatDeps(host?: IdentityHost): BeatLifecycleDeps;
|
|
33
|
+
/** A named, nonzero refusal: dispatch renders the message and exits 1. `notice` is a reclaim it must not lose. */
|
|
28
34
|
export declare class BeatRefusal extends Error {
|
|
35
|
+
readonly notice?: string | undefined;
|
|
36
|
+
constructor(message: string, notice?: string | undefined);
|
|
29
37
|
}
|
|
30
38
|
/**
|
|
31
39
|
* The live process behind a pid: birth (lstart), full argv, cwd and process group. A missing process is
|
|
32
|
-
* DEAD only when the kernel says so (ESRCH or a zombie)
|
|
40
|
+
* DEAD only when the kernel says so (ESRCH or a zombie) — also when it exits between ps and the cwd read;
|
|
41
|
+
* anything unreadable is UNKNOWN, never DEAD. Linux reads cwd from /proc and never needs lsof; every other
|
|
42
|
+
* host reads it through lsof, so a missing or unusable lsof beside a LIVE pid is UNKNOWN that names lsof.
|
|
33
43
|
*/
|
|
34
|
-
export declare function readProcessIdentity(pid: number): IdentityRead;
|
|
44
|
+
export declare function readProcessIdentity(pid: number, host?: IdentityHost): IdentityRead;
|
|
35
45
|
export declare const beatClaimPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
36
46
|
export declare const beatOwnerPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
37
47
|
export declare const beatLogPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
@@ -58,12 +68,61 @@ export interface BeatOwner {
|
|
|
58
68
|
}
|
|
59
69
|
type Read<T> = T | "NONE" | "UNREADABLE";
|
|
60
70
|
export declare function readBeatOwner(repoRoot: string, tier: SupervisionTier): Read<BeatOwner>;
|
|
61
|
-
/**
|
|
62
|
-
export
|
|
63
|
-
|
|
71
|
+
/** Runs `mutate` as ONE claimed act (claimedAct); `check` is the act's own barrier (owner generation, records). */
|
|
72
|
+
export type ClaimGuard = <T>(mutate: () => T, check?: () => void) => T;
|
|
73
|
+
/** Outside any claim (a direct call): the barrier, then the mutation. */
|
|
74
|
+
export declare const unclaimed: ClaimGuard;
|
|
64
75
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
76
|
+
* Atomic first-writer-wins claim. A held claim is BUSY unless `reclaim` (every claimant passes its boundaries) confirms
|
|
77
|
+
* DEAD every pid parsed from the claim's own bytes — its holder and any launched writer publishing under it
|
|
78
|
+
* (reclaimDeadClaim): then exactly those bytes are removed (takeClaim) and the claim is taken; the returned notice says
|
|
79
|
+
* so (on a refusal, `BeatRefusal.notice` does). A live, reused or unreadable holder or publisher is never taken over.
|
|
80
|
+
* A linked claim is SETTLED (returned) only when no claim removal is under way and it is still the canonical claim: a
|
|
81
|
+
* removal's put-back may replace a claim linked into its gap, so until that removal ends the claim holds nothing — the
|
|
82
|
+
* claimant withdraws exactly its own linked bytes (takeClaim) and refuses BUSY, so no caller, a long-lived bridge
|
|
83
|
+
* included, is left holding a live claim nobody acts under; its next attempt links afresh. When another live taker
|
|
84
|
+
* holds or has moved aside those very bytes (a mistaken death proof included), the withdrawal cannot land then; once
|
|
85
|
+
* this attempt refuses, its token is no longer in use, so the bytes are this process's STRAND wherever they are
|
|
86
|
+
* (strandOf), proven from the bytes alone: every later attempt in this process withdraws them from the claim path
|
|
87
|
+
* first (withdrawStrand) and discards them from a killed taker's lock instead of restoring them (recoverLock) — so
|
|
88
|
+
* recovery never waits for the caller to exit.
|
|
89
|
+
*/
|
|
90
|
+
export declare function acquireBeatClaim(repoRoot: string, tier: SupervisionTier, token: string, reclaim?: BeatLifecycleDeps): string | undefined;
|
|
91
|
+
/** The ONE finite contention policy shared by every waiting claimant: loop ticks, legacy one-shots and stop. */
|
|
92
|
+
export declare const BEAT_CLAIM_WAIT: {
|
|
93
|
+
readonly tries: 20;
|
|
94
|
+
readonly intervalMs: 250;
|
|
95
|
+
};
|
|
96
|
+
export interface ClaimWait {
|
|
97
|
+
tries?: number;
|
|
98
|
+
intervalMs?: number;
|
|
99
|
+
sleep: (ms: number) => Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Release removes exactly the bytes that name this token (takeClaim), after any claimed act under them — a launched
|
|
103
|
+
* writer's included — has finished: a replacement found at any instant is left in place. However it ends, the token is
|
|
104
|
+
* then no longer in use: bytes under it that a live taker kept or moved aside are this process's strand (strandOf),
|
|
105
|
+
* withdrawn or discarded by its next attempt. `told` gets the notice of a dead taker's lock recovered on the way.
|
|
106
|
+
*/
|
|
107
|
+
export declare function releaseBeatClaim(repoRoot: string, tier: SupervisionTier, token: string, told?: (notice: string) => void): void;
|
|
108
|
+
/**
|
|
109
|
+
* Run ONE supervision.ts writer (stand-down, arm or beat) under a claim without letting it touch the tier: it writes
|
|
110
|
+
* into a STAGED copy of the tier's records (arm, clear-duty latch, beat, stand-down marker), then every record it
|
|
111
|
+
* changed is committed by one rename — a discharged latch by one removal — as ONE claimed act each (`guard`), in the
|
|
112
|
+
* writers' own order (arm, raised latch, beat, marker, discharged latch). Compare-and-commit: inside each act, after
|
|
113
|
+
* the caller's own barrier (its owner generation) and before the final claim check, every record must still be
|
|
114
|
+
* exactly what this write was staged from (or what it committed itself) — a record replaced meanwhile, the owner by
|
|
115
|
+
* the caller's barrier, refuses with nothing further committed. A claim lost, or a record or owner replaced, before
|
|
116
|
+
* the first commit therefore lands nothing; one lost between two commits lands nothing further; either refuses. What a
|
|
117
|
+
* failing writer published before its failure is committed the same way, then the failure is rethrown.
|
|
118
|
+
* ponytail: staging stands in for a commit guard supervision.ts's atomic writers do not take; a writer killed mid-write
|
|
119
|
+
* leaves an inert `<tier>.stage.<pid>.<uuid>` directory (no reader lists it), which the next start, stop or legacy
|
|
120
|
+
* write removes once that pid is confirmed dead (acquireBeatClaim).
|
|
121
|
+
*/
|
|
122
|
+
export declare function supervised<T>(repoRoot: string, tier: SupervisionTier, guard: ClaimGuard, write: (root: string) => T): T;
|
|
123
|
+
/**
|
|
124
|
+
* A legacy writer: the launch generation it carries (if any), its seat, the arm a running loop holds, the
|
|
125
|
+
* boundaries its checks read through, and where the notice of a dead claim it reclaimed goes.
|
|
67
126
|
*/
|
|
68
127
|
export interface LegacyWriter {
|
|
69
128
|
generation?: string;
|
|
@@ -71,6 +130,7 @@ export interface LegacyWriter {
|
|
|
71
130
|
settled?: boolean;
|
|
72
131
|
arm?: SupervisionArm;
|
|
73
132
|
deps: BeatLifecycleDeps;
|
|
133
|
+
noticed?: (notice: string) => void;
|
|
74
134
|
}
|
|
75
135
|
/**
|
|
76
136
|
* The recheck a legacy write calls immediately before it mutates; `arm` is the arm the writer itself holds, and
|
|
@@ -79,17 +139,23 @@ export interface LegacyWriter {
|
|
|
79
139
|
export type LegacyRecheck = (arm?: SupervisionArm, created?: boolean) => void;
|
|
80
140
|
/**
|
|
81
141
|
* Run one legacy write (one-shot tick, new arm, EVERY loop tick, stand-down) under the tier claim. A launched
|
|
82
|
-
* child acts under its launcher's claim while that claim is still held for its generation
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
142
|
+
* child acts under its launcher's claim (underLaunchClaim) while that claim is still held for its generation, names
|
|
143
|
+
* this child as its publisher and its launcher is not confirmed dead; afterwards it takes the tier claim itself like
|
|
144
|
+
* every other writer. `act` receives the recheck it must call before it writes (the tier's ownership still admits
|
|
145
|
+
* this writer, then the claim still names this holder) and the guard every supervision write it makes commits under
|
|
146
|
+
* (supervised): each commit is one claimed act whose barrier re-runs that same ownership check. A claim held by
|
|
147
|
+
* anyone else is waited out under BEAT_CLAIM_WAIT (20 attempts 250 ms apart, absent `waits` fields default to it)
|
|
148
|
+
* and then refuses BUSY with nothing written; invalid tries/interval refuse before any claim or write. Like start
|
|
149
|
+
* and stop, the writer reclaims a claim whose recorded pids are all confirmed dead (`noticed` receives the notice): a
|
|
150
|
+
* legacy --loop writer killed inside its claimed tick never strands the --stand-down migration step, and a live
|
|
151
|
+
* writer's claim is never taken. Under its own claim, the release that follows `act` is the claim's last check: a claim
|
|
152
|
+
* lost by then refuses, never reports success. Under the launcher's claim no release follows and nothing is checked
|
|
153
|
+
* after the last act — each act verified the claim last — so the launcher's release after its read-back never ends
|
|
154
|
+
* the loop it launched. A dead or abandoned generation recorded for this writer's seat is retired first (retireDead).
|
|
86
155
|
*/
|
|
87
|
-
export declare function withLegacyClaim<T>(repoRoot: string, tier: SupervisionTier, writer: LegacyWriter, act: (recheck: LegacyRecheck) => T | Promise<T>, waits?:
|
|
88
|
-
tries: number;
|
|
89
|
-
sleep: (ms: number) => Promise<void>;
|
|
90
|
-
}): Promise<T>;
|
|
156
|
+
export declare function withLegacyClaim<T>(repoRoot: string, tier: SupervisionTier, writer: LegacyWriter, act: (recheck: LegacyRecheck, held: ClaimGuard) => T | Promise<T>, waits?: ClaimWait): Promise<T>;
|
|
91
157
|
/** The ownership a legacy writer needs before each mutation: its own COMPLETE launched generation, or no owner at all. */
|
|
92
|
-
export declare function assertLegacyOwnership(repoRoot: string, tier: SupervisionTier, writer: LegacyWriter, arm?: SupervisionArm, created?: boolean, held?: BeatOwner): BeatOwner | undefined;
|
|
158
|
+
export declare function assertLegacyOwnership(repoRoot: string, tier: SupervisionTier, writer: LegacyWriter, arm?: SupervisionArm, created?: boolean, held?: BeatOwner, claimed?: ClaimGuard): BeatOwner | undefined;
|
|
93
159
|
/**
|
|
94
160
|
* ONE RULE: every legacy write is preceded by the shared complete owned-generation check of the CURRENT owner
|
|
95
161
|
* record and durable arm against the EXACT generation this writer holds — token, seat, pid (once settled), armId
|
|
@@ -107,7 +173,7 @@ export declare function assertLegacyOwnership(repoRoot: string, tier: Supervisio
|
|
|
107
173
|
*/
|
|
108
174
|
export declare function assertOwnedGeneration(repoRoot: string, tier: SupervisionTier, writer: LegacyWriter & {
|
|
109
175
|
generation: string;
|
|
110
|
-
}, arm?: SupervisionArm | undefined, created?: boolean, held?: BeatOwner, pid?: number): BeatOwner;
|
|
176
|
+
}, arm?: SupervisionArm | undefined, created?: boolean, held?: BeatOwner, claimed?: ClaimGuard, pid?: number): BeatOwner;
|
|
111
177
|
export type CheckedBeat = {
|
|
112
178
|
kind: "EMPTY";
|
|
113
179
|
} | {
|
|
@@ -124,9 +190,14 @@ export type CheckedBeat = {
|
|
|
124
190
|
owner: BeatOwner & {
|
|
125
191
|
pid: number;
|
|
126
192
|
};
|
|
193
|
+
} | {
|
|
194
|
+
kind: "ABANDONED";
|
|
195
|
+
owner: BeatOwner;
|
|
196
|
+
reason: string;
|
|
127
197
|
} | {
|
|
128
198
|
kind: "FOREIGN";
|
|
129
199
|
reason: string;
|
|
200
|
+
migrate?: string;
|
|
130
201
|
} | {
|
|
131
202
|
kind: "PID-REUSED";
|
|
132
203
|
owner: BeatOwner & {
|
|
@@ -140,15 +211,29 @@ export type CheckedBeat = {
|
|
|
140
211
|
* The checked state of one tier, read-only: no claim, no write, no signal. Every authoritative record —
|
|
141
212
|
* owner, marker, beat and durable arm — is validated before any outcome is selected, and ownership binds to
|
|
142
213
|
* this repository's canonical root, the recorded seat and the recorded arm before the process is consulted.
|
|
214
|
+
* An UNBOUND owner (no pid, or no arm epoch) is a launch in progress only while a claim is held: a launch publishes and
|
|
215
|
+
* binds only under its own claim. So when the caller holds the tier claim (`held`), or no claim exists before and after
|
|
216
|
+
* the owner is read (status), an unbound owner with no live recorded process is ABANDONED — its launcher died — and
|
|
217
|
+
* the next start, stop or legacy write retires it (retireDead); a live recorded process keeps it UNKNOWN.
|
|
218
|
+
*/
|
|
219
|
+
export declare function inspectBeat(repoRoot: string, tier: SupervisionTier, seat: string | undefined, deps: BeatLifecycleDeps, held?: boolean): CheckedBeat;
|
|
220
|
+
/**
|
|
221
|
+
* Status: the recorded liveness, rendered without writing; healthy states exit 0, the rest nonzero. It is observation
|
|
222
|
+
* only: a claim whose recorded pids are all dead, what a killed taker or writer left, or — read in the process that
|
|
223
|
+
* stranded it (a bridge) — that process's own strand (strandOf), is REPORTED (nonzero) with the pids and the exact
|
|
224
|
+
* command that recovers it — never recovered here, and no file is touched.
|
|
143
225
|
*/
|
|
144
|
-
export declare function inspectBeat(repoRoot: string, tier: SupervisionTier, seat: string | undefined, deps: BeatLifecycleDeps): CheckedBeat;
|
|
145
|
-
/** Status: the recorded liveness, rendered without writing; healthy states exit 0, the rest nonzero. */
|
|
146
226
|
export declare function beatStatus(repoRoot: string, tier: SupervisionTier, seat: string | undefined, deps: BeatLifecycleDeps): {
|
|
147
227
|
out: string;
|
|
148
228
|
code: number;
|
|
149
229
|
};
|
|
150
|
-
/** start: one claim, one child; exit zero only after this exact child's own advancing beat reads back. */
|
|
230
|
+
/** start: one claim (a single attempt), one child; exit zero only after this exact child's own advancing beat reads back. */
|
|
151
231
|
export declare function startBeat(repoRoot: string, tier: SupervisionTier, seat: string, deps: BeatLifecycleDeps): Promise<string>;
|
|
152
|
-
/**
|
|
232
|
+
/**
|
|
233
|
+
* stop: retire only the recorded generation, signalling its exact pid, then read back DISARMED. A loop tick
|
|
234
|
+
* holding the claim is waited out under BEAT_CLAIM_WAIT; a claim whose recorded pids are all confirmed dead is
|
|
235
|
+
* reclaimed. Every stand-down commit (supervised), signal and removal is one claimed act under stop's claim (`held`)
|
|
236
|
+
* with the owner generation it acts on re-verified inside it.
|
|
237
|
+
*/
|
|
153
238
|
export declare function stopBeat(repoRoot: string, tier: SupervisionTier, seat: string, deps: BeatLifecycleDeps): Promise<string>;
|
|
154
239
|
export {};
|