tickmarkr 2.1.3 → 2.1.4
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/cli/commands/status.d.ts +3 -0
- package/dist/cli/commands/status.js +23 -1
- package/dist/compile/gsd.js +21 -1
- package/dist/run/daemon.d.ts +0 -1
- package/dist/run/daemon.js +54 -19
- package/dist/run/git.d.ts +15 -0
- package/dist/run/git.js +64 -6
- package/dist/run/journal.d.ts +24 -0
- package/dist/run/journal.js +62 -1
- package/dist/run/supervision.d.ts +1 -1
- package/dist/run/supervision.js +126 -35
- package/fixtures/gsd-sample/07-live-check/07-03-SUMMARY.md +4 -0
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +1 -1
|
@@ -8,6 +8,9 @@ export type StatusOpts = {
|
|
|
8
8
|
readWorkerOutput?: (taskId: string, attempt: number, runId: string) => Promise<string | undefined>;
|
|
9
9
|
webhookUrl?: string;
|
|
10
10
|
postWebhook?: DecisionWebhookPost;
|
|
11
|
+
/** How often the live board rewrites its own `watch` beat. Fixtures choose it so a death drill can
|
|
12
|
+
* watch the record STOP inside a test's patience; unset means SUPERVISION_BEAT_MS. */
|
|
13
|
+
supervisionBeatMs?: number;
|
|
11
14
|
};
|
|
12
15
|
export declare const GATE_KEYS: {
|
|
13
16
|
readonly build: "B";
|
|
@@ -12,7 +12,7 @@ import { isPidLive } from "../../run/lock.js";
|
|
|
12
12
|
import { normalizeGateOutcome } from "../../run/outcome.js";
|
|
13
13
|
import { desiredPanes } from "../../run/reconcile.js";
|
|
14
14
|
import { normalizeStallSnapshot } from "../../run/stall.js";
|
|
15
|
-
import { readSupervision, supervisionText } from "../../run/supervision.js";
|
|
15
|
+
import { armSupervision, readSupervision, supervisionText } from "../../run/supervision.js";
|
|
16
16
|
import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
|
|
17
17
|
import { COCKPIT_COLUMN_FLOOR } from "../../tui/cockpit/layout.js";
|
|
18
18
|
import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
|
|
@@ -1370,6 +1370,20 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
1370
1370
|
}
|
|
1371
1371
|
return fresh;
|
|
1372
1372
|
};
|
|
1373
|
+
// SUP-06: the live board IS the `watch` tier, so it is the only process that can beat it — which is
|
|
1374
|
+
// why that tier could only ever print ABSENT about itself while every other seat had a beater. A word
|
|
1375
|
+
// one tier can only ever say in one of its five states trains every reader to skip it.
|
|
1376
|
+
//
|
|
1377
|
+
// Armed ONLY when UNBOUNDED. A bounded render is a READER, and the purity fence (D-02: a bounded
|
|
1378
|
+
// --watch leaves .tickmarkr/ byte-identical) is exactly the test that catches a reader beating on a
|
|
1379
|
+
// watcher's behalf, which would report every dead tier healthy. Armed BEFORE the first frame, so the
|
|
1380
|
+
// board's own first print already names its tier armed rather than one interval later.
|
|
1381
|
+
//
|
|
1382
|
+
// The handle is HELD and stood down in the finally. Discarding it is not a smaller version of this —
|
|
1383
|
+
// it INVERTS it: armSupervision's interval outlives the board in any host that outlives one board, so
|
|
1384
|
+
// a dead board keeps writing and reads ARMED. That is the over-claiming direction, the one an operator
|
|
1385
|
+
// acts on, and the one this instrument exists to close.
|
|
1386
|
+
const armed = bounded ? undefined : armSupervision(cwd, "watch", opts.supervisionBeatMs);
|
|
1373
1387
|
let titleSaved = false;
|
|
1374
1388
|
const restoreTitle = () => {
|
|
1375
1389
|
if (!titleSaved)
|
|
@@ -1427,6 +1441,14 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
|
|
|
1427
1441
|
}
|
|
1428
1442
|
}
|
|
1429
1443
|
finally {
|
|
1444
|
+
// Stops the interval as well as recording the hand-off: a board that returns must not leave a
|
|
1445
|
+
// timer beating for it. A board that is KILLED never reaches here, its beat ages out, and the
|
|
1446
|
+
// tier reads STALE — armed-then-lost, which is the truth about a killed board.
|
|
1447
|
+
// Boards OVERLAP — a second pane is one keystroke away — and the stand-down marker speaks for the
|
|
1448
|
+
// whole tier, so this hand-off is recorded only when no other board is still present (see
|
|
1449
|
+
// armSupervision's presence files). Otherwise the first pane closed would render the second
|
|
1450
|
+
// pane's own tier down while it is drawing frames.
|
|
1451
|
+
armed?.disarm();
|
|
1430
1452
|
if (titleSaved) {
|
|
1431
1453
|
process.removeListener("exit", restoreTitle);
|
|
1432
1454
|
restoreTitle();
|
package/dist/compile/gsd.js
CHANGED
|
@@ -106,6 +106,22 @@ function extractWriteDirectives(body, storedPath) {
|
|
|
106
106
|
return writes;
|
|
107
107
|
}
|
|
108
108
|
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
109
|
+
function summaryCompletionStatus(planFile) {
|
|
110
|
+
const summaryFile = join(dirname(planFile), `${basename(planFile).replace(/-PLAN\.md$/, "")}-SUMMARY.md`);
|
|
111
|
+
if (!existsSync(summaryFile))
|
|
112
|
+
return undefined;
|
|
113
|
+
try {
|
|
114
|
+
const content = readFileSync(summaryFile, "utf8");
|
|
115
|
+
const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
|
|
116
|
+
if (!fmMatch)
|
|
117
|
+
return undefined;
|
|
118
|
+
const fm = parseYaml(fmMatch[1]);
|
|
119
|
+
return isPlainObject(fm) ? fm.status : undefined;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
109
125
|
// schema-legal, branch-safe id segment: dots etc. → dashes; no "--" runs (task-branch separator)
|
|
110
126
|
// or trailing dash — the id schema rejects both
|
|
111
127
|
const sanitize = (s) => s
|
|
@@ -170,7 +186,11 @@ function compileOne(file, storedPath) {
|
|
|
170
186
|
.filter((p) => !p.includes("$") && !p.startsWith("~") && !p.startsWith("/") && !p.split("/").includes(".."));
|
|
171
187
|
const taskCount = [...body.matchAll(/<task[\s>]/g)].length;
|
|
172
188
|
const humanGate = fm.autonomous === false || /<task type="checkpoint:/.test(body);
|
|
173
|
-
const
|
|
189
|
+
const summaryStatus = summaryCompletionStatus(file);
|
|
190
|
+
// GSD source rule (`/gsd:quick status`): a SUMMARY is complete only when its frontmatter says
|
|
191
|
+
// `status: complete`; when `status` is absent it reports `INCOMPLETE`. The compiler must preserve
|
|
192
|
+
// that distinction because an artifact can record a reviewed failure rather than completion.
|
|
193
|
+
const done = summaryStatus === "complete";
|
|
174
194
|
const files = strings(fm.files_modified).map((f) => f.replace(/^\.\//, ""));
|
|
175
195
|
assertWriteScope(file, `P${key}`, files, extractWriteDirectives(body, storedPath));
|
|
176
196
|
// fail closed (D-03): a silently dropped floor/pin routes the task cheap instead of erroring
|
package/dist/run/daemon.d.ts
CHANGED
|
@@ -18,7 +18,6 @@ export interface RunOptions {
|
|
|
18
18
|
mode?: RoutingMode;
|
|
19
19
|
narrate?: (event: JournalEvent) => void;
|
|
20
20
|
exit?: (code: number) => void;
|
|
21
|
-
supervise?: boolean;
|
|
22
21
|
}
|
|
23
22
|
export type ModeSource = "run flag" | "spec" | "repo config" | "global config" | "default";
|
|
24
23
|
export interface ResolvedRunMode {
|
package/dist/run/daemon.js
CHANGED
|
@@ -19,16 +19,15 @@ import { addEvidence, attributeBlocked, blockedTasks, getTask, graphDefinitionHa
|
|
|
19
19
|
import { GATE_NAMES } from "../graph/schema.js";
|
|
20
20
|
import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
|
|
21
21
|
import { runEnvironment } from "./environment.js";
|
|
22
|
-
import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, runWithForkBudget, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
|
|
22
|
+
import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, runWithForkBudget, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
|
|
23
23
|
import { runInteractiveSeed } from "./interactive-seed.js";
|
|
24
|
-
import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
|
|
24
|
+
import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingReviewFindings, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
|
|
25
25
|
import { isDiffCapPark } from "../gates/review.js";
|
|
26
26
|
import { acquireApprovalSerialization, acquireRunLock, releaseRunLock } from "./lock.js";
|
|
27
27
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
|
|
28
28
|
import { nextChannel, route } from "../route/router.js";
|
|
29
29
|
import { desiredPanes } from "./reconcile.js";
|
|
30
30
|
import { harvestCpuFlatWindowMs, NUDGEABLE_ADAPTERS, PANE_READ_ROWS, StallProgressTracker, stallSnapshotBannerRows, WorkerTreeCpuAccountant, } from "./stall.js";
|
|
31
|
-
import { armSupervision } from "./supervision.js";
|
|
32
31
|
// Compatibility exports for the daemon liveness tests and existing consumers. The implementation
|
|
33
32
|
// lives in stall.ts so gate dispatch can depend on it without importing the daemon.
|
|
34
33
|
export { harvestCpuFlatWindowMs, resetHarvestCpuFlatMsForTests, setHarvestCpuFlatMsForTests, workerTreeCpuMs, } from "./stall.js";
|
|
@@ -721,13 +720,9 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
721
720
|
// subprocess, so the optional-chain open below is a no-op there). Cosmetic-only: any failure is
|
|
722
721
|
// swallowed (never affects the run); the operator closes a surviving watch pane.
|
|
723
722
|
const lock = acquireRunLock(repoRoot, runId);
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
// process owns the run) and held to the last, so the beat's span is the run's span. armSupervision
|
|
728
|
-
// never throws, so an unwritable beat can never take a run down; it is deregistered in BOTH exits
|
|
729
|
-
// below, because the signal reaper exits the process before the finally can run.
|
|
730
|
-
const supervision = opts.supervise === false ? undefined : armSupervision(repoRoot, "orchestrator");
|
|
723
|
+
// D10: the lock is this daemon's liveness record and already carries its pid; status prints that
|
|
724
|
+
// identity beside the supervision row. The `orchestrator` tier belongs exclusively to the seated
|
|
725
|
+
// supervisor, so a run never beats or stands down that seat's record on the daemon's behalf.
|
|
731
726
|
// v1.54 T2: declared before the try so the finally can always deregister (a throw before
|
|
732
727
|
// registration leaves it undefined — the guard below covers that path).
|
|
733
728
|
let onTermination;
|
|
@@ -855,7 +850,6 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
855
850
|
}
|
|
856
851
|
catch { /* cosmetic — visibility is never a gate */ }
|
|
857
852
|
}
|
|
858
|
-
supervision?.disarm(); // T16: same reason as the lock — this seat stood down, it did not die
|
|
859
853
|
releaseRunLock(repoRoot); // the process dies at exit() below — the finally never runs on this path
|
|
860
854
|
}
|
|
861
855
|
abortRun(new Error(`terminated by ${sig}`));
|
|
@@ -1145,6 +1139,17 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1145
1139
|
await park(t, `humanGate: "${t.title}" requires approval before dispatch`, "human-gate", null, 0, startMs);
|
|
1146
1140
|
return;
|
|
1147
1141
|
}
|
|
1142
|
+
// The driver owns how a checkout is created, but runDaemon owns the destructive transition:
|
|
1143
|
+
// every task-checkout recreation passes through this wrapper before any driver can remove the
|
|
1144
|
+
// old path. A preservation failure throws and therefore leaves the old checkout in place. The
|
|
1145
|
+
// row is deliberately written before the later worktree-recreation row so the journal cannot
|
|
1146
|
+
// describe only the commits it carried while omitting uncommitted work the removal destroyed.
|
|
1147
|
+
const recreateTaskWorktree = async (taskBranch, taskBase, priorWt) => {
|
|
1148
|
+
const ref = await preserveWorktree(priorWt);
|
|
1149
|
+
if (ref)
|
|
1150
|
+
journal.append("worktree-preserved", t.id, { ref });
|
|
1151
|
+
return driver.worktree(repoRoot, taskBranch, taskBase);
|
|
1152
|
+
};
|
|
1148
1153
|
const r = route(t, cfg, channels, profile, undefined, demotedChannels);
|
|
1149
1154
|
for (const lint of r.lints)
|
|
1150
1155
|
journal.append("routing-lint", t.id, { lint });
|
|
@@ -1457,7 +1462,7 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1457
1462
|
const priorTaskTip = await gitHead(priorWt);
|
|
1458
1463
|
const priorTaskSubject = await gateCommitSubject(taskBase, priorTaskTip, priorWt);
|
|
1459
1464
|
const commitsToCarry = await commitsAheadOf(taskBase, priorWt);
|
|
1460
|
-
const wt = await
|
|
1465
|
+
const wt = await recreateTaskWorktree(taskBranch, taskBase, priorWt);
|
|
1461
1466
|
const carriedCommits = await cherryPickCommits(wt, commitsToCarry);
|
|
1462
1467
|
// Reuse is about the tree the gates will actually inspect. The integration tip may have moved
|
|
1463
1468
|
// while the daemon was down, so compare after recreating the task on today's taskBase rather
|
|
@@ -1753,6 +1758,22 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1753
1758
|
const brief = journaledRows.join("\n\n");
|
|
1754
1759
|
feedback = feedback ? `${brief}\n\n${feedback}` : brief;
|
|
1755
1760
|
}
|
|
1761
|
+
// T6: both carries above are ATTEMPT-scoped — the funded repair is spent at the next
|
|
1762
|
+
// worker-launch (and budgeted at two), and the journaled brief is reset there too, so it hands
|
|
1763
|
+
// this dispatch only the LAST attempt's bytes. An unresolved review finding is a property of the
|
|
1764
|
+
// TASK: the moment one attempt fails for an unrelated reason — a red build, a refused tree, or a
|
|
1765
|
+
// death that journals no gate row at all — the finding is in neither carry and the next worker
|
|
1766
|
+
// re-derives the task from the spec and lands on the same gap the reviewer already anchored.
|
|
1767
|
+
// Re-derived from the journal on EVERY dispatch and retired only by a review that passes on this
|
|
1768
|
+
// task (journal.ts `outstandingReviewFindings`). Appended row-wise, because this round's own
|
|
1769
|
+
// feedback or a repair brief may already quote a finding and repeating it helps no worker.
|
|
1770
|
+
const outstandingFindings = outstandingReviewFindings(journaledSoFar, t.id);
|
|
1771
|
+
const unquoted = outstandingFindings.filter((f) => !feedback.includes(f.note));
|
|
1772
|
+
if (unquoted.length > 0) {
|
|
1773
|
+
const brief = ["## Outstanding review findings — a review has NOT passed on these yet",
|
|
1774
|
+
...unquoted.map((f) => `- ${f.path}: ${f.note}`)].join("\n");
|
|
1775
|
+
feedback = feedback ? `${feedback}\n\n${brief}` : brief;
|
|
1776
|
+
}
|
|
1756
1777
|
retryMode = repairFindings
|
|
1757
1778
|
? "repair"
|
|
1758
1779
|
: priorSession
|
|
@@ -1775,21 +1796,29 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
1775
1796
|
lastContextTokens = undefined;
|
|
1776
1797
|
graph = setStatus(graph, t.id, "running");
|
|
1777
1798
|
saveGraph(repoRoot, graph);
|
|
1778
|
-
|
|
1799
|
+
// T6: a dispatch that carries an outstanding finding says so, and names it. Without this the
|
|
1800
|
+
// ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
|
|
1801
|
+
// spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
|
|
1802
|
+
journal.append("task-dispatch", t.id, {
|
|
1803
|
+
assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode,
|
|
1804
|
+
...(outstandingFindings.length > 0 ? { carriedFindings: outstandingFindings } : {}),
|
|
1805
|
+
});
|
|
1779
1806
|
journal.phaseStart(t.id, "worker", { attempt, assignment });
|
|
1780
1807
|
const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
|
|
1781
1808
|
const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
|
|
1782
1809
|
const priorWt = worktreePath(repoRoot, taskBranch);
|
|
1783
|
-
const
|
|
1784
|
-
const
|
|
1810
|
+
const recreating = existsSync(priorWt);
|
|
1811
|
+
const commitsToCarry = recreating ? await commitsAheadOf(taskBase, priorWt) : [];
|
|
1812
|
+
const wt = await recreateTaskWorktree(taskBranch, taskBase, priorWt);
|
|
1785
1813
|
// OBS-58: quota-failover and every retry recreate the task worktree from the integration tip —
|
|
1786
1814
|
// cherry-pick prior attempts' landed commits forward so a failover dispatch cannot silently
|
|
1787
1815
|
// orphan work a consult already verified as landed.
|
|
1788
1816
|
let carriedCommits = [];
|
|
1789
1817
|
if (commitsToCarry.length > 0) {
|
|
1790
1818
|
carriedCommits = await cherryPickCommits(wt, commitsToCarry);
|
|
1791
|
-
journal.append("worktree-recreation", t.id, { attempted: commitsToCarry, carried: carriedCommits });
|
|
1792
1819
|
}
|
|
1820
|
+
if (recreating)
|
|
1821
|
+
journal.append("worktree-recreation", t.id, { attempted: commitsToCarry, carried: carriedCommits });
|
|
1793
1822
|
// T2 review (material): harvest eligibility is "does this WORKTREE carry unverified work",
|
|
1794
1823
|
// measured against taskBase — the same base the fast-kill's delta probe and the gates
|
|
1795
1824
|
// themselves use. It was measured against this attempt's post-carry HEAD, which excluded
|
|
@@ -2892,6 +2921,15 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
2892
2921
|
if (results.some((g) => g.gate === "test" && !g.pass))
|
|
2893
2922
|
testGateFailed = true;
|
|
2894
2923
|
if (results.every(gateSatisfied)) {
|
|
2924
|
+
// T6: every gate — the review included — is satisfied on this commit, so the failure brief
|
|
2925
|
+
// this loop is still holding describes nothing outstanding. It is dropped HERE, before the
|
|
2926
|
+
// merge, because a conflict below sends the task around the attempt loop again: a brief kept
|
|
2927
|
+
// across that retry hands the next worker findings a later review has since passed on, while
|
|
2928
|
+
// `carriedFindings` — re-derived from the journal, which retired them — is correctly empty,
|
|
2929
|
+
// leaving that dispatch's row indistinguishable from an amnesiac one. Rebuilt exactly as the
|
|
2930
|
+
// gate-fail brief is, so prior-RUN evidence (retired by its own rule, not by this reviewer)
|
|
2931
|
+
// survives and only this run's settled findings go.
|
|
2932
|
+
feedback = withCarriedEvidence("");
|
|
2895
2933
|
const m = await mergeSerial(taskBranch, t, gated);
|
|
2896
2934
|
if (m.tipMoved) {
|
|
2897
2935
|
journal.append("tip-moved", t.id, m.tipMoved);
|
|
@@ -3188,9 +3226,6 @@ export async function runDaemon(repoRoot, opts = {}) {
|
|
|
3188
3226
|
process.removeListener("SIGINT", onTermination);
|
|
3189
3227
|
process.removeListener("SIGTERM", onTermination);
|
|
3190
3228
|
}
|
|
3191
|
-
// T16: every other exit — normal end, throw, termination unwind. disarm() is idempotent, so the
|
|
3192
|
-
// signal path having already stood the tier down changes nothing here.
|
|
3193
|
-
supervision?.disarm();
|
|
3194
3229
|
try {
|
|
3195
3230
|
releaseRunLock(repoRoot);
|
|
3196
3231
|
}
|
package/dist/run/git.d.ts
CHANGED
|
@@ -63,6 +63,21 @@ export declare function cleanupRunWorktrees(repo: string, branch: string, opts:
|
|
|
63
63
|
removeTaskIds: string[];
|
|
64
64
|
}): Promise<void>;
|
|
65
65
|
export declare function resolveIntegrationBranch(_repo: string, branch: string): Promise<string>;
|
|
66
|
+
/**
|
|
67
|
+
* Preserve the bytes an existing checkout holds before recreation removes it.
|
|
68
|
+
*
|
|
69
|
+
* `git stash create` cannot do this job: its apparent `-u` argument is accepted as a message and
|
|
70
|
+
* untracked files never enter the stash object. Build the snapshot through a disposable index
|
|
71
|
+
* instead. The index starts at HEAD (so no unrelated residue from the checkout's real index enters
|
|
72
|
+
* the tree), stages the complete working tree including ordinary untracked paths, and lives outside
|
|
73
|
+
* the repository so it cannot stage itself. None of these plumbing commands writes the checkout or
|
|
74
|
+
* its real index.
|
|
75
|
+
*
|
|
76
|
+
* The returned ref is the durable recovery handle. A clean checkout returns undefined and creates
|
|
77
|
+
* neither a commit nor a ref, keeping meaningful deaths visible rather than minting one ref per
|
|
78
|
+
* ordinary dispatch.
|
|
79
|
+
*/
|
|
80
|
+
export declare function preserveWorktree(cwd: string): Promise<string | undefined>;
|
|
66
81
|
export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
|
|
67
82
|
export declare function linkNodeModules(repo: string, dir: string, { force }?: {
|
|
68
83
|
force?: boolean | undefined;
|
package/dist/run/git.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { availableParallelism } from "node:os";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { availableParallelism, tmpdir } from "node:os";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
|
+
import { StringDecoder } from "node:string_decoder";
|
|
6
7
|
import { shq } from "../adapters/types.js";
|
|
7
8
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
8
9
|
import { ROUTING_ENV_SEAMS } from "../route/router.js";
|
|
@@ -109,12 +110,16 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
109
110
|
// open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
|
|
110
111
|
const p = (spawnChild ?? spawn)("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
111
112
|
let stdout = "", stderr = "";
|
|
112
|
-
|
|
113
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
114
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
115
|
+
let timedOut = false, done = false, started = false, outputSeen = false;
|
|
113
116
|
const finish = (code, err) => {
|
|
114
117
|
if (done)
|
|
115
118
|
return;
|
|
116
119
|
done = true;
|
|
117
120
|
clearTimeout(timer);
|
|
121
|
+
stdout += stdoutDecoder.end();
|
|
122
|
+
stderr += stderrDecoder.end();
|
|
118
123
|
resolve({ code, stdout, stderr: err ?? stderr, timedOut, durationMs: Date.now() - startedAt });
|
|
119
124
|
};
|
|
120
125
|
const timer = setTimeout(() => {
|
|
@@ -127,10 +132,23 @@ function shell(cmd, cwd, timeoutMs, login) {
|
|
|
127
132
|
}
|
|
128
133
|
}, timeoutMs);
|
|
129
134
|
p.on("spawn", () => { started = true; }); // the command exists from here on — never retryable past it
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
// OBS-716: one stateful decoder per stream carries an incomplete UTF-8 sequence into that
|
|
136
|
+
// stream's next pipe chunk; decoding each chunk through string concatenation corrupts bytes at
|
|
137
|
+
// kernel-chosen boundaries. A deterministic fixture proves this decoder correct rather than
|
|
138
|
+
// proving every caller byte-safe: real chunk boundaries are the kernel's to choose, so timing
|
|
139
|
+
// still decides whether a chunk-local decoder exposes the defect in any particular run.
|
|
140
|
+
p.stdout.on("data", (d) => {
|
|
141
|
+
if (d.length > 0)
|
|
142
|
+
outputSeen = true;
|
|
143
|
+
stdout += stdoutDecoder.write(d);
|
|
144
|
+
});
|
|
145
|
+
p.stderr.on("data", (d) => {
|
|
146
|
+
if (d.length > 0)
|
|
147
|
+
outputSeen = true;
|
|
148
|
+
stderr += stderrDecoder.write(d);
|
|
149
|
+
});
|
|
132
150
|
p.on("error", (e) => {
|
|
133
|
-
if (!done && !started && !
|
|
151
|
+
if (!done && !started && !outputSeen && e.code === RETRYABLE_SPAWN_CODE) {
|
|
134
152
|
done = true;
|
|
135
153
|
clearTimeout(timer);
|
|
136
154
|
resolve({ refused: e });
|
|
@@ -260,6 +278,46 @@ const resolveTaskBranch = async (repo, branch) => {
|
|
|
260
278
|
const integration = await resolveIntegrationBranch(repo, branch.slice(0, split));
|
|
261
279
|
return `${integration}${branch.slice(split)}`;
|
|
262
280
|
};
|
|
281
|
+
/**
|
|
282
|
+
* Preserve the bytes an existing checkout holds before recreation removes it.
|
|
283
|
+
*
|
|
284
|
+
* `git stash create` cannot do this job: its apparent `-u` argument is accepted as a message and
|
|
285
|
+
* untracked files never enter the stash object. Build the snapshot through a disposable index
|
|
286
|
+
* instead. The index starts at HEAD (so no unrelated residue from the checkout's real index enters
|
|
287
|
+
* the tree), stages the complete working tree including ordinary untracked paths, and lives outside
|
|
288
|
+
* the repository so it cannot stage itself. None of these plumbing commands writes the checkout or
|
|
289
|
+
* its real index.
|
|
290
|
+
*
|
|
291
|
+
* The returned ref is the durable recovery handle. A clean checkout returns undefined and creates
|
|
292
|
+
* neither a commit nor a ref, keeping meaningful deaths visible rather than minting one ref per
|
|
293
|
+
* ordinary dispatch.
|
|
294
|
+
*/
|
|
295
|
+
export async function preserveWorktree(cwd) {
|
|
296
|
+
if (!existsSync(cwd))
|
|
297
|
+
return undefined;
|
|
298
|
+
const scratch = mkdtempSync(join(tmpdir(), "tickmarkr-preserve-index-"));
|
|
299
|
+
const index = join(scratch, "index");
|
|
300
|
+
const withIndex = (command) => `GIT_INDEX_FILE=${shq(index)} ${command}`;
|
|
301
|
+
try {
|
|
302
|
+
await shGitOk(withIndex("git read-tree HEAD"), cwd);
|
|
303
|
+
await shGitOk(withIndex("git add -A -- ."), cwd);
|
|
304
|
+
const tree = (await shGitOk(withIndex("git write-tree"), cwd)).trim();
|
|
305
|
+
const headTree = (await shGitOk("git rev-parse 'HEAD^{tree}'", cwd)).trim();
|
|
306
|
+
if (tree === headTree)
|
|
307
|
+
return undefined;
|
|
308
|
+
// Do not depend on consumer-level identity configuration: this is an engine recovery object,
|
|
309
|
+
// not an authored project commit. Its HEAD parent makes the preserved tree directly inspectable.
|
|
310
|
+
const identity = "GIT_AUTHOR_NAME=tickmarkr GIT_AUTHOR_EMAIL=tickmarkr@localhost "
|
|
311
|
+
+ "GIT_COMMITTER_NAME=tickmarkr GIT_COMMITTER_EMAIL=tickmarkr@localhost";
|
|
312
|
+
const commit = (await shGitOk(`${identity} git commit-tree ${shq(tree)} -p HEAD -m ${shq("tickmarkr: preserve uncommitted worktree")}`, cwd)).trim();
|
|
313
|
+
const ref = `refs/tickmarkr/preserved/${commit}`;
|
|
314
|
+
await shGitOk(`git update-ref ${shq(ref)} ${shq(commit)}`, cwd);
|
|
315
|
+
return ref;
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
263
321
|
export async function createWorktree(repo, branch, baseRef) {
|
|
264
322
|
branch = await resolveTaskBranch(repo, branch);
|
|
265
323
|
const dir = join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
|
package/dist/run/journal.d.ts
CHANGED
|
@@ -104,6 +104,30 @@ export declare function repairsSinceApproval(events: JournalEvent[], taskId: str
|
|
|
104
104
|
* retires the findings it settled — the uphold case re-derives its own brief separately).
|
|
105
105
|
*/
|
|
106
106
|
export declare function journaledFailureBrief(events: JournalEvent[], taskId: string): string[];
|
|
107
|
+
/**
|
|
108
|
+
* T6: the review findings still OUTSTANDING on a task. A review finding is a property of the TASK,
|
|
109
|
+
* not of the attempt that drew it: it stays outstanding until a later review PASSES on the task (or
|
|
110
|
+
* an operator approval settles it), and it therefore travels on EVERY dispatch until then.
|
|
111
|
+
*
|
|
112
|
+
* The two carries beside this one are attempt-scoped by construction and both lose it. The funded
|
|
113
|
+
* repair (`pendingRepairFindings`) is spent at the next `worker-launch` and is budgeted at two per
|
|
114
|
+
* engagement; `journaledFailureBrief` is reset at that same launch, so it hands the next brief only
|
|
115
|
+
* the LAST attempt's bytes. The moment one attempt fails for an unrelated reason — a red build, a
|
|
116
|
+
* refused tree, or a death that produces no verdict at all and journals no gate row whatsoever — the
|
|
117
|
+
* outstanding finding is in neither carry, and the run re-derives the task from the spec and lands on
|
|
118
|
+
* the same gap the reviewer already anchored.
|
|
119
|
+
*
|
|
120
|
+
* Retirement is closed and narrow: a review that PASSED, or the one approval that accepts the review
|
|
121
|
+
* gate itself (`GATE_SATISFIED_RELEASE` stamped `gate: "review"` — the operator taking the diff the
|
|
122
|
+
* reviewer rejected). Every other approval RETAINS. `--uphold` funds an attempt to FIX the findings;
|
|
123
|
+
* `--recheck` and an attempt-cap release fund another dispatch and say nothing about the reviewer's
|
|
124
|
+
* objection; a plain human-gate approval predates any review; a gate-satisfied release naming some
|
|
125
|
+
* other gate settled that gate, not this one. Reading "approved" as "settled" is how a still-open
|
|
126
|
+
* finding was dropped at the exact moment the operator paid for another attempt to fix it. A review
|
|
127
|
+
* that DECLINED (`skipped`) is not a verdict and neither adds nor retires — fail closed. Findings are
|
|
128
|
+
* keyed by fingerprint, so a reviewer restating one across rounds carries it once, not once per round.
|
|
129
|
+
*/
|
|
130
|
+
export declare function outstandingReviewFindings(events: JournalEvent[], taskId: string): StructuredFinding[];
|
|
107
131
|
/** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
|
|
108
132
|
export declare function pendingRepairFindings(events: JournalEvent[], taskId: string): string | undefined;
|
|
109
133
|
/**
|
package/dist/run/journal.js
CHANGED
|
@@ -87,6 +87,13 @@ export function upheldFeedbackByTask(events) {
|
|
|
87
87
|
&& typeof e.data.details === "string") {
|
|
88
88
|
lastReviewFail.set(e.taskId, e.data.details);
|
|
89
89
|
}
|
|
90
|
+
else if (e.event === "gate-result" && e.data.gate === "review" && e.data.pass === true
|
|
91
|
+
&& e.data.skipped !== true) {
|
|
92
|
+
// A later review pass settles the upheld finding. Retire both the active brief and the failed
|
|
93
|
+
// verdict it came from so a still-later approval cannot resurrect already-settled feedback.
|
|
94
|
+
upheld.delete(e.taskId);
|
|
95
|
+
lastReviewFail.delete(e.taskId);
|
|
96
|
+
}
|
|
90
97
|
else if (e.event === "task-approved") {
|
|
91
98
|
// any later approval supersedes: a plain accept-the-diff approval retires the uphold brief.
|
|
92
99
|
if (e.data.release === REVIEW_UPHELD_RELEASE) {
|
|
@@ -480,6 +487,49 @@ export function journaledFailureBrief(events, taskId) {
|
|
|
480
487
|
}
|
|
481
488
|
return rows;
|
|
482
489
|
}
|
|
490
|
+
/**
|
|
491
|
+
* T6: the review findings still OUTSTANDING on a task. A review finding is a property of the TASK,
|
|
492
|
+
* not of the attempt that drew it: it stays outstanding until a later review PASSES on the task (or
|
|
493
|
+
* an operator approval settles it), and it therefore travels on EVERY dispatch until then.
|
|
494
|
+
*
|
|
495
|
+
* The two carries beside this one are attempt-scoped by construction and both lose it. The funded
|
|
496
|
+
* repair (`pendingRepairFindings`) is spent at the next `worker-launch` and is budgeted at two per
|
|
497
|
+
* engagement; `journaledFailureBrief` is reset at that same launch, so it hands the next brief only
|
|
498
|
+
* the LAST attempt's bytes. The moment one attempt fails for an unrelated reason — a red build, a
|
|
499
|
+
* refused tree, or a death that produces no verdict at all and journals no gate row whatsoever — the
|
|
500
|
+
* outstanding finding is in neither carry, and the run re-derives the task from the spec and lands on
|
|
501
|
+
* the same gap the reviewer already anchored.
|
|
502
|
+
*
|
|
503
|
+
* Retirement is closed and narrow: a review that PASSED, or the one approval that accepts the review
|
|
504
|
+
* gate itself (`GATE_SATISFIED_RELEASE` stamped `gate: "review"` — the operator taking the diff the
|
|
505
|
+
* reviewer rejected). Every other approval RETAINS. `--uphold` funds an attempt to FIX the findings;
|
|
506
|
+
* `--recheck` and an attempt-cap release fund another dispatch and say nothing about the reviewer's
|
|
507
|
+
* objection; a plain human-gate approval predates any review; a gate-satisfied release naming some
|
|
508
|
+
* other gate settled that gate, not this one. Reading "approved" as "settled" is how a still-open
|
|
509
|
+
* finding was dropped at the exact moment the operator paid for another attempt to fix it. A review
|
|
510
|
+
* that DECLINED (`skipped`) is not a verdict and neither adds nor retires — fail closed. Findings are
|
|
511
|
+
* keyed by fingerprint, so a reviewer restating one across rounds carries it once, not once per round.
|
|
512
|
+
*/
|
|
513
|
+
export function outstandingReviewFindings(events, taskId) {
|
|
514
|
+
const open = new Map();
|
|
515
|
+
for (const e of events) {
|
|
516
|
+
if (e.taskId !== taskId)
|
|
517
|
+
continue;
|
|
518
|
+
if (e.event === "task-approved") {
|
|
519
|
+
if (e.data.release === GATE_SATISFIED_RELEASE && e.data.gate === "review")
|
|
520
|
+
open.clear();
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (e.event !== "gate-result" || e.data.gate !== "review" || e.data.skipped === true)
|
|
524
|
+
continue;
|
|
525
|
+
if (e.data.pass !== false)
|
|
526
|
+
open.clear(); // a later review PASSED on this task: nothing is outstanding
|
|
527
|
+
else
|
|
528
|
+
for (const finding of findingRows(e, "review"))
|
|
529
|
+
open.set(finding.fingerprint, finding);
|
|
530
|
+
}
|
|
531
|
+
return [...open.values()];
|
|
532
|
+
}
|
|
483
533
|
/** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
|
|
484
534
|
export function pendingRepairFindings(events, taskId) {
|
|
485
535
|
const e = decisionForNextDispatch(events, taskId, "repair-attempt");
|
|
@@ -1055,12 +1105,16 @@ export class Journal {
|
|
|
1055
1105
|
}
|
|
1056
1106
|
replayResumeState() {
|
|
1057
1107
|
const m = new Map();
|
|
1108
|
+
const events = this.read();
|
|
1109
|
+
// Keep the legacy resume-state field aligned with the journal-authoritative prompt-time fold.
|
|
1110
|
+
// In particular, a review pass after an uphold must erase the fallback daemon.ts may consult.
|
|
1111
|
+
const activeUpheldFeedback = upheldFeedbackByTask(events);
|
|
1058
1112
|
const pendingReroute = new Set(); // reroute verdicts not yet cleared by a later dispatch
|
|
1059
1113
|
// OBS-547: what the last dispatch ADDED, so a scope-authoring event can take it back. An
|
|
1060
1114
|
// unchargeable dispatch must replay as if it never happened — no attempt counted, no channel burned.
|
|
1061
1115
|
const lastDispatch = new Map();
|
|
1062
1116
|
const lastReviewFail = new Map(); // OBS-189: newest failed review details per task
|
|
1063
|
-
for (const e of
|
|
1117
|
+
for (const e of events) {
|
|
1064
1118
|
if (!e.taskId)
|
|
1065
1119
|
continue;
|
|
1066
1120
|
if (e.event === "gate-result" && e.data.gate === "review" && e.data.pass === false
|
|
@@ -1160,6 +1214,13 @@ export class Journal {
|
|
|
1160
1214
|
st.lastAssignment = undefined;
|
|
1161
1215
|
}
|
|
1162
1216
|
}
|
|
1217
|
+
for (const [taskId, st] of m) {
|
|
1218
|
+
const feedback = activeUpheldFeedback.get(taskId);
|
|
1219
|
+
if (feedback)
|
|
1220
|
+
st.upheldFeedback = feedback;
|
|
1221
|
+
else
|
|
1222
|
+
delete st.upheldFeedback;
|
|
1223
|
+
}
|
|
1163
1224
|
return m;
|
|
1164
1225
|
}
|
|
1165
1226
|
// OBS-130: gate satisfaction is authority, not an inferred daemon state. Only an explicit
|
|
@@ -5,7 +5,7 @@ export declare const SUPERVISION_STALE_MS: number;
|
|
|
5
5
|
export declare const SUPERVISION_FUTURE_GRACE_MS = 1000;
|
|
6
6
|
export declare const SUPERVISION_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch"];
|
|
7
7
|
export type SupervisionTier = (typeof SUPERVISION_TIERS)[number];
|
|
8
|
-
export declare const SUPERVISION_SEAT_TIERS: readonly ["orchestrator-context", "overseer-context"];
|
|
8
|
+
export declare const SUPERVISION_SEAT_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context"];
|
|
9
9
|
export type SeatTier = (typeof SUPERVISION_SEAT_TIERS)[number];
|
|
10
10
|
/** Does this tier's record have to name the seat it speaks for? */
|
|
11
11
|
export declare const isSeatTier: (tier: string) => tier is SeatTier;
|
package/dist/run/supervision.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import { stateDirName, tickmarkrDir } from "../graph/graph.js";
|
|
4
5
|
// SUP-01: supervision liveness as FILE STATE, not as a report — lock.ts's proven shape, one file per
|
|
@@ -32,8 +33,9 @@ export const SUPERVISION_FUTURE_GRACE_MS = 1_000;
|
|
|
32
33
|
// SUP-05: CONTEXT is per SEAT, so its tiers are per seat too. One shared `context` tier would be read
|
|
33
34
|
// by both supervising seats and beaten by whichever of them still had a watcher, so a live overseer
|
|
34
35
|
// watcher would render the dead orchestrator one as armed — the mask this whole instrument exists to
|
|
35
|
-
// remove. The enumeration is CLOSED at one tier
|
|
36
|
-
// `watch`
|
|
36
|
+
// remove. The enumeration is CLOSED at one supervision tier and one context tier per supervising
|
|
37
|
+
// seat: orchestrator and overseer. `watch` is the sole process-owned tier and is armed seatlessly by
|
|
38
|
+
// the live unbounded board.
|
|
37
39
|
export const SUPERVISION_TIERS = [
|
|
38
40
|
"orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch",
|
|
39
41
|
];
|
|
@@ -42,18 +44,59 @@ export const SUPERVISION_TIERS = [
|
|
|
42
44
|
// to a seat. Measured 2026-08-26: a consult seat of another tier ran the documented beat loop and the
|
|
43
45
|
// board read that tier armed with no seat of that tier having armed anything. ARMED-and-seatless reads
|
|
44
46
|
// as coverage, which is worse than ABSENT, so on these tiers a record that names no seat is not a beat.
|
|
45
|
-
export const SUPERVISION_SEAT_TIERS = [
|
|
47
|
+
export const SUPERVISION_SEAT_TIERS = [
|
|
48
|
+
"orchestrator", "orchestrator-context", "overseer", "overseer-context",
|
|
49
|
+
];
|
|
46
50
|
/** Does this tier's record have to name the seat it speaks for? */
|
|
47
51
|
export const isSeatTier = (tier) => SUPERVISION_SEAT_TIERS.includes(tier);
|
|
48
52
|
// PURE path math: stateDirName, never tickmarkrDir — the latter mkdirs the state dir and writes its
|
|
49
53
|
// .gitignore, so routing a READER through it would make status create the very tree it reports on.
|
|
50
|
-
|
|
54
|
+
const supervisionDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "supervision");
|
|
55
|
+
export const supervisionBeatPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.beat`);
|
|
51
56
|
/** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
|
|
52
|
-
export const supervisionStandDownPath = (repoRoot, tier) => join(
|
|
57
|
+
export const supervisionStandDownPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.standdown`);
|
|
58
|
+
// SUP-06: PRESENCE — one file per ARMED WATCHER, because a tier may legitimately have more than one.
|
|
59
|
+
// Two boards watch one repo the moment an operator opens a second pane, and the tier is armed while
|
|
60
|
+
// EITHER of them lives. The stand-down marker speaks for the whole tier, so the first board out
|
|
61
|
+
// writing it renders the second board's own tier DISARMED until that board's next beat — a live seat
|
|
62
|
+
// reported down, which is the under-claiming half of exactly the lie this instrument exists to remove.
|
|
63
|
+
// So the marker is written only by the LAST watcher out, and these files are how it knows it is last.
|
|
64
|
+
// Freshness decides presence, never a process table (SUP-02): a watcher that is killed cannot remove
|
|
65
|
+
// its own file, and an unremoved file ages past the same ceiling a beat does and stops counting.
|
|
66
|
+
const presencePrefix = (tier) => `${tier}.live.`;
|
|
67
|
+
const supervisionPresencePath = (repoRoot, tier, id) => join(supervisionDir(repoRoot), `${presencePrefix(tier)}${id}`);
|
|
68
|
+
/** Every presence file on this tier, by name. Missing directory ⇒ nobody is present. */
|
|
69
|
+
const presenceNames = (repoRoot, tier) => {
|
|
70
|
+
try {
|
|
71
|
+
return readdirSync(supervisionDir(repoRoot)).filter((n) => n.startsWith(presencePrefix(tier)));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Stale peers observed in ONE directory snapshot, or undefined when that same snapshot saw a live
|
|
79
|
+
* one. A later arm has a new id and is deliberately absent from the returned cleanup set.
|
|
80
|
+
*/
|
|
81
|
+
function stalePeersIfLast(repoRoot, tier, id, now = Date.now()) {
|
|
82
|
+
const own = `${presencePrefix(tier)}${id}`;
|
|
83
|
+
const stale = [];
|
|
84
|
+
for (const name of presenceNames(repoRoot, tier)) {
|
|
85
|
+
if (name === own)
|
|
86
|
+
continue;
|
|
87
|
+
try {
|
|
88
|
+
if (now - statSync(join(supervisionDir(repoRoot), name)).mtimeMs <= SUPERVISION_STALE_MS)
|
|
89
|
+
return undefined;
|
|
90
|
+
stale.push(name);
|
|
91
|
+
}
|
|
92
|
+
catch { /* a vanished peer needs no cleanup and is not evidence of a live watcher */ }
|
|
93
|
+
}
|
|
94
|
+
return stale;
|
|
95
|
+
}
|
|
53
96
|
// WRITER — a watcher's own call, on its own tier, every SUPERVISION_BEAT_MS. Never a reader's: the
|
|
54
97
|
// purity fence (status --watch leaves the state dir byte-identical) is the test that catches a reader
|
|
55
98
|
// that beats on the watcher's behalf, which would report every dead tier as healthy.
|
|
56
|
-
|
|
99
|
+
function writeSupervisionBeat(repoRoot, tier, seat, armId) {
|
|
57
100
|
// A seat tier may not be armed anonymously, and the refusal belongs HERE rather than only in the
|
|
58
101
|
// verb: any caller that could write a seatless record could arm a tier nobody occupies.
|
|
59
102
|
if (isSeatTier(tier) && !seat?.trim()) {
|
|
@@ -62,7 +105,13 @@ export function beatSupervision(repoRoot, tier, seat) {
|
|
|
62
105
|
tickmarkrDir(repoRoot); // the write path DOES create — beats land inside the gitignored state dir
|
|
63
106
|
const p = supervisionBeatPath(repoRoot, tier);
|
|
64
107
|
mkdirSync(dirname(p), { recursive: true });
|
|
65
|
-
writeFileSync(p, JSON.stringify({
|
|
108
|
+
writeFileSync(p, JSON.stringify({
|
|
109
|
+
tier, ...(seat ? { seat } : {}), ...(armId ? { armId } : {}),
|
|
110
|
+
pid: process.pid, beatAt: new Date().toISOString(),
|
|
111
|
+
}) + "\n");
|
|
112
|
+
}
|
|
113
|
+
export function beatSupervision(repoRoot, tier, seat) {
|
|
114
|
+
writeSupervisionBeat(repoRoot, tier, seat);
|
|
66
115
|
}
|
|
67
116
|
// THE WATCHER-FACING ENTRY POINT — the loop SUPERVISION_BEAT_MS actually drives. A supervising seat
|
|
68
117
|
// calls this once at the top of its watch and holds the handle for the duration; a seat that dies,
|
|
@@ -71,9 +120,11 @@ export function beatSupervision(repoRoot, tier, seat) {
|
|
|
71
120
|
// an unref'd interval that never holds the watcher's event loop open, and a beat failure that is
|
|
72
121
|
// swallowed rather than crashing the watcher — an unwritten beat ages out and reads STALE, which is
|
|
73
122
|
// the truth. The FIRST beat is swallowed on the same rule: a cosmetic instrument that could not write
|
|
74
|
-
// must not take the
|
|
75
|
-
//
|
|
76
|
-
//
|
|
123
|
+
// must not take the watcher down with it. The sole production in-repo callsite is `status --watch`
|
|
124
|
+
// when UNBOUNDED, which arms `watch` seatlessly for the life of the board — a bounded render is a
|
|
125
|
+
// reader and arms nothing, which is the purity fence D-02 tests. Supervising seats write their named
|
|
126
|
+
// tiers through the shipped beat verb instead. A tier nobody arms reads ABSENT — exactly what ABSENT
|
|
127
|
+
// means, not a false "healthy".
|
|
77
128
|
//
|
|
78
129
|
// Arming CLEARS any prior stand-down record: a tier that stood down and armed again is armed, and a
|
|
79
130
|
// marker left behind by the last run would otherwise report the live one as stood down forever.
|
|
@@ -86,40 +137,64 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
|
|
|
86
137
|
rmSync(supervisionStandDownPath(repoRoot, tier), { force: true, recursive: true });
|
|
87
138
|
}
|
|
88
139
|
catch { /* uncleared: the reader validates the marker and a newer beat outranks it — never masked */ }
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
140
|
+
// This watcher's own identity fences BOTH its presence and its stand-down against every later arm.
|
|
141
|
+
// pid alone collides between two boards in one host (and after pid reuse); a UUID never aliases the
|
|
142
|
+
// stale presence of a killed process that a later last-one-out cleanup may already have observed.
|
|
143
|
+
const id = `${process.pid}.${randomUUID()}`;
|
|
144
|
+
const presence = supervisionPresencePath(repoRoot, tier, id);
|
|
145
|
+
// Presence is refreshed with the beat, so it ages by the same clock and needs no separate loop.
|
|
146
|
+
const mark = () => {
|
|
94
147
|
try {
|
|
95
|
-
|
|
148
|
+
writeSupervisionBeat(repoRoot, tier, seat, id); // creates the directory presence is written into
|
|
149
|
+
writeFileSync(presence, JSON.stringify({ tier, pid: process.pid, id }) + "\n");
|
|
96
150
|
}
|
|
97
|
-
catch { /* repo gone / disk full —
|
|
98
|
-
}
|
|
151
|
+
catch { /* repo gone / disk full / no seat — the tier ages out rather than crashing its watcher */ }
|
|
152
|
+
};
|
|
153
|
+
mark();
|
|
154
|
+
const timer = setInterval(mark, beatMs);
|
|
99
155
|
timer.unref();
|
|
100
156
|
let stoodDown = false;
|
|
101
157
|
return {
|
|
102
|
-
// Stand down: stop beating AND say so.
|
|
103
|
-
// exit
|
|
104
|
-
// instant belongs to the first stand-down.
|
|
158
|
+
// Stand down: stop beating AND say so. Idempotence lets a watcher safely share cleanup across
|
|
159
|
+
// multiple exit paths; the recorded instant belongs to the first stand-down.
|
|
105
160
|
disarm: () => {
|
|
106
161
|
if (stoodDown)
|
|
107
162
|
return;
|
|
108
163
|
stoodDown = true;
|
|
109
164
|
clearInterval(timer);
|
|
165
|
+
try {
|
|
166
|
+
rmSync(presence, { force: true, recursive: true });
|
|
167
|
+
}
|
|
168
|
+
catch { /* ages out on its own */ }
|
|
169
|
+
// The marker speaks for the TIER, so only the last watcher out may write one: a peer still
|
|
170
|
+
// present means the tier is not down, and saying it is would render that live board's own tier
|
|
171
|
+
// DISARMED. The snapshot also fixes the cleanup set: a board arming after this decision receives
|
|
172
|
+
// a new id, so this older board can neither sweep its presence nor claim its beat stood down.
|
|
173
|
+
const stalePeers = stalePeersIfLast(repoRoot, tier, id);
|
|
174
|
+
if (stalePeers === undefined)
|
|
175
|
+
return;
|
|
110
176
|
// Published ATOMICALLY — written aside, renamed over — so no reader can ever meet a half-written
|
|
111
177
|
// marker. A torn marker is rejected anyway (see readStandDown), but a stand-down that reads as
|
|
112
178
|
// garbage is a stand-down that reports as a death, and the rename costs one line.
|
|
113
179
|
const p = supervisionStandDownPath(repoRoot, tier);
|
|
114
|
-
const tmp = `${p}.${
|
|
180
|
+
const tmp = `${p}.${id}.tmp`;
|
|
115
181
|
try {
|
|
116
182
|
mkdirSync(dirname(p), { recursive: true });
|
|
117
183
|
writeFileSync(tmp, JSON.stringify({
|
|
118
|
-
tier, ...(seat ? { seat } : {}),
|
|
184
|
+
tier, ...(seat ? { seat } : {}), armId: id,
|
|
185
|
+
pid: process.pid, disarmedAt: new Date().toISOString(),
|
|
119
186
|
}) + "\n");
|
|
120
187
|
renameSync(tmp, p);
|
|
121
188
|
}
|
|
122
189
|
catch { /* unrecordable stand-down ages out as STALE — pessimistic, which is the safe way to fail */ }
|
|
190
|
+
// Sweep only stale names in the pre-publication snapshot. Re-reading here used to catch and
|
|
191
|
+
// delete a newer board that armed between the peer check and this older board's rename.
|
|
192
|
+
for (const name of stalePeers) {
|
|
193
|
+
try {
|
|
194
|
+
rmSync(join(supervisionDir(repoRoot), name), { force: true, recursive: true });
|
|
195
|
+
}
|
|
196
|
+
catch { /* next sweep */ }
|
|
197
|
+
}
|
|
123
198
|
},
|
|
124
199
|
};
|
|
125
200
|
}
|
|
@@ -153,20 +228,26 @@ function readBeat(repoRoot, tier) {
|
|
|
153
228
|
// is not evidence that nobody armed the tier). On a SEAT tier it is the opposite: a record naming no
|
|
154
229
|
// seat leaves the tier armed and unattributable, which reads as coverage no seat is providing, so
|
|
155
230
|
// it is UNREADABLE — something is there and no beat any reader can attribute comes out of it.
|
|
156
|
-
const seat =
|
|
231
|
+
const { seat, armId } = beatMetadata(p);
|
|
157
232
|
if (isSeatTier(tier) && seat === undefined)
|
|
158
233
|
return "UNREADABLE";
|
|
159
|
-
return {
|
|
234
|
+
return {
|
|
235
|
+
mtimeMs: st.mtimeMs,
|
|
236
|
+
...(seat !== undefined ? { seat } : {}),
|
|
237
|
+
...(armId !== undefined ? { armId } : {}),
|
|
238
|
+
};
|
|
160
239
|
}
|
|
161
|
-
/**
|
|
162
|
-
function
|
|
240
|
+
/** Optional metadata declared by a beat; its mtime remains the only source of age. */
|
|
241
|
+
function beatMetadata(path) {
|
|
163
242
|
try {
|
|
164
243
|
const rec = JSON.parse(readFileSync(path, "utf8"));
|
|
165
|
-
|
|
244
|
+
const seat = typeof rec?.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
|
|
245
|
+
const armId = typeof rec?.armId === "string" && rec.armId.trim() ? rec.armId : undefined;
|
|
246
|
+
return { ...(seat !== undefined ? { seat } : {}), ...(armId !== undefined ? { armId } : {}) };
|
|
166
247
|
}
|
|
167
248
|
catch {
|
|
168
|
-
return
|
|
169
|
-
} // unparseable bytes name no seat — the caller decides what that means
|
|
249
|
+
return {};
|
|
250
|
+
} // unparseable bytes name no seat or arm — the caller decides what that means
|
|
170
251
|
}
|
|
171
252
|
function beatLiveness(tier, beat, now) {
|
|
172
253
|
if (typeof beat !== "object")
|
|
@@ -203,6 +284,7 @@ function readStandDown(repoRoot, tier) {
|
|
|
203
284
|
if (!st.isFile())
|
|
204
285
|
return "UNREADABLE";
|
|
205
286
|
let seat;
|
|
287
|
+
let armId;
|
|
206
288
|
try {
|
|
207
289
|
const rec = JSON.parse(readFileSync(p, "utf8"));
|
|
208
290
|
if (rec?.tier !== tier)
|
|
@@ -210,6 +292,7 @@ function readStandDown(repoRoot, tier) {
|
|
|
210
292
|
if (typeof rec.disarmedAt !== "string" || Number.isNaN(Date.parse(rec.disarmedAt)))
|
|
211
293
|
return "UNREADABLE";
|
|
212
294
|
seat = typeof rec.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
|
|
295
|
+
armId = typeof rec.armId === "string" && rec.armId.trim() ? rec.armId : undefined;
|
|
213
296
|
// A seat tier's hand-off names WHICH seat left, on the same rule as its beat: an anonymous
|
|
214
297
|
// stand-down on a per-seat tier says a watcher left without saying whose, so it is no record.
|
|
215
298
|
if (isSeatTier(tier) && seat === undefined)
|
|
@@ -218,18 +301,26 @@ function readStandDown(repoRoot, tier) {
|
|
|
218
301
|
catch {
|
|
219
302
|
return "UNREADABLE";
|
|
220
303
|
} // unparseable or unreadable bytes — not a stand-down anyone can read
|
|
221
|
-
return {
|
|
304
|
+
return {
|
|
305
|
+
mtimeMs: st.mtimeMs,
|
|
306
|
+
...(seat !== undefined ? { seat } : {}),
|
|
307
|
+
...(armId !== undefined ? { armId } : {}),
|
|
308
|
+
};
|
|
222
309
|
}
|
|
223
310
|
// THE TIER'S STATE — what every surface and every operator reads. A valid stand-down outranks the beat:
|
|
224
311
|
// the watcher that wrote it is gone ON PURPOSE, and its last beat ages out exactly like a dead one's
|
|
225
|
-
// would. It outranks the beat it FOLLOWED and no other — a
|
|
226
|
-
//
|
|
312
|
+
// would. It outranks the beat it FOLLOWED and no other — a later timestamp OR a FRESH different
|
|
313
|
+
// armed-watcher identity is another arm, so a marker whose rename lost that race cannot mask a live
|
|
314
|
+
// watcher. Once that foreign beat is stale, a newer clean hand-off must win: otherwise overlapping
|
|
315
|
+
// boards closed in last-beater-first order would leave the tier reporting a death forever.
|
|
227
316
|
export function supervisionStatus(repoRoot, tier, now = Date.now()) {
|
|
228
317
|
const beat = readBeat(repoRoot, tier);
|
|
229
318
|
const standDown = readStandDown(repoRoot, tier);
|
|
230
319
|
if (standDown === "UNREADABLE")
|
|
231
320
|
return { tier, state: "UNREADABLE" };
|
|
232
|
-
|
|
321
|
+
const beatOutranksStandDown = standDown !== "NONE" && typeof beat === "object" && (beat.mtimeMs > standDown.mtimeMs || (now - beat.mtimeMs <= SUPERVISION_STALE_MS &&
|
|
322
|
+
beat.armId !== undefined && standDown.armId !== undefined && beat.armId !== standDown.armId));
|
|
323
|
+
if (standDown !== "NONE" && !beatOutranksStandDown) {
|
|
233
324
|
// the seat that stood down is named by the marker, falling back to whatever its last beat named
|
|
234
325
|
const seat = standDown.seat ?? (typeof beat === "object" ? beat.seat : undefined);
|
|
235
326
|
return { tier, state: "DISARMED", ...(seat !== undefined ? { seat } : {}) };
|
package/package.json
CHANGED
|
@@ -81,7 +81,7 @@ handoff_fresh() {
|
|
|
81
81
|
[ -f "$HANDOFF" ] || return 1
|
|
82
82
|
local age now mt
|
|
83
83
|
now=$(date +%s)
|
|
84
|
-
mt=$(stat -
|
|
84
|
+
mt=$(stat -c %Y "$HANDOFF" 2>/dev/null || stat -f %m "$HANDOFF" 2>/dev/null) || return 1
|
|
85
85
|
age=$((now - mt))
|
|
86
86
|
[ "$age" -le "$MAXAGE" ]
|
|
87
87
|
}
|