tickmarkr 2.1.2 → 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.
@@ -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
- // T16: the orchestrator seat IS this daemon, so this is where the tier gets armed — the writer half
725
- // T3 shipped had no caller outside its own tests, which made the reader honest and useless: it read
726
- // ABSENT for the entire life of every run. Armed immediately after the lock (the first instant this
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 driver.worktree(repoRoot, taskBranch, taskBase);
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
- journal.append("task-dispatch", t.id, { assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode });
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 commitsToCarry = existsSync(priorWt) ? await commitsAheadOf(taskBase, priorWt) : [];
1784
- const wt = await driver.worktree(repoRoot, taskBranch, taskBase);
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
@@ -1,3 +1,4 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
2
3
  export { ROUTING_ENV_SEAMS };
3
4
  export declare const FORK_CAP_ENV = "VITEST_MAX_FORKS";
@@ -42,6 +43,10 @@ export interface ShResult {
42
43
  timedOut?: boolean;
43
44
  durationMs?: number;
44
45
  }
46
+ export declare const setSpawnForTests: (fn: typeof spawn) => void;
47
+ export declare const resetSpawnForTests: () => void;
48
+ export declare const SPAWN_ATTEMPT_LIMIT = 4;
49
+ export declare const SPAWN_RETRY_BACKOFF_MS = 50;
45
50
  export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
46
51
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
47
52
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
@@ -58,6 +63,21 @@ export declare function cleanupRunWorktrees(repo: string, branch: string, opts:
58
63
  removeTaskIds: string[];
59
64
  }): Promise<void>;
60
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>;
61
81
  export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
