tickmarkr 2.5.4 → 2.5.6

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 (78) hide show
  1. package/README.md +3 -1
  2. package/dist/adapters/registry.js +6 -1
  3. package/dist/adapters/types.d.ts +3 -0
  4. package/dist/cli/commands/approve.d.ts +1 -0
  5. package/dist/cli/commands/approve.js +118 -9
  6. package/dist/cli/commands/doctor.d.ts +10 -0
  7. package/dist/cli/commands/doctor.js +44 -29
  8. package/dist/cli/commands/fleet.js +199 -33
  9. package/dist/cli/commands/init.js +196 -6
  10. package/dist/cli/commands/plan.js +20 -4
  11. package/dist/cli/commands/resume.js +4 -2
  12. package/dist/cli/commands/run.js +11 -2
  13. package/dist/cli/commands/verify.js +102 -81
  14. package/dist/cli/help.d.ts +2 -0
  15. package/dist/cli/help.js +3 -1
  16. package/dist/config/config.d.ts +25 -8
  17. package/dist/config/config.js +41 -29
  18. package/dist/config/fleet-overlay.d.ts +3 -9
  19. package/dist/config/fleet-overlay.js +114 -19
  20. package/dist/config/fleet-why.d.ts +7 -0
  21. package/dist/config/fleet-why.js +5 -0
  22. package/dist/drivers/index.d.ts +15 -1
  23. package/dist/drivers/index.js +39 -10
  24. package/dist/drivers/orca.d.ts +119 -10
  25. package/dist/drivers/orca.js +781 -110
  26. package/dist/gates/baseline.d.ts +17 -3
  27. package/dist/gates/baseline.js +63 -15
  28. package/dist/gates/cache.d.ts +101 -0
  29. package/dist/gates/cache.js +401 -0
  30. package/dist/gates/llm.d.ts +3 -0
  31. package/dist/gates/llm.js +11 -0
  32. package/dist/gates/review.d.ts +28 -3
  33. package/dist/gates/review.js +106 -14
  34. package/dist/gates/run-gates.d.ts +9 -1
  35. package/dist/gates/run-gates.js +407 -102
  36. package/dist/gates/test-manifest.d.ts +128 -0
  37. package/dist/gates/test-manifest.js +463 -0
  38. package/dist/gates/test-reporter.d.ts +4 -0
  39. package/dist/gates/test-reporter.js +57 -0
  40. package/dist/graph/graph.d.ts +2 -0
  41. package/dist/graph/graph.js +45 -1
  42. package/dist/route/preference.d.ts +22 -1
  43. package/dist/route/preference.js +123 -25
  44. package/dist/route/router.js +31 -6
  45. package/dist/run/consult.js +5 -4
  46. package/dist/run/daemon.d.ts +15 -0
  47. package/dist/run/daemon.js +638 -167
  48. package/dist/run/execution-budget.d.ts +25 -0
  49. package/dist/run/execution-budget.js +142 -0
  50. package/dist/run/git.d.ts +50 -1
  51. package/dist/run/git.js +131 -12
  52. package/dist/run/journal.d.ts +16 -2
  53. package/dist/run/journal.js +115 -25
  54. package/dist/run/lease.d.ts +58 -0
  55. package/dist/run/lease.js +310 -0
  56. package/dist/run/merge.d.ts +2 -0
  57. package/dist/run/merge.js +91 -3
  58. package/dist/run/operator-state.d.ts +11 -0
  59. package/dist/run/operator-state.js +17 -3
  60. package/dist/run/recovery.d.ts +8 -0
  61. package/dist/run/recovery.js +25 -0
  62. package/dist/run/repair-selection.d.ts +12 -0
  63. package/dist/run/repair-selection.js +56 -0
  64. package/dist/run/stall.d.ts +6 -1
  65. package/dist/run/stall.js +60 -3
  66. package/dist/tui/cockpit/board.d.ts +96 -0
  67. package/dist/tui/cockpit/board.js +346 -0
  68. package/dist/tui/cockpit/decision-actions.js +2 -0
  69. package/dist/tui/cockpit/layout.d.ts +5 -1
  70. package/dist/tui/cockpit/layout.js +8 -3
  71. package/dist/tui/cockpit/live-runtime.js +71 -21
  72. package/dist/tui/cockpit/run-view.d.ts +7 -5
  73. package/dist/tui/cockpit/run-view.js +12 -11
  74. package/dist/tui/ink/fleet-app.d.ts +49 -27
  75. package/dist/tui/ink/fleet-app.js +229 -38
  76. package/package.json +2 -2
  77. package/skills/tickmarkr-overseer/SKILL.md +173 -113
  78. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
