tickmarkr 2.6.0 → 2.6.1

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.
Files changed (47) hide show
  1. package/dist/adapters/prompt.d.ts +2 -1
  2. package/dist/adapters/prompt.js +10 -0
  3. package/dist/adapters/types.d.ts +1 -0
  4. package/dist/adapters/types.js +10 -0
  5. package/dist/cli/commands/fleet.js +26 -1
  6. package/dist/cli/commands/plan.js +7 -3
  7. package/dist/cli/commands/status.js +13 -3
  8. package/dist/cli/help.d.ts +2 -0
  9. package/dist/cli/help.js +2 -0
  10. package/dist/drivers/orca.js +6 -1
  11. package/dist/gates/cache.d.ts +3 -1
  12. package/dist/gates/cache.js +10 -3
  13. package/dist/gates/llm.js +4 -1
  14. package/dist/gates/review.js +7 -11
  15. package/dist/gates/run-gates.d.ts +4 -0
  16. package/dist/gates/run-gates.js +7 -4
  17. package/dist/gates/test-manifest.d.ts +12 -0
  18. package/dist/gates/test-manifest.js +26 -6
  19. package/dist/graph/graph.d.ts +6 -2
  20. package/dist/graph/graph.js +15 -4
  21. package/dist/route/role-pick.d.ts +16 -0
  22. package/dist/route/role-pick.js +15 -0
  23. package/dist/route/router.d.ts +14 -0
  24. package/dist/route/router.js +9 -1
  25. package/dist/run/consult.js +5 -9
  26. package/dist/run/daemon.d.ts +9 -0
  27. package/dist/run/daemon.js +395 -59
  28. package/dist/run/git.d.ts +36 -1
  29. package/dist/run/git.js +89 -5
  30. package/dist/run/host-health.d.ts +20 -0
  31. package/dist/run/host-health.js +64 -0
  32. package/dist/run/journal.js +9 -0
  33. package/dist/run/operator-state.d.ts +24 -2
  34. package/dist/run/operator-state.js +41 -5
  35. package/dist/run/stall.d.ts +38 -2
  36. package/dist/run/stall.js +276 -6
  37. package/dist/tui/cockpit/board.d.ts +1 -1
  38. package/dist/tui/cockpit/board.js +27 -19
  39. package/dist/tui/cockpit/derive.d.ts +2 -0
  40. package/dist/tui/cockpit/derive.js +4 -0
  41. package/dist/tui/cockpit/live-store.d.ts +1 -0
  42. package/dist/tui/cockpit/live-store.js +31 -8
  43. package/dist/tui/cockpit/run-cockpit.js +2 -1
  44. package/dist/tui/cockpit/run-view.d.ts +2 -4
  45. package/dist/tui/cockpit/run-view.js +9 -8
  46. package/package.json +1 -1
  47. package/skills/tickmarkr-overseer/SKILL.md +43 -6
package/dist/run/git.d.ts CHANGED
@@ -4,6 +4,18 @@ import { ROUTING_ENV_SEAMS } from "../route/router.js";
4
4
  export { ROUTING_ENV_SEAMS };
5
5
  export declare const FORK_CAP_ENV = "VITEST_MAX_FORKS";
6
6
  export declare const SUITE_PARENT_ENV = "TICKMARKR_SUITE_PARENT";
7
+ /** OBS-1173 add.2: only ps's clean "no such process" (exit 1, nothing printed) means gone (""). A
8
+ * timeout, a signal, a spawn error or any other exit is a FAILED probe (null) that says nothing. */
9
+ export declare function classifyIdentityProbe(error: {
10
+ code?: number | string | null;
11
+ killed?: boolean;
12
+ signal?: string | null;
13
+ } | null, stdout: string): string | null;
14
+ /** `undefined` = the pid is gone; `null` = the probe failed and proves nothing (never gone, never a match). */
15
+ export declare function processIdentity(pid: number): Promise<string | null | undefined>;
16
+ export declare const activeShellIdentities: () => ReadonlyMap<number, string | undefined>;
17
+ /** A process that started after its receipt is a pid reuse, never that receipt's child. */
18
+ export declare function resolveActiveShellIdentities(): Promise<void>;
7
19
  export declare const DEFAULT_FORK_CAP = "6";
8
20
  /** Verification uses command leases; workers keep their concurrency budget. */
9
21
  export declare const runWithVerificationBudget: <T>(capacity: RunCapacity, fn: () => Promise<T>) => Promise<T>;