62
82
  export declare function linkNodeModules(repo: string, dir: string, { force }?: {
63
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";
@@ -64,6 +65,31 @@ export const runWithForkBudget = (concurrency, fn) => forkBudget.run(String(deri
64
65
  export const resolvedForkCap = () => forkBudget.getStore() ?? DEFAULT_FORK_CAP;
65
66
  /** The shipped shell ceiling: the fallback every caller gets when nothing measured a better one. */
66
67
  export const DEFAULT_SHELL_TIMEOUT_MS = 600000;
68
+ /**
69
+ * OBS-688: every gate, every baseline capture and every tip verification reaches the machine through
70
+ * this one seam, and the seam took its spawn from the standard library directly — so the one failure
71
+ * it must handle, the kernel REFUSING the fork, was unreachable from a fixture and could only be
72
+ * described. It is injectable here for exactly that reason; production always holds `spawn` itself.
73
+ * Undefined-until-set, never captured at module load: the standard library binding stays read at
74
+ * CALL time, exactly as before this seam existed, so a suite that mocks `node:child_process` without
75
+ * a `spawn` export still imports this module (tests/adapters/pi-auth.test.ts does).
76
+ */
77
+ let spawnChild;
78
+ export const setSpawnForTests = (fn) => { spawnChild = fn; };
79
+ export const resetSpawnForTests = () => { spawnChild = undefined; };
80
+ /**
81
+ * A refusal is NOT evidence about the work: the command never started, so nothing ran, and a retry
82
+ * cannot repeat a side effect. That argument is the whole safety case for retrying here, and it
83
+ * holds for exactly one closed case — the kernel refused the fork for a temporary resource shortage
84
+ * (EAGAIN: the fork table is full, which a gate burst does to a box twice in one night). Every
85
+ * other spawn error is a standing fact about the machine — a missing interpreter above all — and
86
+ * retrying it buys nothing while delaying every genuine failure by the whole backoff, so it returns
87
+ * on the first read. The retry is also gated on the child having produced NO byte and never having
88
+ * emitted `spawn`: past either, a command has run and re-running it is a side effect, never a retry.
89
+ */
90
+ const RETRYABLE_SPAWN_CODE = "EAGAIN";
91
+ export const SPAWN_ATTEMPT_LIMIT = 4;
92
+ export const SPAWN_RETRY_BACKOFF_MS = 50;
67
93
  // stdin "ignore": same class as HARD-05 / SubprocessDriver — never leave an open pipe a child can block on
68
94
  // (pi -p / codex exec wait for stdin EOF). timedOut distinguishes SIGKILL-timeout from a real nonzero exit.
69
95
  function shell(cmd, cwd, timeoutMs, login) {
@@ -77,19 +103,23 @@ function shell(cmd, cwd, timeoutMs, login) {
77
103
  // OBS-110: apply the run's own fork cap only when the operator has not already set one.
78
104
  if (!(FORK_CAP_ENV in env))
79
105
  env[FORK_CAP_ENV] = resolvedForkCap();
80
- return new Promise((resolve) => {
106
+ const attempt = () => new Promise((resolve) => {
81
107
  const startedAt = Date.now();
82
108
  // detached: bash gets its own process group so a timeout can kill the whole tree —
83
109
  // SIGKILLing bash alone orphans grandchildren (codex/pi) that hold the stdio pipes
84
110
  // open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
85
- const p = spawn("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
111
+ const p = (spawnChild ?? spawn)("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
86
112
  let stdout = "", stderr = "";
87
- let timedOut = false, done = false;
113
+ const stdoutDecoder = new StringDecoder("utf8");
114
+ const stderrDecoder = new StringDecoder("utf8");
115
+ let timedOut = false, done = false, started = false, outputSeen = false;
88
116
  const finish = (code, err) => {
89
117
  if (done)
90
118
  return;
91
119
  done = true;
92
120
  clearTimeout(timer);
121
+ stdout += stdoutDecoder.end();
122
+ stderr += stderrDecoder.end();
93
123
  resolve({ code, stdout, stderr: err ?? stderr, timedOut, durationMs: Date.now() - startedAt });
94
124
  };
95
125
  const timer = setTimeout(() => {
@@ -101,14 +131,50 @@ function shell(cmd, cwd, timeoutMs, login) {
101
131
  p.kill("SIGKILL");
102
132
  }
103
133
  }, timeoutMs);
104
- p.stdout.on("data", (d) => (stdout += d));
105
- p.stderr.on("data", (d) => (stderr += d));
106
- p.on("error", (e) => finish(127, String(e)));
134
+ p.on("spawn", () => { started = true; }); // the command exists from here on — never retryable past it
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
+ });
150
+ p.on("error", (e) => {
151
+ if (!done && !started && !outputSeen && e.code === RETRYABLE_SPAWN_CODE) {
152
+ done = true;
153
+ clearTimeout(timer);
154
+ resolve({ refused: e });
155
+ return;
156
+ }
157
+ finish(127, String(e));
158
+ });
107
159
  p.on("close", (code) => finish(code ?? 1));
108
160
  // "close" waits for stdio to drain; a surviving pipe-holder must not outlive the timeout
109
161
  p.on("exit", (code) => { if (timedOut)
110
162
  finish(code ?? 1); });
111
163
  });
164
+ return (async () => {
165
+ const startedAt = Date.now();
166
+ for (let n = 1;; n++) {
167
+ const r = await attempt();
168
+ if (!("refused" in r))
169
+ return r;
170
+ // Bounded, and the bound is what makes a persisting shortage a REPORTED failure rather than a
171
+ // wedged daemon: past it the caller gets the refusal's own text under exit 127, as before.
172
+ if (n >= SPAWN_ATTEMPT_LIMIT) {
173
+ return { code: 127, stdout: "", stderr: String(r.refused), durationMs: Date.now() - startedAt };
174
+ }
175
+ await new Promise((wake) => setTimeout(wake, SPAWN_RETRY_BACKOFF_MS * n));
176
+ }
177
+ })();
112
178
  }
113
179
  export function sh(cmd, cwd, timeoutMs = DEFAULT_SHELL_TIMEOUT_MS) {
114
180
  return shell(cmd, cwd, timeoutMs, true);
@@ -212,6 +278,46 @@ const resolveTaskBranch = async (repo, branch) => {
212
278
  const integration = await resolveIntegrationBranch(repo, branch.slice(0, split));
213
279
  return `${integration}${branch.slice(split)}`;
214
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
+ }
215
321
  export async function createWorktree(repo, branch, baseRef) {
216
322
  branch = await resolveTaskBranch(repo, branch);
217
323
  const dir = join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
@@ -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
  /**
@@ -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 this.read()) {
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
@@ -10,6 +10,7 @@ export interface Inspection {
10
10
  ino: number;
11
11
  }
12
12
  export declare function shouldRefuse(i: Pick<Inspection, "garbage" | "dead">): boolean;
13
+ export declare function isPidLive(pid: number): boolean;
13
14
  export declare function acquireRunLock(repoRoot: string, runId: string): {
14
15
  reclaimed?: {
15
16
  pid: number;
package/dist/run/lock.js CHANGED
@@ -45,6 +45,23 @@ process.once("exit", () => { if (heldPath)
45
45
  export function shouldRefuse(i) {
46
46
  return i.garbage || !i.dead;
47
47
  }
48
+ // LOCK-04, PID-SCOPED: the same decision table, in the shape a caller holding only a pid can consume.
49
+ // Every other liveness export here takes a repository root, so a reader with a pid off a journal row
50
+ // had no seam to reach and wrote the four lines itself — twice. One copy was faithful; the other
51
+ // treated ANY thrown error as death, so a daemon owned by another user (EPERM) read dead there and
52
+ // alive here. A rule that forbids a second `process.kill(pid, 0)` without exporting a usable
53
+ // predicate produces exactly those copies, so this is the predicate. no throw ⇒ ALIVE; ESRCH ⇒ the
54
+ // only proof-positive death; ANY other errno (EPERM = alive-but-not-ours, EINVAL, …) ⇒ ALIVE,
55
+ // because none of them is evidence of death and this table fails closed toward alive.
56
+ export function isPidLive(pid) {
57
+ try {
58
+ process.kill(pid, 0);
59
+ return true;
60
+ }
61
+ catch (k) {
62
+ return k.code !== "ESRCH";
63
+ }
64
+ }
48
65
  // statSync throws ENOENT when no lock exists — callers treat that as "not held".
49
66
  function inspect(p) {
50
67
  const st = statSync(p); // single stat: both the heartbeat mtime and the reclaim-guard inode
@@ -53,16 +70,8 @@ function inspect(p) {
53
70
  const parsed = PayloadSchema.safeParse(readPayload(p));
54
71
  const garbage = !parsed.success; // LOCK-01: its own state — shouldRefuse refuses it unconditionally; only `tickmarkr unlock` removes it
55
72
  const pid = parsed.success ? parsed.data.pid : undefined;
56
- let dead = pid === undefined; // harmless fallback for the garbage row — garbage short-circuits shouldRefuse before this is read
57
- if (pid !== undefined) {
58
- try {
59
- process.kill(pid, 0);
60
- dead = false;
61
- } // no throw ⇒ ALIVE
62
- catch (k) {
63
- dead = k.code === "ESRCH";
64
- } // ESRCH ⇒ dead; EPERM ⇒ ALIVE
65
- }
73
+ // harmless fallback for the garbage row — garbage short-circuits shouldRefuse before this is read
74
+ const dead = pid === undefined ? true : !isPidLive(pid);
66
75
  return { pid, runId: parsed.success ? parsed.data.runId : undefined, garbage, dead, expired, mtimeMs, ino: st.ino };
67
76
  }
68
77
  function readPayload(p) {
@@ -222,9 +231,10 @@ export async function acquireApprovalSerialization(repoRoot, runId) {
222
231
  }
223
232
  }
224
233
  // LOCK-04: the owner the decision table sees, read-only, for callers that need the pid as well as
225
- // the answer. inspect() owns pid-liveness (ESRCH dead / EPERM alive / garbage fail-closed); a second
226
- // `process.kill(pid, 0)` anywhere else would be a second copy of that rule, free to drift. undefined
227
- // ⇒ no lock at all — never conflate that with a lock whose recorded owner is dead.
234
+ // the answer. inspect() owns pid-liveness (ESRCH dead / EPERM alive / garbage fail-closed) and reads
235
+ // it from isPidLive above; a second `process.kill(pid, 0)` anywhere else would be a second copy of
236
+ // that rule, free to drift — a caller holding only a pid consumes isPidLive, never its own probe.
237
+ // undefined ⇒ no lock at all — never conflate that with a lock whose recorded owner is dead.
228
238
  export function runLockOwner(repoRoot) {
229
239
  let insp;
230
240
  try {
@@ -3,24 +3,30 @@ export declare const SUPERVISION_BEAT_MS = 10000;
3
3
  /** Ceiling before a beat reads STALE: SIX beats, lock.ts's ratio — five may be missed before alarm. */
4
4
  export declare const SUPERVISION_STALE_MS: number;
5
5
  export declare const SUPERVISION_FUTURE_GRACE_MS = 1000;
6
- export declare const SUPERVISION_TIERS: readonly ["orchestrator", "overseer", "watch"];
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", "orchestrator-context", "overseer", "overseer-context"];
9
+ export type SeatTier = (typeof SUPERVISION_SEAT_TIERS)[number];
10
+ /** Does this tier's record have to name the seat it speaks for? */
11
+ export declare const isSeatTier: (tier: string) => tier is SeatTier;
8
12
  export type SupervisionState = "ABSENT" | "STALE" | "ARMED" | "UNREADABLE" | "DISARMED";
9
13
  export interface TierLiveness {
10
14
  tier: SupervisionTier;
11
15
  state: SupervisionState;
12
16
  /** Absent for ABSENT, UNREADABLE and DISARMED — no beat to age. Always present for STALE and ARMED. */
13
17
  beatAgeMs?: number;
18
+ /** The seat the record names, when it names one. Always present on a seat tier that is not ABSENT. */
19
+ seat?: string;
14
20
  }
15
21
  export declare const supervisionBeatPath: (repoRoot: string, tier: SupervisionTier) => string;
16
22
  /** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
17
23
  export declare const supervisionStandDownPath: (repoRoot: string, tier: SupervisionTier) => string;
18
- export declare function beatSupervision(repoRoot: string, tier: SupervisionTier): void;
24
+ export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string): void;
19
25
  /** Handle a watcher holds for as long as it is supervising; disarm stands it down and is idempotent. */
20
26
  export interface ArmedSupervision {
21
27
  disarm: () => void;
22
28
  }
23
- export declare function armSupervision(repoRoot: string, tier: SupervisionTier, beatMs?: number): ArmedSupervision;
29
+ export declare function armSupervision(repoRoot: string, tier: SupervisionTier, beatMs?: number, seat?: string): ArmedSupervision;
24
30
  export declare function readTierLiveness(repoRoot: string, tier: SupervisionTier, now?: number): TierLiveness;
25
31
  export declare function supervisionStatus(repoRoot: string, tier: SupervisionTier, now?: number): TierLiveness;
26
32
  /** Every KNOWN tier, always — a tier omitted from this list would read as one that is fine. */