package/dist/run/merge.js CHANGED
@@ -2,8 +2,10 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { shq } from "../adapters/types.js";
4
4
  import { ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, fingerprint, freshFailures, } from "../gates/baseline.js";
5
+ import { evaluateManifestedTest, isVitestTestCommand } from "../gates/test-manifest.js";
6
+ import { computeVerificationIdentity, formatReusedDetails, getVerdictStore, getWorktreeTree, resolveStateDir, isInfraResult, } from "../gates/cache.js";
5
7
  import { tickmarkrDir } from "../graph/graph.js";
6
- import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
8
+ import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
7
9
  export function integrationBranch(cfg, runId) {
8
10
  return `${cfg.integrationBranchPrefix}${runId}`;
9
11
  }
@@ -120,6 +122,11 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
120
122
  }
121
123
  return results;
122
124
  }
125
+ const stateDir = resolveStateDir(intWt, runDir);
126
+ const store = getVerdictStore(stateDir);
127
+ // Publish only after the entire verification completes. The daemon kills a cancelled
128
+ // verifier process, so completed early gates must remain in memory until then.
129
+ const pending = new Map();
123
130
  for (const [gate, cmd] of gatesToRun) {
124
131
  if (runStartCommands !== undefined) {
125
132
  const expectedCmd = gate === "test"
@@ -150,6 +157,73 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
150
157
  const entry = gate === "test" && (hasTipCommand || (!identity.test && identity.tipTest))
151
158
  ? baseline?.commands.tipTest
152
159
  : baseline?.commands[gate];
160
+ // A distinct tipTest selects a different forgiveness entry even when the command
161
+ // converges with the task command. Include that effective entry in the identity.
162
+ const verificationBaseline = baseline ? { commands: { ...baseline.commands } } : undefined;
163
+ if (verificationBaseline) {
164
+ if (entry)
165
+ verificationBaseline.commands[gate] = entry;
166
+ else
167
+ delete verificationBaseline.commands[gate];
168
+ }
169
+ // A tip verifier may reuse only a completed green from its own scope.
170
+ const id = await computeVerificationIdentity({
171
+ worktree: intWt,
172
+ gate,
173
+ scope: "tip",
174
+ command: cmd,
175
+ baseline: verificationBaseline,
176
+ selectedSet: undefined,
177
+ capacity: resolvedCapacity(),
178
+ });
179
+ // Persistent reuse requires the run's durable journal. Without it there is no
180
+ // recorded cycle provenance to reconcile with the caller's capacity checks.
181
+ const hit = journalFound ? store.get(id) : undefined;
182
+ if (hit && id && hit.pass && !isInfraResult(hit)) {
183
+ results.push({
184
+ gate,
185
+ cmd,
186
+ pass: hit.pass,
187
+ exitCode: hit.exitCode ?? 0,
188
+ fingerprints: hit.meta?.fingerprints ?? [],
189
+ ...(hit.meta?.forgiven ? { forgiven: true } : {}),
190
+ details: formatReusedDetails(hit.details, id),
191
+ ...(hit.meta?.reportPath ? { reportPath: hit.meta.reportPath } : {}),
192
+ ...(hit.meta?.spawnedCommand ? { spawnedCommand: hit.meta.spawnedCommand } : {}),
193
+ ...(hit.meta?.artifact ? { artifact: hit.meta.artifact } : {}),
194
+ });
195
+ continue;
196
+ }
197
+ if (id && journalFound)
198
+ pending.set(gate, id);
199
+ // VL-1: tip verify and standalone verify read the SAME invocation-bound report the gate would —
200
+ // a detected vitest test command never reaches the stdout-count/file-count path below (this
201
+ // gate's own manifest is always the FULL suite; verify has no selected screen to hold). A
202
+ // scripted test command that is not the detected runner falls through unchanged.
203
+ if (gate === "test" && isVitestTestCommand(cmd, intWt)) {
204
+ const outcome = await evaluateManifestedTest(cmd, intWt, {
205
+ baselineDurations: entry?.fileDurations,
206
+ longestFile: entry?.longestFile,
207
+ overallCeilingMs: effectiveCeilingMs(entry),
208
+ artifactDir: runDir,
209
+ });
210
+ const artifact = join(runDir, `tip-verify-${gate}.log`);
211
+ if (!outcome.pass)
212
+ writeFileSync(artifact, outcome.details);
213
+ results.push({
214
+ gate,
215
+ cmd,
216
+ pass: outcome.pass,
217
+ exitCode: outcome.exitCode,
218
+ reportPath: outcome.reportPath,
219
+ spawnedCommand: outcome.meta.spawnedCommand,
220
+ fingerprints: outcome.meta.failingTests ?? [],
221
+ details: outcome.details,
222
+ ...(outcome.classification ? { cause: outcome.classification } : {}),
223
+ ...(outcome.pass ? {} : { artifact }),
224
+ });
225
+ continue;
226
+ }
153
227
  // OBS-534: the ceiling is the BATTERY's, derived by effectiveCeilingMs from the same baseline entry
154
228
  // this loop already reads for forgiveness two lines down — never the flat DEFAULT_SHELL_TIMEOUT_MS
155
229
  // `sh` defaults to. A suite whose capture measured 600007ms carries a recorded 1800021ms ceiling;
@@ -216,7 +290,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
216
290
  const pass = !deficit && (r.code === 0 || greenTeardown || forgiven);
217
291
  if (!pass)
218
292
  writeFileSync(artifact, raw);
219
- results.push({
293
+ const tipResult = {
220
294
  gate,
221
295
  cmd,
222
296
  pass,
@@ -233,7 +307,21 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
233
307
  ...(forgiven && !deficit ? { forgiven: true } : {}),
234
308
  ...(cause ? { cause } : {}),
235
309
  ...(pass ? {} : { artifact }),
236
- });
310
+ };
311
+ results.push(tipResult);
312
+ }
313
+ // Recheck through the Git runner before publishing: cancellation disables further
314
+ // spawns in the daemon child, and a command that changed the tree invalidates reuse.
315
+ const completedTree = pending.size ? await getWorktreeTree(intWt) : undefined;
316
+ for (const result of results) {
317
+ const id = pending.get(result.gate);
318
+ if (id && id.tree === completedTree && !isInfraResult(result)) {
319
+ store.set(id, { ...result, meta: {
320
+ fingerprints: result.fingerprints, forgiven: result.forgiven,
321
+ reportPath: result.reportPath, spawnedCommand: result.spawnedCommand,
322
+ artifact: result.artifact,
323
+ } });
324
+ }
237
325
  }
238
326
  return results;
239
327
  }