@@ -174,6 +186,23 @@ export declare function cleanupRunWorktrees(repo: string, branch: string, opts:
174
186
  removeTaskIds: string[];
175
187
  }): Promise<void>;
176
188
  export declare function resolveIntegrationBranch(_repo: string, branch: string): Promise<string>;
189
+ /**
190
+ * The attempt whose worker last wrote in a checkout, or explicit "unknown" when no worker launch is
191
+ * on record for the current tree (gate-only recreations, standalone verify, legacy callers). The
192
+ * label is what the preserve commit trailer and every `worktree-preserved`/refusal row carry, so a
193
+ * preserved engine commit is never credited to whichever seat is dispatched next.
194
+ */
195
+ export type PreserveProducer = {
196
+ channel: string;
197
+ attempt: number;
198
+ } | "unknown";
199
+ export declare const PRESERVE_PRODUCER_TRAILER = "Tickmarkr-Producer";
200
+ /** Subject of every engine preserve commit; only such commits are owned by their preserve row. */
201
+ export declare const PRESERVE_COMMIT_SUBJECT = "tickmarkr: preserve uncommitted worktree";
202
+ export declare const producerFields: (producer: PreserveProducer | undefined) => {
203
+ producer: string;
204
+ producerAttempt?: number;
205
+ };
177
206
  /**
178
207
  * Preserve the bytes an existing checkout holds before recreation removes it.
179
208
  *
@@ -188,7 +217,13 @@ export declare function resolveIntegrationBranch(_repo: string, branch: string):
188
217
  * neither a commit nor a ref, keeping meaningful deaths visible rather than minting one ref per
189
218
  * ordinary dispatch.
190
219
  */
191
- export declare function preserveWorktree(cwd: string): Promise<string | undefined>;
220
+ export declare function preserveWorktree(cwd: string, producer?: PreserveProducer): Promise<string | undefined>;
221
+ /**
222
+ * Identity of the physical checkout, including recreation at the same path. Unlike mtime/ctime,
223
+ * birthtime does not change when builds add outputs. Read from disk on every call so process
224
+ * restarts preserve reuse. Filesystems without birthtime cannot prove build-output continuity.
225
+ */
226
+ export declare function checkoutIncarnation(worktree: string): string | undefined;
192
227
  export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
