tickmarkr 1.97.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/run/stall.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { shGit } from "./git.js";
1
3
  // OBS-82: normalize known presentation tokens before measuring transcript extent or filtering an
2
4
  // LLM-bound transcript. This remains a closed allowlist — ANSI/VT escapes, braille-range spinner
3
5
  // glyphs, and elapsed-time tokens bound to time-unit suffixes. Every other byte passes through
@@ -18,6 +20,177 @@ const ELAPSED_RE = /(?<![\w.])\d+(?:\.\d+)?(?:ms|[hms])(?!\w)/g;
18
20
  export function normalizeStallSnapshot(text) {
19
21
  return text.replace(ANSI_RE, "").replace(SPINNER_RE, "").replace(ELAPSED_RE, "");
20
22
  }
23
+ // T2 (OBS-264): a CPU delta needs two samples separated in WALL CLOCK, and the CPU clock is
24
+ // QUANTIZED: darwin's `ps` prints hundredths ("0:00.03"), linux's precise /proc clock advances in
25
+ // jiffies. Equality across a window shorter than the quantum is not evidence of rest. Crossing 30
26
+ // ticks means the tree burned <1 tick in 30, i.e. under ~3% of one core. This process-tree liveness
27
+ // primitive lives here rather than in daemon.ts so lower-level gate waits can use it without closing
28
+ // the daemon -> run-gates -> llm -> daemon dependency cycle.
29
+ const HARVEST_CPU_FLAT_MS = 3_000;
30
+ const HARVEST_CPU_FLAT_TICKS = 30;
31
+ const WORKER_TREE_CPU_ACCOUNTING_POLL_MS = 100;
32
+ const WORKER_TREE_CPU_UNMEASURABLE_SAMPLE_CAP = 20;
33
+ let harvestCpuFlatMs;
34
+ export function harvestCpuFlatWindowMs(resolutionMs) {
35
+ return harvestCpuFlatMs ?? Math.max(HARVEST_CPU_FLAT_MS, resolutionMs * HARVEST_CPU_FLAT_TICKS);
36
+ }
37
+ /** Test seam — pin the quantization-aware flat window without changing production policy. */
38
+ export function setHarvestCpuFlatMsForTests(ms) {
39
+ harvestCpuFlatMs = ms;
40
+ }
41
+ export function resetHarvestCpuFlatMsForTests() {
42
+ harvestCpuFlatMs = undefined;
43
+ }
44
+ // `ps` CPU time: "[[dd-]hh:]mm:ss[.frac]". Anything else is a header or a row this parser must
45
+ // not guess at. `frac` reports whether this host exposes sub-second digits so callers use the
46
+ // sampled clock's quantum rather than assuming one.
47
+ function parsePsCpu(raw) {
48
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(raw);
49
+ if (!m)
50
+ return undefined;
51
+ const ms = ((Number(m[1] ?? 0) * 24 + Number(m[2] ?? 0)) * 60 + Number(m[3])) * 60_000
52
+ + Math.round(Number(m[4]) * 1000);
53
+ return { ms, frac: m[4].includes(".") };
54
+ }
55
+ let linuxClockTickMs;
56
+ function linuxProcessCpuMs(pid, cwd) {
57
+ if (!existsSync("/proc/self/stat"))
58
+ return Promise.resolve(undefined);
59
+ // shGit, not sh: the accountant samples at 100ms cadence and must not run the operator's login
60
+ // profile (nvm/pyenv/direnv side effects included) on every sample.
61
+ linuxClockTickMs ??= shGit("getconf CLK_TCK", cwd, 15_000).then((r) => {
62
+ const ticks = r.code === 0 ? Number(r.stdout.trim()) : Number.NaN;
63
+ return Number.isFinite(ticks) && ticks > 0 ? 1_000 / ticks : undefined;
64
+ });
65
+ return linuxClockTickMs.then((resolutionMs) => {
66
+ if (resolutionMs === undefined)
67
+ return undefined;
68
+ try {
69
+ // `/proc/<pid>/stat` fields 14-17 are user/system jiffies for the process and its waited-for
70
+ // children. Child totals retain tools that start and exit wholly between live-tree polls.
71
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
72
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
73
+ const ticks = Number(fields[11]) + Number(fields[12]) + Number(fields[13]) + Number(fields[14]);
74
+ return Number.isFinite(ticks) ? { ms: ticks * resolutionMs, resolutionMs } : undefined;
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ });
80
+ }
81
+ // Every non-seeded worker process descends from its attempt-unique dispatch script. One ps snapshot
82
+ // finds that root and its descendants. An empty tree is measurable zero; a failed or unparseable
83
+ // snapshot is undefined because missing evidence can never prove inactivity.
84
+ async function workerTreeCpuSnapshot(marker, cwd) {
85
+ const snapshot = await shGit("ps -Awwo pid=,ppid=,time=,command=", cwd, 15_000);
86
+ if (snapshot.code !== 0)
87
+ return undefined;
88
+ const rows = [];
89
+ for (const line of snapshot.stdout.split("\n")) {
90
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
91
+ if (!m)
92
+ continue;
93
+ const cpu = parsePsCpu(m[3]);
94
+ if (cpu !== undefined)
95
+ rows.push({ pid: m[1], ppid: m[2], cpuMs: cpu.ms, frac: cpu.frac, cmd: m[4] });
96
+ }
97
+ if (rows.length === 0)
98
+ return undefined;
99
+ const tree = new Set(rows.filter((p) => p.cmd.includes(marker)).map((p) => p.pid));
100
+ // ps output is not topologically ordered; relax the parent -> child closure until stable.
101
+ for (let grew = true; grew;) {
102
+ grew = false;
103
+ for (const p of rows) {
104
+ if (!tree.has(p.pid) && tree.has(p.ppid)) {
105
+ tree.add(p.pid);
106
+ grew = true;
107
+ }
108
+ }
109
+ }
110
+ const precise = new Map();
111
+ let preciseResolutionMs;
112
+ for (const p of rows) {
113
+ if (!tree.has(p.pid))
114
+ continue;
115
+ const cpu = await linuxProcessCpuMs(p.pid, cwd);
116
+ precise.set(p.pid, cpu?.ms ?? p.cpuMs);
117
+ if (cpu !== undefined)
118
+ preciseResolutionMs = cpu.resolutionMs;
119
+ }
120
+ if (preciseResolutionMs === undefined && existsSync("/proc/self/stat")) {
121
+ preciseResolutionMs = (await linuxProcessCpuMs(String(process.pid), cwd))?.resolutionMs;
122
+ }
123
+ return {
124
+ processes: precise,
125
+ resolutionMs: preciseResolutionMs ?? (rows.some((p) => p.frac) ? 10 : 1_000),
126
+ };
127
+ }
128
+ export async function workerTreeCpuMs(marker, cwd) {
129
+ const snapshot = await workerTreeCpuSnapshot(marker, cwd);
130
+ if (snapshot === undefined)
131
+ return undefined;
132
+ return {
133
+ ms: [...snapshot.processes.values()].reduce((sum, cpuMs) => sum + cpuMs, 0),
134
+ resolutionMs: snapshot.resolutionMs,
135
+ };
136
+ }
137
+ // Sparse live-tree totals forget a tool's CPU as soon as it exits. This attempt-local accountant
138
+ // instead accumulates each observed PID's delta and replaces only the live-PID cursor. Daemon workers
139
+ // and gate workers deliberately share these semantics: the marker is the unique dispatch script,
140
+ // an unreadable sample clears the current evidence, and stopped descendants remain in the total.
141
+ export class WorkerTreeCpuAccountant {
142
+ marker;
143
+ cwd;
144
+ active = false;
145
+ loop;
146
+ live = new Map();
147
+ totalMs = 0;
148
+ gaps = 0;
149
+ consecutiveGaps = 0;
150
+ latest;
151
+ constructor(marker, cwd) {
152
+ this.marker = marker;
153
+ this.cwd = cwd;
154
+ }
155
+ async sample() {
156
+ const snapshot = await workerTreeCpuSnapshot(this.marker, this.cwd);
157
+ if (snapshot === undefined) {
158
+ this.gaps++;
159
+ this.live.clear();
160
+ this.latest = undefined;
161
+ if (++this.consecutiveGaps >= WORKER_TREE_CPU_UNMEASURABLE_SAMPLE_CAP)
162
+ this.active = false;
163
+ return;
164
+ }
165
+ this.consecutiveGaps = 0;
166
+ for (const [pid, cpuMs] of snapshot.processes) {
167
+ const prior = this.live.get(pid);
168
+ this.totalMs += prior === undefined || cpuMs < prior ? cpuMs : cpuMs - prior;
169
+ }
170
+ this.live = snapshot.processes;
171
+ this.latest = { ms: this.totalMs, resolutionMs: snapshot.resolutionMs };
172
+ }
173
+ async start() {
174
+ if (this.active)
175
+ return;
176
+ this.active = true;
177
+ await this.sample();
178
+ this.loop = (async () => {
179
+ while (this.active) {
180
+ await new Promise((resolve) => setTimeout(resolve, WORKER_TREE_CPU_ACCOUNTING_POLL_MS));
181
+ if (this.active)
182
+ await this.sample();
183
+ }
184
+ })();
185
+ }
186
+ read() {
187
+ return { cpu: this.latest, gaps: this.gaps };
188
+ }
189
+ async stop() {
190
+ this.active = false;
191
+ await this.loop;
192
+ }
193
+ }
21
194
  // T1 (OBS-262): the rescue nudge's adapter scope — claude-code only (steering path proven,
22
195
  // OBS-122). Widening it is a future fixture-capture chore (an occupied-frame capture per adapter,
23
196
  // OBS-181 scar), never a drive-by edit. Lives in the stall module so the watchdog's policy and its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.97.0",
3
+ "version": "2.0.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,13 +64,15 @@ through brief lineage. **An executor choice nobody made is still an executor cho
64
64
  **FIVE-TAB CANON (standing operator layout — corrected three times on 2026-07-27, layout approved
65
65
  2026-07-29, re-earned 2026-08-17):**
66
66
  - `OVERSEER` — you. Do not add a second live run surface: the daemon self-places the shipped board
67
- beside the supervising seat that invokes the run.
68
- - `ORCH` — the orchestrator and the daemon-placed, run-id-pinned shipped board beside it. **Look for
69
- that `role: "watch"` pane; never hand-place or hand-roll a live run surface. Nothing else, ever: a
70
- work seat NEVER splits into the ORCH tab.** Operator verbatim: *"in orch tab should be the orch and
71
- the watcher only."* Re-earned 2026-08-17: a planning seat split beside the orchestrator, and the
72
- operator caught it, again. The daemon owns the side placement and board-first width allocation;
73
- neither the worker-pane halving floor nor an overseer split command places this pane.
67
+ ABOVE the supervising seat that invokes the run.
68
+ - `ORCH` — the orchestrator with the daemon-placed, run-id-pinned shipped board STACKED ABOVE it: the
69
+ board owns the tab's full width and the top 72% of its height, and the orchestrator's own narration
70
+ is the rail underneath. **Look for that `role: "watch"` pane; never hand-place or hand-roll a live
71
+ run surface. Nothing else, ever: a work seat NEVER splits into the ORCH tab.** Operator verbatim:
72
+ *"in orch tab should be the orch and the watcher only."* Re-earned 2026-08-17: a planning seat split
73
+ beside the orchestrator, and the operator caught it, again. The daemon owns this vertical stack and
74
+ places it the same way at every terminal width; neither the worker-pane halving floor, nor a
75
+ measured column count, nor an overseer split command places this pane.
74
76
  - Worker/seat tabs — tickmarkr opens ONE TAB PER TASK itself; GSD-leg seats get the same treatment
75
77
  (own tab, or a shared WORKERS tab), never the ORCH tab.
76
78
  - `CONSULT · <topic>` — ONE shared tab for ALL consultants of a round, side-by-side splits; never one
@@ -134,10 +136,12 @@ journal tail to decide what happens next, or sweeping orphans — you have taken
134
136
  ### What the ORCHESTRATOR does, and what you require of it
135
137
 
136
138
  - **The live surface arrives with the run.** `tickmarkr run` is stdout-silent until run-end by design;
137
- its daemon self-places one shipped `role: "watch"` board beside the supervising seat and pins that
138
- board to the daemon's run id. **Look for the matching daemon-placed pane.** If it is absent, treat that
139
- as a daemon/run liveness fault and use the normal recovery path; never hand-place, hand-roll, or launch
140
- a replacement live surface.
139
+ its daemon self-places one shipped `role: "watch"` board ABOVE the supervising seat — full width, top
140
+ 72% of the height, narration below — and pins that board to the daemon's run id. **Look for the matching
141
+ daemon-placed pane.** If it is absent, treat that as a daemon/run liveness fault and use the normal
142
+ recovery path; never hand-place, hand-roll, or launch a replacement live surface. A board the daemon
143
+ could not stack is not silently re-arranged: the split is closed and the run continues BOARDLESS, so an
144
+ absent board means the placement failed, never that it landed somewhere else in the tab.
141
145
  - **The journal is the source of truth**, not panes. Watchers go on `run-end` / `task-human` /
142
146
  `task-failed` / `consult-verdict`; never sleep-poll inside an agent turn. **Never key a watcher on an
143
147
  agent's `done`** — that is turn end and fires the moment a seat finishes acknowledging you.
@@ -551,10 +555,11 @@ orchestrator turn boundary.
551
555
  safe 53 → floor 108"*), and it splits right only while `paneWidth/2 ≥ 108 + 2` (`herdr.ts:494`),
552
556
  otherwise **down**. Apply the same test by hand: `herdr pane layout --pane <id>`, halve the width,
553
557
  and if the halves fall under the floor, split `--direction down`.
554
- - **The daemon-placed ORCH board is outside this manual split rule.** The halving bound protects
555
- worker-pane trailers; the daemon, not the overseer, places the run-id-pinned shipped board beside
556
- the supervising seat and owns its board-first width allocation. Look for that pane and do not split,
557
- place, or recreate it.
558
+ - **The daemon-placed ORCH board is outside this manual split rule — width does not place it at all.**
559
+ The halving bound protects worker-pane trailers; the board carries none. The daemon, not the overseer,
560
+ splits the supervising seat DOWN at ratio 0.72 and swaps the new pane ABOVE it (`boardSplitPlan`,
561
+ `src/drivers/herdr.ts`), so the board is full width and the narration is the rail beneath it at every
562
+ terminal width. Look for that pane and do not split, place, resize, or recreate it.
558
563
  - **Binary splits cannot produce an even 3-column row at any width.** 220 goes to 110/55/55 whichever
559
564
  pane you split. **At a 220-col terminal the width-derived cap is TWO side-by-side panes**; a third
560
565
  seat goes below one of them, or into its own tab. "Three panes" is a *height* heuristic