@@ -30,11 +30,19 @@ export interface OperatorTask {
30
30
  evidence?: EvidenceIdentity;
31
31
  mergeEvidence?: EvidenceIdentity;
32
32
  gates: Record<string, OperatorGate>;
33
+ /** BD-1: the board's per-task counters — latest dispatch channel, review verdicts drawn, human parks. */
34
+ channel?: string;
35
+ reviewRounds: number;
36
+ parks: number;
33
37
  }
34
38
  export interface OperatorSnapshot {
35
39
  sequence: number;
36
40
  observedAt: number;
37
41
  lastEventAt?: string;
42
+ firstEventAt?: string;
43
+ /** BD-1 header counts: run-resume and escalation rows seen. */
44
+ resumes: number;
45
+ escalations: number;
38
46
  lifecycle: "PENDING" | "RUNNING" | "PARTIAL" | "APPROVED" | "COMPLETE" | "UNKNOWN";
39
47
  label: string;
40
48
  green: boolean;
@@ -63,6 +71,9 @@ export declare class OperatorStateFold {
63
71
  private approved;
64
72
  private tipFailed;
65
73
  private lastEventAt?;
74
+ private firstEventAt?;
75
+ private resumes;
76
+ private escalations;
66
77
  private passed;
67
78
  private total;
68
79
  apply(record: OperatorRecord): void;
@@ -16,11 +16,19 @@ export class OperatorStateFold {
16
16
  approved = false;
17
17
  tipFailed = false;
18
18
  lastEventAt;
19
+ firstEventAt;
20
+ resumes = 0;
21
+ escalations = 0;
19
22
  passed = 0;
20
23
  total = 0;
21
24
  apply(record) {
22
25
  const { event: e } = record;
23
26
  this.lastEventAt = e.ts;
27
+ this.firstEventAt ??= e.ts;
28
+ if (e.event === "run-resume")
29
+ this.resumes++;
30
+ if (e.event === "escalation")
31
+ this.escalations++;
24
32
  const ref = identity(record);
25
33
  if (e.event === "run-start" || e.event === "run-resume") {
26
34
  if (e.event === "run-start" && !this.start) {
@@ -72,6 +80,9 @@ export class OperatorStateFold {
72
80
  t.path = typeof e.data.worktree === "string" ? e.data.worktree : typeof e.data.cwd === "string" ? e.data.cwd : undefined;
73
81
  t.pane = typeof e.data.pane === "string" ? e.data.pane : undefined;
74
82
  t.alarmMs = typeof e.data.alarmMs === "number" ? e.data.alarmMs : undefined;
83
+ const a = e.data.assignment;
84
+ if (a && typeof a.adapter === "string" && typeof a.model === "string")
85
+ t.channel = `${a.adapter}:${a.model}`;
75
86
  }
76
87
  if (e.event === "task-done")
77
88
  t.state = "completed";
@@ -85,6 +96,7 @@ export class OperatorStateFold {
85
96
  t.state = "failed";
86
97
  if (e.event === "task-human") {
87
98
  t.state = "human";
99
+ t.parks++;
88
100
  t.parkKind = typeof e.data.kind === "string" ? e.data.kind : undefined;
89
101
  }
90
102
  if (e.event === "task-blocked")
@@ -98,6 +110,8 @@ export class OperatorStateFold {
98
110
  if (typeof gate === "string" && GATE_NAMES.includes(gate)) {
99
111
  if (e.event === "gate-start")
100
112
  t.gates[gate] = { state: "running", evidence: ref };
113
+ if (e.event === "gate-result" && gate === "review")
114
+ t.reviewRounds++;
101
115
  if (e.event === "gate-result")
102
116
  t.gates[gate] = {
103
117
  state: e.data.disabled === true ? "disabled" : e.data.skipped === true ? "not-run" : e.data.pass === true ? "passed" : e.data.pass === false ? "failed" : "unknown", evidence: ref,
@@ -107,7 +121,7 @@ export class OperatorStateFold {
107
121
  task(id) {
108
122
  let t = this.tasks.get(id);
109
123
  if (!t) {
110
- t = { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
124
+ t = { id, state: "unknown", merged: false, dispatches: 0, reviewRounds: 0, parks: 0, gates: emptyGates() };
111
125
  this.tasks.set(id, t);
112
126
  }
113
127
  return t;
@@ -117,7 +131,7 @@ export class OperatorStateFold {
117
131
  const comparable = this.comparableTo(hash);
118
132
  const ids = comparable ? [...graph.tasks.map(t => t.id), ...[...this.tasks.keys()].filter(id => !graph.tasks.some(t => t.id === id))] : [...this.tasks.keys()];
119
133
  const tasks = ids.map(id => {
120
- const t = this.tasks.get(id) ?? { id, state: "unknown", merged: false, dispatches: 0, gates: emptyGates() };
134
+ const t = this.tasks.get(id) ?? { id, state: "unknown", merged: false, dispatches: 0, reviewRounds: 0, parks: 0, gates: emptyGates() };
121
135
  return { ...t, title: comparable ? graph.tasks.find(g => g.id === id)?.title : undefined, gates: Object.fromEntries(Object.entries(t.gates).map(([g, cell]) => [g, { ...cell }])) };
122
136
  });
123
137
  const buckets = Object.fromEntries(bucketNames.map(k => [k, this.end?.buckets[k] === undefined ? undefined : [...this.end.buckets[k]]]));
@@ -134,7 +148,7 @@ export class OperatorStateFold {
134
148
  && bucketNames.every(k => buckets[k]?.length === 0);
135
149
  const lifecycle = !readable || !this.start ? "UNKNOWN" : this.active ? "RUNNING" : this.approved ? "APPROVED" : green ? "COMPLETE" : this.end ? "PARTIAL" : "PENDING";
136
150
  return {
137
- sequence, observedAt, lastEventAt: this.lastEventAt, lifecycle,
151
+ sequence, observedAt, lastEventAt: this.lastEventAt, firstEventAt: this.firstEventAt, resumes: this.resumes, escalations: this.escalations, lifecycle,
138
152
  label: this.approved ? "approved; resume required" : lifecycle,
139
153
  green, currentTip, comparable, comparison: comparable ? "matching graph" : "not comparable",
140
154
  merged,
@@ -0,0 +1,8 @@
1
+ import type { GateResult } from "../gates/types.js";
2
+ import type { JournalEvent } from "./journal.js";
3
+ export type FailureDisposition = "infrastructure" | "behavioral" | "unknown";
4
+ export type VerificationRetryCause = "infrastructure" | "host-starved";
5
+ /** Structured verifier evidence wins; neither an exit code nor prose proves infrastructure. */
6
+ export declare function failureDisposition(result: Pick<GateResult, "pass" | "meta">): FailureDisposition;
7
+ /** Reserve before re-execution. Interrupted reservations and new subjects never refill the task cap. */
8
+ export declare function reserveInfrastructureRetry(events: readonly JournalEvent[], taskId: string, subject: string, append: (event: string, taskId: string, data: Record<string, unknown>) => void, cause?: VerificationRetryCause): boolean;
@@ -0,0 +1,25 @@
1
+ /** Structured verifier evidence wins; neither an exit code nor prose proves infrastructure. */
2
+ export function failureDisposition(result) {
3
+ if (result.pass)
4
+ return "unknown";
5
+ if (result.meta?.classification === "regression")
6
+ return "behavioral";
7
+ if (result.meta?.infra === true && result.meta?.classification === "infra")
8
+ return "infrastructure";
9
+ return "unknown";
10
+ }
11
+ /** Reserve before re-execution. Interrupted reservations and new subjects never refill the task cap. */
12
+ export function reserveInfrastructureRetry(events, taskId, subject, append, cause = "infrastructure") {
13
+ const used = events.filter((e) => e.taskId === taskId && e.event === "infra-retry-reserved");
14
+ const malformed = used.some((e) => typeof e.data.subject !== "string" || !e.data.subject
15
+ || (e.data.cause !== "infrastructure" && e.data.cause !== "host-starved"));
16
+ const reason = !subject ? "unknown-subject" : malformed ? "invalid-accounting"
17
+ : used.some((e) => e.data.subject === subject) ? "subject-allowance-exhausted"
18
+ : used.length >= 2 ? "task-allowance-exhausted" : undefined;
19
+ if (reason) {
20
+ append("infra-retry-denied", taskId, { subject, cause, reason, consumed: used.length });
21
+ return false;
22
+ }
23
+ append("infra-retry-reserved", taskId, { subject, cause, consumed: used.length + 1 });
24
+ return true;
25
+ }
@@ -0,0 +1,12 @@
1
+ import type { JournalEvent } from "./journal.js";
2
+ export interface RepairSelectionDecision {
3
+ selectTests: boolean;
4
+ requiredFiles: string[];
5
+ reason: "no-test-failure" | "known-failing-files" | "legacy-test-failure" | "unattributed-test-failure" | "full-suite-failure";
6
+ }
7
+ type SelectionEvent = Pick<JournalEvent, "event" | "taskId" | "data">;
8
+ /** Diagnostic selection only: neither a narrow pass nor this decision can authorize integration.
9
+ * Distrust and known failing files survive later green results, approvals and resume. A historical
10
+ * ambiguous failure is never reinterpreted using a newly enabled policy. */
11
+ export declare function repairSelectionDecision(events: readonly SelectionEvent[], taskId: string, enabled: boolean): RepairSelectionDecision;
12
+ export {};
@@ -0,0 +1,56 @@
1
+ /** These are literal repository-relative identities. Existence and selection support are checked
2
+ * against the current worktree by the gate; this journal fold cannot establish either. */
3
+ function fileIdentities(value) {
4
+ if (!Array.isArray(value) || value.length === 0 || value.some((file) => typeof file !== "string" || !file || /[\\\x00-\x1f\x7f]/.test(file)
5
+ || /^[A-Za-z]:/.test(file) || file.split("/").some((part) => !part || part === "." || part === ".."))) {
6
+ return undefined;
7
+ }
8
+ return [...new Set(value)].sort();
9
+ }
10
+ /** Diagnostic selection only: neither a narrow pass nor this decision can authorize integration.
11
+ * Distrust and known failing files survive later green results, approvals and resume. A historical
12
+ * ambiguous failure is never reinterpreted using a newly enabled policy. */
13
+ export function repairSelectionDecision(events, taskId, enabled) {
14
+ const required = new Set();
15
+ let distrust;
16
+ for (const event of events) {
17
+ if (event.taskId !== taskId || event.event !== "gate-result"
18
+ || event.data.gate !== "test" || event.data.pass !== false)
19
+ continue;
20
+ const data = event.data;
21
+ if (!enabled) {
22
+ distrust ??= "legacy-test-failure";
23
+ continue;
24
+ }
25
+ // Only the recorded closed disposition can exempt infrastructure. Contradictory structured
26
+ // failure evidence cannot turn a behavioral failure into a harmless infrastructure event.
27
+ if (data.disposition === "infrastructure" && data.classification !== "regression"
28
+ && (data.failingFiles === undefined || (Array.isArray(data.failingFiles) && data.failingFiles.length === 0)))
29
+ continue;
30
+ if (data.disposition !== "behavioral" || typeof data.commit !== "string"
31
+ || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(data.commit)) {
32
+ distrust ??= "unattributed-test-failure";
33
+ continue;
34
+ }
35
+ // A replacement full-suite failure follows a passing screen in the current pipeline. No
36
+ // ordinary full-suite failure establishes that a selector safely covers the failed behavior.
37
+ if (data.fullSuite === true) {
38
+ distrust ??= "full-suite-failure";
39
+ continue;
40
+ }
41
+ const selected = fileIdentities(data.selectedTests);
42
+ const failing = fileIdentities(data.failingFiles);
43
+ if (!selected || !failing || failing.some((file) => !selected.includes(file))
44
+ || (data.fullSuite !== undefined && data.fullSuite !== false)) {
45
+ distrust ??= "unattributed-test-failure";
46
+ continue;
47
+ }
48
+ for (const file of failing)
49
+ required.add(file);
50
+ }
51
+ return {
52
+ selectTests: distrust === undefined,
53
+ requiredFiles: [...required].sort(),
54
+ reason: distrust ?? (required.size ? "known-failing-files" : "no-test-failure"),
55
+ };
56
+ }
@@ -10,6 +10,10 @@ export declare function harvestCpuFlatWindowMs(resolutionMs: number): number;
10
10
  /** Test seam — pin the quantization-aware flat window without changing production policy. */
11
11
  export declare function setHarvestCpuFlatMsForTests(ms: number): void;
12
12
  export declare function resetHarvestCpuFlatMsForTests(): void;
13
+ /** The dispatch shell records its foreground group in an attempt-unique artifact. */
14
+ export declare function readOwnedProcessGroup(path: string): number | undefined;
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>;
13
17
  export declare function workerTreeCpuMs(marker: string, cwd: string): Promise<{
14
18
  ms: number;
15
19
  resolutionMs: number;
@@ -17,6 +21,7 @@ export declare function workerTreeCpuMs(marker: string, cwd: string): Promise<{
17
21
  export declare class WorkerTreeCpuAccountant {
18
22
  private marker;
19
23
  private cwd;
24
+ private group?;
20
25
  private active;
21
26
  private loop;
22
27
  private live;
@@ -24,7 +29,7 @@ export declare class WorkerTreeCpuAccountant {
24
29
  private gaps;
25
30
  private consecutiveGaps;
26
31
  private latest;
27
- constructor(marker: string, cwd: string);
32
+ constructor(marker: string, cwd: string, group?: (() => number | undefined) | undefined);
28
33
  private sample;
29
34
  start(): Promise<void>;
30
35
  read(): {
package/dist/run/stall.js CHANGED
@@ -52,6 +52,51 @@ function parsePsCpu(raw) {
52
52
  + Math.round(Number(m[4]) * 1000);
53
53
  return { ms, frac: m[4].includes(".") };
54
54
  }
55
+ /** The dispatch shell records its foreground group in an attempt-unique artifact. */
56
+ export function readOwnedProcessGroup(path) {
57
+ try {
58
+ const raw = readFileSync(path, "utf8").trim();
59
+ const group = /^\d+$/.test(raw) ? Number(raw) : 0;
60
+ return Number.isSafeInteger(group) && group > 1 ? group : undefined;
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ /** Never infer ownership from a task name or scan another attempt's marker when reaping. */
67
+ export async function reapOwnedProcessGroup(group, cwd) {
68
+ if (group === undefined)
69
+ return null;
70
+ const own = await shGit(`ps -o pgid= -p ${process.pid}`, cwd, 5_000);
71
+ // An in-process driver fixture (or a non-isolating host) can share the daemon's
72
+ // group. It is not an owned worker group: let the driver's close retire that slot.
73
+ if (own.code !== 0 || !/^\d+$/.test(own.stdout.trim()) || Number(own.stdout.trim()) === group)
74
+ return null;
75
+ try {
76
+ process.kill(-group, "SIGKILL");
77
+ }
78
+ catch (error) {
79
+ if (error.code !== "ESRCH")
80
+ throw error;
81
+ }
82
+ let survivors = [];
83
+ // SIGKILL delivery and process retirement are asynchronous. Bound the confirmation
84
+ // grace, retaining names if a process is still present after it.
85
+ for (let probe = 0; probe < 5; probe++) {
86
+ const snapshot = await shGit("ps -Awwo pid=,pgid=,stat=", cwd, 5_000);
87
+ if (snapshot.code !== 0)
88
+ return null;
89
+ survivors = snapshot.stdout.split("\n").flatMap((line) => {
90
+ const row = /^\s*(\d+)\s+(\d+)\s+(\S+)/.exec(line);
91
+ return row && Number(row[2]) === group && !row[3].startsWith("Z") ? [Number(row[1])] : [];
92
+ });
93
+ if (survivors.length === 0)
94
+ break;
95
+ if (probe < 4)
96
+ await new Promise((resolve) => setTimeout(resolve, 50));
97
+ }
98
+ return survivors;
99
+ }
55
100
  let linuxClockTickMs;
56
101
  function linuxProcessCpuMs(pid, cwd) {
57
102
  if (!existsSync("/proc/self/stat"))
@@ -81,7 +126,7 @@ function linuxProcessCpuMs(pid, cwd) {
81
126
  // Every non-seeded worker process descends from its attempt-unique dispatch script. One ps snapshot
82
127
  // finds that root and its descendants. An empty tree is measurable zero; a failed or unparseable
83
128
  // snapshot is undefined because missing evidence can never prove inactivity.
84
- async function workerTreeCpuSnapshot(marker, cwd) {
129
+ async function workerTreeCpuSnapshot(marker, cwd, group) {
85
130
  const snapshot = await shGit("ps -Awwo pid=,ppid=,time=,command=", cwd, 15_000);
86
131
  if (snapshot.code !== 0)
87
132
  return undefined;
@@ -97,6 +142,16 @@ async function workerTreeCpuSnapshot(marker, cwd) {
97
142
  if (rows.length === 0)
98
143
  return undefined;
99
144
  const tree = new Set(rows.filter((p) => p.cmd.includes(marker)).map((p) => p.pid));
145
+ if (group !== undefined) {
146
+ const groups = await shGit("ps -Awwo pid=,pgid=", cwd, 15_000);
147
+ if (groups.code !== 0)
148
+ return undefined;
149
+ for (const line of groups.stdout.split("\n")) {
150
+ const row = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
151
+ if (row && Number(row[2]) === group)
152
+ tree.add(row[1]);
153
+ }
154
+ }
100
155
  // ps output is not topologically ordered; relax the parent -> child closure until stable.
101
156
  for (let grew = true; grew;) {
102
157
  grew = false;
@@ -141,6 +196,7 @@ export async function workerTreeCpuMs(marker, cwd) {
141
196
  export class WorkerTreeCpuAccountant {
142
197
  marker;
143
198
  cwd;
199
+ group;
144
200
  active = false;
145
201
  loop;
146
202
  live = new Map();
@@ -148,12 +204,13 @@ export class WorkerTreeCpuAccountant {
148
204
  gaps = 0;
149
205
  consecutiveGaps = 0;
150
206
  latest;
151
- constructor(marker, cwd) {
207
+ constructor(marker, cwd, group) {
152
208
  this.marker = marker;
153
209
  this.cwd = cwd;
210
+ this.group = group;
154
211
  }
155
212
  async sample() {
156
- const snapshot = await workerTreeCpuSnapshot(this.marker, this.cwd);
213
+ const snapshot = await workerTreeCpuSnapshot(this.marker, this.cwd, this.group?.());
157
214
  if (snapshot === undefined) {
158
215
  this.gaps++;
159
216
  this.live.clear();
@@ -0,0 +1,96 @@
1
+ import { type RunGraph } from "../../graph/schema.js";
2
+ import type { OperatorSnapshot } from "../../run/operator-state.js";
3
+ /** The prototype's palette (tasks-redesign.mjs:24-30): xterm-256 indices, stated once. */
4
+ export declare const BOARD_PALETTE: {
5
+ readonly chrome: 238;
6
+ readonly mute: 243;
7
+ readonly body: 252;
8
+ readonly faint: 240;
9
+ readonly accent: 75;
10
+ readonly pass: 71;
11
+ readonly fail: 174;
12
+ readonly warn: 179;
13
+ readonly park: 140;
14
+ };
15
+ /** The seven gates, declaration order; every cell is exactly CELL columns wide. */
16
+ export declare const BOARD_GATES: readonly ["build", "test", "lint", "evidence", "scope", "acceptance", "review"];
17
+ export declare const BOARD_CELL = 3;
18
+ export declare const BOARD_AREA_W = 16;
19
+ export declare const BOARD_DEPS_W = 14;
20
+ export declare const BOARD_NOTE_RESERVE = 28;
21
+ export declare const BOARD_FIXED: number;
22
+ export declare const BOARD_EFFORT_BAR_MAX = 46;
23
+ /** The board's own key hints — only the ones the Run view handles; nothing advertised is inert. */
24
+ export declare const BOARD_KEYS: readonly ["↑↓ move", "↵ task detail", "q quit"];
25
+ /** Width band the Run view draws in (RULING-231-19 §2: ≥150 full · 110–149 note wraps · <110 stack). */
26
+ export type BoardBand = "full" | "wrap" | "stacked";
27
+ export declare const boardBand: (columns: number) => BoardBand;
28
+ export declare const stripBoardAnsi: (s: string) => string;
29
+ /** ANSI-aware clip at `n` visible cells (prototype `clip`), cut on a grapheme boundary so a
30
+ * double-cell glyph never crosses the limit and an emoji is never emitted as a lone surrogate;
31
+ * a clipped line closes its own colour. */
32
+ export declare function clipBoard(s: string, n: number): string;
33
+ export declare const BOARD_AREA_ORDER: string[];
34
+ /** {codes, unmapped} — abstentions applied, set semantics, misses reported rather than dropped. */
35
+ export declare function areasFor(files: readonly string[]): {
36
+ codes: string[];
37
+ unmapped: string[];
38
+ };
39
+ export interface BoardInput {
40
+ readonly runId: string;
41
+ readonly snapshot: OperatorSnapshot;
42
+ readonly graph?: Pick<RunGraph, "tasks">;
43
+ /** The clock the header's elapsed/ago fields read. */
44
+ readonly now: number;
45
+ /** A live daemon holds this run's lock. */
46
+ readonly live?: boolean;
47
+ /** The task the focus marker sits on; absent draws the prototype's plain indent. */
48
+ readonly selection?: string;
49
+ /** Footer key hints, joined the prototype's way; `false` leaves the footer to the caller (see boardFooter). */
50
+ readonly keys?: readonly string[] | false;
51
+ readonly colour?: boolean;
52
+ }
53
+ /** One task row, in the parts the Run view recomposes per width band. */
54
+ export interface BoardRow {
55
+ readonly id: string;
56
+ /** indent (or the focus marker) + id + area + deps + title */
57
+ readonly head: string;
58
+ /** the seven gate cells */
59
+ readonly strip: string;
60
+ /** channel + attempts */
61
+ readonly tail: string;
62
+ /** `◍ parked ×n · ↻ n review rounds · ✖ gate` / `waiting on …` / `in flight`, joined by ` · ` */
63
+ readonly note: string;
64
+ /** The note's segments, so a band can wrap it across continuation rows without cutting one. */
65
+ readonly noteParts: readonly string[];
66
+ }
67
+ export interface BoardFrame {
68
+ /** Header through the gate header rule, in the prototype's order. */
69
+ readonly top: readonly string[];
70
+ readonly rows: readonly BoardRow[];
71
+ /** Header row parts, aligned to the row parts. */
72
+ readonly gateHeader: {
73
+ readonly head: string;
74
+ readonly strip: string;
75
+ readonly tail: string;
76
+ };
77
+ /** Legend, retired ids, rule, effort fold, footer. */
78
+ readonly bottom: readonly string[];
79
+ readonly width: number;
80
+ readonly lbl: number;
81
+ readonly chan: number;
82
+ }
83
+ /** The prototype's frame, sectioned. `renderBoard` joins it row for row. */
84
+ export declare function boardFrame(input: BoardInput, width: number): BoardFrame;
85
+ /** The footer row: the key hints in chrome, the prototype's leading space kept. */
86
+ export declare const boardFooter: (keys: readonly string[], colour?: boolean) => string;
87
+ /** The prototype's frame as lines, one row per task, unclipped — exactly what the design signed off. */
88
+ export declare function renderBoard(input: BoardInput, width: number): string[];
89
+ /**
90
+ * The Run view's three width bands over the same frame: full draws the prototype's rows; wrap
91
+ * puts a note that does not fit on its own second row; stacked draws the gate cells, channel and
92
+ * attempts under the row so nothing is truncated away.
93
+ */
94
+ export declare function renderBoardLines(input: BoardInput, width: number): string[];
95
+ /** Pack note segments greedily into rows of `avail` cells; a segment wider than a row splits at the cell. */
96
+ export declare function wrapNote(parts: readonly string[], sep: string, firstAvail: number, avail: number): string[];