193
228
  export interface DependencyLink {
194
229
  /** Package link, relative to node_modules (including @scope/name). */
package/dist/run/git.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { executionSignal } from "./execution-budget.js";
2
2
  import { commandLeaseEnvironment, withCommandLease } from "./lease.js";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
- import { execFileSync, spawn } from "node:child_process";
4
+ import { execFile, execFileSync, spawn } from "node:child_process";
5
5
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
6
6
  import { availableParallelism, homedir, tmpdir } from "node:os";
7
7
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -16,6 +16,52 @@ export { ROUTING_ENV_SEAMS };
16
16
  // as argv (OBS-55) so child test oracles stay intact. The operator's own export wins.
17
17
  export const FORK_CAP_ENV = "VITEST_MAX_FORKS";
18
18
  export const SUITE_PARENT_ENV = "TICKMARKR_SUITE_PARENT";
19
+ /** A pid is an address, not an identity. Keep this evidence out of ShellReceipt. */
20
+ const linuxIdentity = (pid) => {
21
+ try {
22
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
23
+ const start = stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/)[19];
24
+ return start && /^\d+$/.test(start) ? `${pid}:${start}` : undefined;
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ };
30
+ /** OBS-1173 add.2: only ps's clean "no such process" (exit 1, nothing printed) means gone (""). A
31
+ * timeout, a signal, a spawn error or any other exit is a FAILED probe (null) that says nothing. */
32
+ export function classifyIdentityProbe(error, stdout) {
33
+ const start = stdout.trim().replace(/\s+/g, " ");
34
+ if (!error)
35
+ return start || null;
36
+ return !error.killed && !error.signal && error.code === 1 && !start ? "" : null;
37
+ }
38
+ /** `undefined` = the pid is gone; `null` = the probe failed and proves nothing (never gone, never a match). */
39
+ export async function processIdentity(pid) {
40
+ if (process.platform === "linux")
41
+ return linuxIdentity(pid);
42
+ // Async and bounded like every other ps probe: a loaded host is slow, not unreadable.
43
+ const start = await new Promise((done) => execFile("ps", ["-p", String(pid), "-o", "lstart="], {
44
+ encoding: "utf8", timeout: 15_000, maxBuffer: 4096,
45
+ }, (error, stdout) => done(classifyIdentityProbe(error, stdout))));
46
+ if (start === null)
47
+ return null;
48
+ return start ? `${pid}:${start}` : undefined;
49
+ }
50
+ // Started receipts own these live pids; terminal receipts retire exactly that invocation. The
51
+ // shell hot path never probes: Linux reads /proc in place, other hosts resolve on demand below.
52
+ const activeShells = new Map();
53
+ export const activeShellIdentities = () => new Map([...activeShells].map(([pid, live]) => [pid, live.identity ?? undefined]));
54
+ /** A process that started after its receipt is a pid reuse, never that receipt's child. */
55
+ export async function resolveActiveShellIdentities() {
56
+ await Promise.all([...activeShells].filter(([, live]) => live.identity === undefined).map(async ([pid, live]) => {
57
+ const identity = await processIdentity(pid);
58
+ if (identity === null)
59
+ return; // a failed probe resolves nothing; the next resolve retries
60
+ const started = identity ? Date.parse(identity.slice(identity.indexOf(":") + 1)) : NaN;
61
+ if (live.identity === undefined)
62
+ live.identity = identity && started <= live.startedMs + 1_000 ? identity : null;
63
+ }));
64
+ }
19
65
  export const DEFAULT_FORK_CAP = "6";
20
66
  /** Worker concurrency is resolved once per run and isolated from other async runs. */
21
67
  const forkBudget = new AsyncLocalStorage();
@@ -217,6 +263,8 @@ export function shell(cmd, cwd, timeoutMs, login = false, options = {}) {
217
263
  let attribution;
218
264
  let confirmedStart = false;
219
265
  let terminal = false;
266
+ let activePid;
267
+ let activeInvocation;
220
268
  const begin = (invocation) => {
221
269
  attribution = options.receiptAttribution?.(invocation);
222
270
  confirmedStart = false;
@@ -225,10 +273,21 @@ export function shell(cmd, cwd, timeoutMs, login = false, options = {}) {
225
273
  const emit = (receipt) => {
226
274
  if (terminal)
227
275
  return;
228
- if (receipt.outcome === "started")
276
+ if (receipt.outcome === "started") {
229
277
  confirmedStart = true;
230
- else
278
+ activePid = receipt.pid;
279
+ if (activePid !== undefined) {
280
+ activeInvocation = Symbol("shell invocation");
281
+ activeShells.set(activePid, { startedMs: Date.now(), invocation: activeInvocation,
282
+ ...(process.platform === "linux" ? { identity: linuxIdentity(activePid) ?? null } : {}) });
283
+ }
284
+ }
285
+ else {
231
286
  terminal = true;
287
+ if (activePid !== undefined && activeShells.get(activePid)?.invocation === activeInvocation)
288
+ activeShells.delete(activePid);
289
+ activePid = undefined;
290
+ }
232
291
  // Observational callbacks must not change process cleanup, retries or cancellation.
233
292
  try {
234
293
  options.onReceipt?.({ ...receipt, confirmedStart, ...(attribution ? { attribution: { ...attribution } } : {}) });
@@ -540,6 +599,14 @@ const resolveTaskBranch = async (repo, branch) => {
540
599
  const integration = await resolveIntegrationBranch(repo, branch.slice(0, split));
541
600
  return `${integration}${branch.slice(split)}`;
542
601
  };
602
+ export const PRESERVE_PRODUCER_TRAILER = "Tickmarkr-Producer";
603
+ /** Subject of every engine preserve commit; only such commits are owned by their preserve row. */
604
+ export const PRESERVE_COMMIT_SUBJECT = "tickmarkr: preserve uncommitted worktree";
605
+ export const producerFields = (producer) => producer && producer !== "unknown" ? { producer: producer.channel, producerAttempt: producer.attempt } : { producer: "unknown" };
606
+ const producerLabel = (producer) => {
607
+ const f = producerFields(producer);
608
+ return f.producerAttempt === undefined ? f.producer : `${f.producer} attempt ${f.producerAttempt}`;
609
+ };
543
610
  /**
544
611
  * Preserve the bytes an existing checkout holds before recreation removes it.
545
612
  *
@@ -554,7 +621,7 @@ const resolveTaskBranch = async (repo, branch) => {
554
621
  * neither a commit nor a ref, keeping meaningful deaths visible rather than minting one ref per
555
622
  * ordinary dispatch.
556
623
  */
557
- export async function preserveWorktree(cwd) {
624
+ export async function preserveWorktree(cwd, producer = "unknown") {
558
625
  if (!existsSync(cwd))
559
626
  return undefined;
560
627
  const scratch = mkdtempSync(join(tmpdir(), "tickmarkr-preserve-index-"));
@@ -571,7 +638,7 @@ export async function preserveWorktree(cwd) {
571
638
  // not an authored project commit. Its HEAD parent makes the preserved tree directly inspectable.
572
639
  const identity = "GIT_AUTHOR_NAME=tickmarkr GIT_AUTHOR_EMAIL=tickmarkr@localhost "
573
640
  + "GIT_COMMITTER_NAME=tickmarkr GIT_COMMITTER_EMAIL=tickmarkr@localhost";
574
- const commit = (await shGitOk(`${identity} git commit-tree ${shq(tree)} -p HEAD -m ${shq("tickmarkr: preserve uncommitted worktree")}`, cwd)).trim();
641
+ const commit = (await shGitOk(`${identity} git commit-tree ${shq(tree)} -p HEAD -m ${shq(PRESERVE_COMMIT_SUBJECT)} -m ${shq(`${PRESERVE_PRODUCER_TRAILER}: ${producerLabel(producer)}`)}`, cwd)).trim();
575
642
  const ref = `refs/tickmarkr/preserved/${commit}`;
576
643
  await shGitOk(`git update-ref ${shq(ref)} ${shq(commit)}`, cwd);
577
644
  return ref;
@@ -580,6 +647,23 @@ export async function preserveWorktree(cwd) {
580
647
  rmSync(scratch, { recursive: true, force: true });
581
648
  }
582
649
  }
650
+ /**
651
+ * Identity of the physical checkout, including recreation at the same path. Unlike mtime/ctime,
652
+ * birthtime does not change when builds add outputs. Read from disk on every call so process
653
+ * restarts preserve reuse. Filesystems without birthtime cannot prove build-output continuity.
654
+ */
655
+ export function checkoutIncarnation(worktree) {
656
+ try {
657
+ const root = realpathSync(worktree);
658
+ const stat = statSync(root, { bigint: true });
659
+ if (!stat.isDirectory() || stat.birthtimeNs <= 0n)
660
+ return undefined;
661
+ return `${root}:${stat.dev}:${stat.ino}:${stat.birthtimeNs}`;
662
+ }
663
+ catch {
664
+ return undefined;
665
+ }
666
+ }
583
667
  export async function createWorktree(repo, branch, baseRef) {
584
668
  branch = await resolveTaskBranch(repo, branch);
585
669
  const dir = join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
@@ -0,0 +1,20 @@
1
+ import { spawn } from "node:child_process";
2
+ export declare const HOST_PROBE_SAMPLES = 3;
3
+ export declare const HOST_PROBE_SAMPLE_MS = 1000;
4
+ export declare const HOST_LATENCY_FLOOR_MS = 50;
5
+ export declare const HOST_LATENCY_RATIO = 3;
6
+ export declare class HostDegradedError extends Error {
7
+ }
8
+ export declare const setHostProbeSpawnForTests: (fn: typeof spawn) => void;
9
+ export declare const resetHostProbeSpawnForTests: () => void;
10
+ declare const execLatency: (signal: AbortSignal, timeoutMs: number) => Promise<number | null>;
11
+ export declare const setHostLatencySampleForTests: (fn: typeof execLatency) => void;
12
+ export declare const resetHostLatencySampleForTests: () => void;
13
+ export interface HostObservation {
14
+ samplesMs: Array<number | null>;
15
+ medianMs: number | null;
16
+ }
17
+ /** Three sequential samples, each bounded by 1s and the remaining observation budget. */
18
+ export declare function observeHost(signal: AbortSignal, budgetMs?: number): Promise<HostObservation>;
19
+ export declare const hostDegraded: (observation: HostObservation, referenceMs: number | undefined) => boolean;
20
+ export {};
@@ -0,0 +1,64 @@
1
+ import { spawn } from "node:child_process";
2
+ import { performance } from "node:perf_hooks";
3
+ import { FILE_HANG_SLACK } from "../gates/test-manifest.js";
4
+ export const HOST_PROBE_SAMPLES = 3;
5
+ export const HOST_PROBE_SAMPLE_MS = 1_000;
6
+ export const HOST_LATENCY_FLOOR_MS = 50;
7
+ export const HOST_LATENCY_RATIO = FILE_HANG_SLACK;
8
+ export class HostDegradedError extends Error {
9
+ }
10
+ // A probe owns just this child: no shell, descendants, inherited Node options, or host-wide kills.
11
+ let probeSpawn = spawn;
12
+ export const setHostProbeSpawnForTests = (fn) => { probeSpawn = fn; };
13
+ export const resetHostProbeSpawnForTests = () => { probeSpawn = spawn; };
14
+ const execLatency = (signal, timeoutMs) => new Promise((resolve, reject) => {
15
+ signal.throwIfAborted();
16
+ const start = performance.now();
17
+ const env = { ...process.env };
18
+ delete env.NODE_OPTIONS;
19
+ let child;
20
+ try {
21
+ child = probeSpawn(process.execPath, ["-e", ""], { stdio: "ignore", env });
22
+ }
23
+ catch {
24
+ resolve(null);
25
+ return;
26
+ }
27
+ let unreadable = false;
28
+ const retire = () => { unreadable = true; child.kill("SIGKILL"); };
29
+ const timer = setTimeout(retire, timeoutMs);
30
+ signal.addEventListener("abort", retire, { once: true });
31
+ child.once("error", () => { unreadable = true; });
32
+ // Resolve only after close: cancellation and timeout must reap the owned child before returning.
33
+ child.once("close", code => {
34
+ clearTimeout(timer);
35
+ signal.removeEventListener("abort", retire);
36
+ if (signal.aborted)
37
+ reject(signal.reason);
38
+ else {
39
+ const elapsed = performance.now() - start;
40
+ resolve(!unreadable && code === 0 && elapsed <= timeoutMs ? elapsed : null);
41
+ }
42
+ });
43
+ if (signal.aborted)
44
+ retire();
45
+ });
46
+ let sample = execLatency;
47
+ export const setHostLatencySampleForTests = (fn) => { sample = fn; };
48
+ export const resetHostLatencySampleForTests = () => { sample = execLatency; };
49
+ /** Three sequential samples, each bounded by 1s and the remaining observation budget. */
50
+ export async function observeHost(signal, budgetMs = HOST_PROBE_SAMPLES * HOST_PROBE_SAMPLE_MS) {
51
+ const deadline = performance.now() + budgetMs;
52
+ const samplesMs = [];
53
+ for (let i = 0; i < HOST_PROBE_SAMPLES; i++) {
54
+ signal.throwIfAborted();
55
+ const remaining = deadline - performance.now();
56
+ const value = remaining > 0 ? await sample(signal, Math.min(HOST_PROBE_SAMPLE_MS, remaining)) : null;
57
+ samplesMs.push(value !== null && Number.isFinite(value) && value > 0 ? value : null);
58
+ }
59
+ const medianMs = samplesMs.every((n) => n !== null)
60
+ ? [...samplesMs].sort((a, b) => a - b)[1] : null;
61
+ return { samplesMs, medianMs };
62
+ }
63
+ export const hostDegraded = (observation, referenceMs) => observation.medianMs === null || referenceMs === undefined
64
+ || observation.medianMs >= Math.max(HOST_LATENCY_FLOOR_MS, referenceMs * HOST_LATENCY_RATIO);
@@ -1673,6 +1673,15 @@ export class Journal {
1673
1673
  lastDispatch.delete(e.taskId);
1674
1674
  }
1675
1675
  }
1676
+ else if (e.event === "capacity-requeue") {
1677
+ // OBS-1161: a capacity requeue is free — the daemon re-dispatches the SAME attempt on the SAME
1678
+ // seat — so the dispatch it closes must not count, or a resume bills every busy-seat wait
1679
+ // against MAX_ATTEMPTS. Only the attempt rewinds: the seat stays tried and in force (busy, not
1680
+ // burned). Idempotent by the same rule as the scope rewind: only the OUTSTANDING dispatch.
1681
+ const st = m.get(e.taskId);
1682
+ if (st && lastDispatch.delete(e.taskId))
1683
+ st.attempts = Math.max(0, st.attempts - 1);
1684
+ }
1676
1685
  else if (e.event === "consult-verdict" && e.data.action === "reroute") {
1677
1686
  // A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
1678
1687
  pendingReroute.add(e.taskId);
@@ -11,11 +11,18 @@ export interface OperatorRecord extends EvidenceIdentity {
11
11
  event: JournalEvent;
12
12
  }
13
13
  export type OperatorTaskState = "unknown" | "pending" | "running" | "completed" | "merged" | "failed" | "human" | "blocked";
14
- export type OperatorGateState = "unknown" | "not-run" | "disabled" | "running" | "passed" | "failed";
14
+ export type OperatorGateState = "unknown" | "not-run" | "disabled" | "queued" | "running" | "passed" | "failed";
15
15
  export interface OperatorGate {
16
16
  state: OperatorGateState;
17
17
  evidence?: EvidenceIdentity;
18
18
  }
19
+ export interface GateActivity {
20
+ state: "queued" | "running";
21
+ gate?: string;
22
+ reason?: "suite-wait" | "host-degraded";
23
+ count?: number;
24
+ evidence: EvidenceIdentity;
25
+ }
19
26
  export interface OperatorTask {
20
27
  id: string;
21
28
  title?: string;
@@ -30,10 +37,14 @@ export interface OperatorTask {
30
37
  evidence?: EvidenceIdentity;
31
38
  mergeEvidence?: EvidenceIdentity;
32
39
  gates: Record<string, OperatorGate>;
40
+ gateActivity?: GateActivity;
33
41
  /** BD-1: the board's per-task counters — latest dispatch channel, review verdicts drawn, human parks. */
34
42
  channel?: string;
35
43
  reviewRounds: number;
36
44
  parks: number;
45
+ /** Distinct producing channels of the merged subject from task-done; "unknown" names unattributed preserved work.
46
+ * Kept apart from `channel`: the last-dispatched seat may have authored nothing. */
47
+ authors?: readonly string[];
37
48
  }
38
49
  export interface OperatorSnapshot {
39
50
  sequence: number;
@@ -47,6 +58,10 @@ export interface OperatorSnapshot {
47
58
  label: string;
48
59
  green: boolean;
49
60
  currentTip: "passed" | "not required" | "pending" | "failed" | "unknown";
61
+ graphAvailability?: {
62
+ status: "readable" | "absent" | "unreadable";
63
+ error?: string;
64
+ };
50
65
  comparable: boolean;
51
66
  comparison: "matching graph" | "not comparable";
52
67
  merged: number;
@@ -60,6 +75,12 @@ export interface OperatorSnapshot {
60
75
  latestRunEnd?: EvidenceIdentity;
61
76
  approvedResumeRequired: boolean;
62
77
  }
78
+ /** Author channels recorded on a task-done row. A row written before authors were recorded carries none and
79
+ * projects nothing (never the dispatch); unattributed preserved work arrives as an explicit "unknown" entry. */
80
+ export declare const doneAuthors: (data: Record<string, unknown>) => string[] | undefined;
81
+ /** The note a status row or board row shows beside the last-dispatched channel when the merged
82
+ * authors are not exactly that channel (a carry-only seat, a mixed subject, or legacy unknown). */
83
+ export declare const authorsNote: (authors: readonly string[] | undefined, channel: string | undefined) => string | undefined;
63
84
  /** Incremental pure lifecycle fold. Retains facts per task/gate, never verdict bodies or event history. */
64
85
  export declare class OperatorStateFold {
65
86
  private tasks;
@@ -78,11 +99,12 @@ export declare class OperatorStateFold {
78
99
  private total;
79
100
  apply(record: OperatorRecord): void;
80
101
  private task;
81
- snapshot({ graph, sequence, observedAt, readable }?: {
102
+ snapshot({ graph, sequence, observedAt, readable, graphAvailability }?: {
82
103
  graph?: RunGraph;
83
104
  sequence?: number;
84
105
  observedAt?: number;
85
106
  readable?: boolean;
107
+ graphAvailability?: OperatorSnapshot["graphAvailability"];
86
108
  }): OperatorSnapshot;
87
109
  private comparableTo;
88
110
  }
@@ -5,6 +5,12 @@ const bucketNames = ["failed", "human", "blocked", "pending"];
5
5
  const strings = (value) => Array.isArray(value) && value.every(v => typeof v === "string") ? [...value] : undefined;
6
6
  const identity = ({ source, line, id, generation }) => ({ source, line, id, generation });
7
7
  const emptyGates = () => Object.fromEntries(GATE_NAMES.map(g => [g, { state: "not-run" }]));
8
+ /** Author channels recorded on a task-done row. A row written before authors were recorded carries none and
9
+ * projects nothing (never the dispatch); unattributed preserved work arrives as an explicit "unknown" entry. */
10
+ export const doneAuthors = (data) => strings(data.authors);
11
+ /** The note a status row or board row shows beside the last-dispatched channel when the merged
12
+ * authors are not exactly that channel (a carry-only seat, a mixed subject, or legacy unknown). */
13
+ export const authorsNote = (authors, channel) => authors === undefined || (authors.length === 1 && authors[0] === channel) ? undefined : `authors ${authors.join("+")}`;
8
14
  /** Incremental pure lifecycle fold. Retains facts per task/gate, never verdict bodies or event history. */
9
15
  export class OperatorStateFold {
10
16
  tasks = new Map();
@@ -75,6 +81,7 @@ export class OperatorStateFold {
75
81
  t.dispatches++;
76
82
  t.parkKind = undefined;
77
83
  t.gates = emptyGates();
84
+ t.gateActivity = undefined;
78
85
  t.attempt = typeof e.data.attempt === "number" ? e.data.attempt : undefined;
79
86
  // These are recorded values only: no guessed directory or universal silence threshold.
80
87
  t.path = typeof e.data.worktree === "string" ? e.data.worktree : typeof e.data.cwd === "string" ? e.data.cwd : undefined;
@@ -84,8 +91,10 @@ export class OperatorStateFold {
84
91
  if (a && typeof a.adapter === "string" && typeof a.model === "string")
85
92
  t.channel = `${a.adapter}:${a.model}`;
86
93
  }
87
- if (e.event === "task-done")
94
+ if (e.event === "task-done") {
88
95
  t.state = "completed";
96
+ t.authors = doneAuthors(e.data);
97
+ }
89
98
  if (e.event === "merge") {
90
99
  t.state = "merged";
91
100
  t.merged = true;
@@ -104,12 +113,39 @@ export class OperatorStateFold {
104
113
  if (e.event === "task-approved") {
105
114
  t.state = "pending";
106
115
  t.parkKind = undefined;
116
+ t.gateActivity = undefined;
107
117
  this.approved = !this.active;
118
+ if (e.data.release === "recheck")
119
+ t.gates = emptyGates();
120
+ }
121
+ if (e.event === "phase-start" && e.data.phase === "gates")
122
+ t.gateActivity = undefined;
123
+ if (e.event === "suite-wait" || e.event === "host-degraded") {
124
+ // Only this row's attribution is authority. Legacy task-only waits must not borrow a red gate.
125
+ const gate = typeof e.data.gate === "string" && GATE_NAMES.includes(e.data.gate) ? e.data.gate : undefined;
126
+ t.gateActivity = { state: "queued", gate, reason: e.event, evidence: ref,
127
+ ...(typeof e.data.count === "number" ? { count: e.data.count } : {}) };
128
+ if (gate)
129
+ t.gates[gate] = { state: "queued", evidence: ref };
130
+ }
131
+ if (e.event === "suite-admitted")
132
+ t.gateActivity = undefined;
133
+ if (["task-done", "merge", "task-failed", "task-human", "task-blocked"].includes(e.event))
134
+ t.gateActivity = undefined;
135
+ const phaseGate = e.event === "phase-start" && typeof e.data.phase === "string"
136
+ ? e.data.phase.startsWith("gate:") ? e.data.phase.slice(5) : e.data.phase === "judge" ? "acceptance" : e.data.phase === "review" ? "review" : undefined
137
+ : undefined;
138
+ const gate = e.data.gate ?? phaseGate;
139
+ if ((e.event === "phase-start" && (phaseGate !== undefined || e.data.gate !== undefined)) || e.event === "gate-start" || e.event === "gate-phase-start") {
140
+ t.gateActivity = { state: "running", evidence: ref };
108
141
  }
109
- const gate = e.data.gate;
110
142
  if (typeof gate === "string" && GATE_NAMES.includes(gate)) {
111
- if (e.event === "gate-start")
143
+ if (e.event === "gate-start" || e.event === "gate-phase-start" || e.event === "phase-start") {
112
144
  t.gates[gate] = { state: "running", evidence: ref };
145
+ t.gateActivity = { state: "running", gate, evidence: ref };
146
+ }
147
+ if (e.event === "gate-result" && t.gateActivity?.gate === gate)
148
+ t.gateActivity = undefined;
113
149
  if (e.event === "gate-result" && gate === "review")
114
150
  t.reviewRounds++;
115
151
  if (e.event === "gate-result")
@@ -126,7 +162,7 @@ export class OperatorStateFold {
126
162
  }
127
163
  return t;
128
164
  }
129
- snapshot({ graph, sequence = 0, observedAt = Date.now(), readable = true } = {}) {
165
+ snapshot({ graph, sequence = 0, observedAt = Date.now(), readable = true, graphAvailability } = {}) {
130
166
  const hash = graph && graphDefinitionHash(graph);
131
167
  const comparable = this.comparableTo(hash);
132
168
  const ids = comparable ? [...graph.tasks.map(t => t.id), ...[...this.tasks.keys()].filter(id => !graph.tasks.some(t => t.id === id))] : [...this.tasks.keys()];
@@ -150,7 +186,7 @@ export class OperatorStateFold {
150
186
  return {
151
187
  sequence, observedAt, lastEventAt: this.lastEventAt, firstEventAt: this.firstEventAt, resumes: this.resumes, escalations: this.escalations, lifecycle,
152
188
  label: this.approved ? "approved; resume required" : lifecycle,
153
- green, currentTip, comparable, comparison: comparable ? "matching graph" : "not comparable",
189
+ green, currentTip, graphAvailability, comparable, comparison: comparable ? "matching graph" : "not comparable",
154
190
  merged,
155
191
  planned, tasks, buckets,
156
192
  gatesRan: { passed: this.passed, total: this.total }, latestRunEnd: this.end?.evidence,
@@ -13,7 +13,42 @@ export declare function resetHarvestCpuFlatMsForTests(): void;
13
13
  /** The dispatch shell records its foreground group in an attempt-unique artifact. */
14
14
  export declare function readOwnedProcessGroup(path: string): number | undefined;
15
15
  /** Never infer ownership from a task name or scan another attempt's marker when reaping. */
16
- export declare function reapOwnedProcessGroup(group: number | undefined, cwd: string): Promise<number[] | null>;
16
+ export declare function reapOwnedProcessGroup(group: number | undefined, cwd: string, ownership?: WorkerReapOwnership): Promise<number[] | null>;
17
+ export interface ReapProcess {
18
+ pid: number;
19
+ ppid: number;
20
+ group: number;
21
+ session: string;
22
+ identity: string;
23
+ command: string;
24
+ startedAt?: string;
25
+ }
26
+ export interface WorkerReapOwnership {
27
+ marker: string;
28
+ /** Session recorded by the dispatch shell before its children can detach. */
29
+ session?: string;
30
+ parent?: {
31
+ pid: number;
32
+ startedAt: string;
33
+ };
34
+ identities?: Map<number, string>;
35
+ /** Descendants observed while the dispatch root was still alive. */
36
+ descendants?: Map<number, string>;
37
+ excludedGroups: number[];
38
+ excludedWorktrees: string[];
39
+ /** Discovery evidence is distinct from the final survivor census. */
40
+ strays: number[];
41
+ }
42
+ /** Bounded host boundary, also used to inject unreadability and pid reuse in regressions. */
43
+ export declare const workerReapHost: {
44
+ snapshot(): Promise<ReapProcess[] | undefined>;
45
+ inspect(pid: number): Promise<{
46
+ cwd: string;
47
+ suiteParent?: number;
48
+ } | undefined>;
49
+ identity: (pid: number) => Promise<string | null | undefined>;
50
+ kill(pid: number): void;
51
+ };
17
52
  export declare function workerTreeCpuMs(marker: string, cwd: string): Promise<{
18
53
  ms: number;
19
54
  resolutionMs: number;
@@ -22,6 +57,7 @@ export declare class WorkerTreeCpuAccountant {
22
57
  private marker;
23
58
  private cwd;
24
59
  private group?;
60
+ private identities?;
25
61
  private active;
26
62
  private loop;
27
63
  private live;
@@ -29,7 +65,7 @@ export declare class WorkerTreeCpuAccountant {
29
65
  private gaps;
30
66
  private consecutiveGaps;
31
67
  private latest;
32
- constructor(marker: string, cwd: string, group?: (() => number | undefined) | undefined);
68
+ constructor(marker: string, cwd: string, group?: (() => number | undefined) | undefined, identities?: Map<number, string> | undefined);
33
69
  private sample;
34
70
  start(): Promise<void>;
35
71
  read(): {