tickmarkr 2.1.8 → 2.2.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.
@@ -6,6 +6,7 @@ import { declaredInputBoxForWorkerName, matchesEmptyInputBox, matchesInputBox, m
6
6
  import { consumePaneLaunchIntent, PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
7
7
  import { createWorktree, sh } from "../run/git.js";
8
8
  import { Journal } from "../run/journal.js";
9
+ import { readSupervision } from "../run/supervision.js";
9
10
  import { herdrSealShellPrefix } from "./subprocess.js";
10
11
  import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
11
12
  // VIS-09 P43-03: adopted safety floor from 43-MEASUREMENT.md (narrowest safe 53 → floor 108).
@@ -194,6 +195,64 @@ export class HerdrDriver {
194
195
  // recovery to the file and the pipe while the operator's rail stays silent about it.
195
196
  Journal.open(repoRoot, owned.runId, this.narrate).append("dispatch-retry", owned.taskId, data);
196
197
  }
198
+ openRunJournal(runId) {
199
+ const roots = new Set(this.journalRoots.values());
200
+ roots.add(process.cwd());
201
+ for (const repoRoot of roots) {
202
+ try {
203
+ return { repoRoot, journal: Journal.open(repoRoot, runId, this.narrate) };
204
+ }
205
+ catch {
206
+ /* try the next known root */
207
+ }
208
+ }
209
+ return undefined;
210
+ }
211
+ liveSupervisionSeats(repoRoot) {
212
+ const seats = new Set();
213
+ for (const tier of readSupervision(repoRoot)) {
214
+ if (tier.state === "ARMED" && tier.seat)
215
+ seats.add(tier.seat);
216
+ }
217
+ return seats;
218
+ }
219
+ journalReconcile(handle, event, taskId, data) {
220
+ try {
221
+ handle?.journal.append(event, taskId, data);
222
+ }
223
+ catch {
224
+ /* reconcile remains cosmetic even when its audit row cannot be written */
225
+ }
226
+ }
227
+ paneReconcileData(pane, label, ownedName, sweeperRunId) {
228
+ return {
229
+ paneId: pane.paneId,
230
+ ...(pane.tabId !== undefined ? { tabId: pane.tabId } : {}),
231
+ label,
232
+ ownedName,
233
+ ownedRunId: ownedName.runId,
234
+ runId: sweeperRunId,
235
+ sweeperRunId,
236
+ };
237
+ }
238
+ parsePaneList(handle, runId, stdout, stage) {
239
+ try {
240
+ const panes = JSON.parse(stdout).result?.panes;
241
+ if (!Array.isArray(panes))
242
+ throw new Error("pane list returned no panes array");
243
+ return panes;
244
+ }
245
+ catch (error) {
246
+ this.journalReconcile(handle, "pane-reconcile-list-failed", undefined, {
247
+ runId,
248
+ sweeperRunId: runId,
249
+ stage,
250
+ error: error instanceof Error ? error.message : String(error),
251
+ stdout,
252
+ });
253
+ return null;
254
+ }
255
+ }
197
256
  /** v1.99 T2: bind this driver's own journal writes to the run's live narration sink. */
198
257
  narrateWith(narrate) {
199
258
  this.narrate = narrate;
@@ -1199,6 +1258,7 @@ export class HerdrDriver {
1199
1258
  // resume/end) run with nothing in flight and take them too. Cosmetic by contract: every failure —
1200
1259
  // herdr gone, pane vanished mid-sweep, unparseable listing — is swallowed; this method never throws.
1201
1260
  async reconcile(desired, runId, opts) {
1261
+ const journalHandle = this.openRunJournal(runId);
1202
1262
  try {
1203
1263
  if (!this.ws)
1204
1264
  return;
@@ -1206,25 +1266,96 @@ export class HerdrDriver {
1206
1266
  // panesToClose skips any pane whose label doesn't parse as tickmarkr-owned (orchestrator/operator
1207
1267
  // shells, undetected agents), so a fuller pane listing never widens the blast radius.
1208
1268
  const list = await this.herdr("pane list");
1209
- const panes = JSON.parse(list.stdout).result?.panes ?? [];
1210
- const toClose = panesToClose(panes.map((p) => ({ name: p.label, paneId: p.pane_id, tabId: p.tab_id, workspaceId: p.workspace_id })), desired, this.ws, runId, opts);
1269
+ if (list.code !== 0) {
1270
+ this.journalReconcile(journalHandle, "pane-reconcile-list-failed", undefined, {
1271
+ runId,
1272
+ sweeperRunId: runId,
1273
+ stage: "pre-close",
1274
+ exitCode: list.code,
1275
+ error: list.stderr || list.stdout || `exit ${list.code}`,
1276
+ });
1277
+ return;
1278
+ }
1279
+ const panes = this.parsePaneList(journalHandle, runId, list.stdout, "pre-close");
1280
+ if (panes === null)
1281
+ return;
1282
+ const liveSeats = journalHandle ? this.liveSupervisionSeats(journalHandle.repoRoot) : new Set();
1283
+ for (const seat of opts?.liveSeats ?? [])
1284
+ liveSeats.add(seat);
1285
+ const toClose = panesToClose(panes.map((p) => ({ name: p.label, paneId: p.pane_id, tabId: p.tab_id, workspaceId: p.workspace_id })), desired, this.ws, runId, { ...opts, liveSeats });
1286
+ const paneById = new Map(panes
1287
+ .filter((p) => typeof p.pane_id === "string")
1288
+ .map((p) => [p.pane_id, p]));
1211
1289
  const touched = new Set();
1212
1290
  for (const c of toClose) {
1213
- if (typeof c.tabId === "string")
1214
- touched.add(c.tabId);
1215
- await this.herdr(`pane close ${shq(c.paneId)}`); // best-effort — a vanished pane is already reconciled
1291
+ const listed = paneById.get(c.paneId);
1292
+ const label = typeof listed?.label === "string" ? listed.label : "";
1293
+ const ownedName = parseOwnedName(label);
1294
+ if (!ownedName) {
1295
+ this.journalReconcile(journalHandle, "pane-reconcile-close-failed", undefined, {
1296
+ paneId: c.paneId,
1297
+ ...(c.tabId !== undefined ? { tabId: c.tabId } : {}),
1298
+ label,
1299
+ runId,
1300
+ sweeperRunId: runId,
1301
+ error: "pane selected for reconcile no longer has a parseable owned label",
1302
+ });
1303
+ continue;
1304
+ }
1305
+ const data = this.paneReconcileData(c, label, ownedName, runId);
1306
+ const closed = await this.herdr(`pane close ${shq(c.paneId)}`);
1307
+ if (closed.code === 0) {
1308
+ if (typeof c.tabId === "string")
1309
+ touched.add(c.tabId);
1310
+ this.journalReconcile(journalHandle, "pane-reconcile-close", ownedName.taskId, data);
1311
+ }
1312
+ else {
1313
+ this.journalReconcile(journalHandle, "pane-reconcile-close-failed", ownedName.taskId, {
1314
+ ...data,
1315
+ exitCode: closed.code,
1316
+ error: closed.stderr || closed.stdout || `exit ${closed.code}`,
1317
+ });
1318
+ }
1216
1319
  }
1217
1320
  if (touched.size === 0)
1218
1321
  return;
1219
1322
  // a tab our closes emptied was ours by construction (a tab with operator panes still has panes)
1220
1323
  const pl = await this.herdr("pane list");
1221
- const alive = new Set((JSON.parse(pl.stdout).result?.panes ?? []).map((p) => p.tab_id));
1222
- for (const tab of touched)
1223
- if (!alive.has(tab))
1224
- await this.herdr(`tab close ${shq(tab)}`);
1324
+ if (pl.code !== 0) {
1325
+ this.journalReconcile(journalHandle, "pane-reconcile-list-failed", undefined, {
1326
+ runId,
1327
+ sweeperRunId: runId,
1328
+ stage: "post-close",
1329
+ exitCode: pl.code,
1330
+ error: pl.stderr || pl.stdout || `exit ${pl.code}`,
1331
+ });
1332
+ return;
1333
+ }
1334
+ const alivePanes = this.parsePaneList(journalHandle, runId, pl.stdout, "post-close");
1335
+ if (alivePanes === null)
1336
+ return;
1337
+ const alive = new Set(alivePanes.map((p) => p.tab_id));
1338
+ for (const tab of touched) {
1339
+ if (alive.has(tab))
1340
+ continue;
1341
+ const closed = await this.herdr(`tab close ${shq(tab)}`);
1342
+ if (closed.code !== 0) {
1343
+ this.journalReconcile(journalHandle, "tab-reconcile-close-failed", undefined, {
1344
+ tabId: tab,
1345
+ runId,
1346
+ sweeperRunId: runId,
1347
+ exitCode: closed.code,
1348
+ error: closed.stderr || closed.stdout || `exit ${closed.code}`,
1349
+ });
1350
+ }
1351
+ }
1225
1352
  }
1226
- catch {
1227
- /* cosmetic — visibility hygiene never fails the run */
1353
+ catch (error) {
1354
+ this.journalReconcile(journalHandle, "pane-reconcile-failed", undefined, {
1355
+ runId,
1356
+ sweeperRunId: runId,
1357
+ error: error instanceof Error ? error.message : String(error),
1358
+ });
1228
1359
  }
1229
1360
  }
1230
1361
  async worktree(repo, branch, baseRef) {
@@ -54,10 +54,12 @@ export interface FleetAgent {
54
54
  tabId?: string;
55
55
  workspaceId?: string;
56
56
  }
57
- export declare function panesToClose(agents: FleetAgent[], desired: Set<string>, ws: string, runId: string, opts?: {
57
+ export interface PanesToCloseOpts {
58
58
  spareLiveLlm?: boolean;
59
59
  endedRunIds?: Set<string>;
60
- }): {
60
+ liveSeats?: Set<string>;
61
+ }
62
+ export declare function panesToClose(agents: FleetAgent[], desired: Set<string>, ws: string, runId: string, opts?: PanesToCloseOpts): {
61
63
  paneId: string;
62
64
  tabId?: string;
63
65
  }[];
@@ -80,8 +82,5 @@ export interface ExecutorDriver {
80
82
  narrateWith?(narrate: (event: JournalEvent) => void): void;
81
83
  worktree(repo: string, branch: string, baseRef: string): Promise<string>;
82
84
  narrator?: (cwd: string, command: string, runId?: string) => Promise<Slot>;
83
- reconcile?: (desired: Set<string>, runId: string, opts?: {
84
- spareLiveLlm?: boolean;
85
- endedRunIds?: Set<string>;
86
- }) => Promise<void>;
85
+ reconcile?: (desired: Set<string>, runId: string, opts?: PanesToCloseOpts) => Promise<void>;
87
86
  }
@@ -20,54 +20,6 @@ export function parseOwnedName(name) {
20
20
  export function isForeignName(name) {
21
21
  return parseOwnedName(name) === null;
22
22
  }
23
- // v1.22b T1: workspace-aware fold over a fleet snapshot — decides which owned task panes are garbage
24
- // right now: the desired-set/spareLiveLlm sweep (OBS-17 T2), scoped to THIS RUN'S OWN panes (by runId,
25
- // OBS-772) in THIS RUN'S OWN WORKSPACE (OBS-769). Both conditions, and neither alone is the rule.
26
- // Watch panes are operator-owned after run end and are reclaimed by the next run; foreign names
27
- // (parseOwnedName fails) are never candidates.
28
- //
29
- // OBS-769 — WHY THE SWEEP STOPS AT THE WORKSPACE BOUNDARY. It used to close an owned pane carrying
30
- // any OTHER runId in any other workspace, unconditionally, as a "misplaced leftover". Two tickmarkr
31
- // runs in two repositories are lawful (the lock forbids two runs in ONE repository, not on one
32
- // machine) and herdr gives each its own workspace — so that branch made every pair of concurrent
33
- // runs kill each other's LIVE workers. Measured 2026-08-28: the run in w0 closed run ...2958's
34
- // panes at 23:42:40.351/.392, and 53s later ...2958's own task-human sweep closed w0's live codex
35
- // worker at 23:43:34.096. ...2958 ended 0/8. The death detector cannot see it: closing the pane
36
- // makes paneAbsent, processTree, confirmedProcessTree and worktreeDelta true by ONE cause, and a
37
- // closed pane can never accrue the CPU that the `cpu-accruing` hold reads.
38
- // The comment this replaces claimed "only run age marks a misplaced pane garbage" — there was no age
39
- // check in the code, and age is the wrong predicate anyway: w0's run STARTED EARLIER than ...2958,
40
- // so an age rule would have licensed exactly the kill that landed. Run age says nothing about
41
- // liveness, and a sweeping daemon cannot read another repository's run state. The workspace is the
42
- // only ownership boundary available without cross-repo I/O, so it is the one enforced.
43
- // Cost, named: an orphan pane from a dead run stranded in a workspace no later run opens is now left
44
- // for the operator. That is cosmetic (`reconcile` is cosmetic by contract — "visibility is never a
45
- // gate"), and a cosmetic cleanup must never be able to kill a live worker.
46
- // OBS-772 — WHY THE runId LINE EXISTS, AND WHY THE WORKSPACE LINE ALONE WAS NOT THE FIX. The first
47
- // repair was workspace-scoped only, and its own comment dismissed the residue — "two runs sharing one
48
- // workspace would still sweep each other" — as unreachable, on the reasoning that one workspace per run
49
- // is herdr's placement. That reasoned from ONE driver to the whole product. OrcaDriver has no workspace
50
- // dimension at all: orca.ts passes a single ORCA_SPACE as the workspaceId for EVERY checkout and as
51
- // `ws`, so `workspaceId !== ws` is never true there and every foreign pane fell straight through. Orca
52
- // users had zero protection while the defect read as fixed. The runId line is the real rule and it is
53
- // driver-agnostic: reconcile exists to clean up THIS RUN's panes, and a leftover from a dead run is
54
- // exactly what cannot be told from a live run's pane without liveness data this process does not have.
55
- // Both lines are kept — the workspace line preserves the pre-existing sparing of this run's own panes
56
- // in another workspace, which the runId line alone would not.
57
- // ⚠ WHAT THE runId LINE COST BEFORE OBS-777 — SUSPENDED, NOT NARROWED, and the price was larger than
58
- // it read. Sparing every other runId suspended OBS-17's FOUNDING use case: "a killed daemon can't
59
- // close its slots". This sweep was built to reclaim exactly those orphans, but could not reclaim ANY
60
- // previous run's panes. Three separate pins asserted the old behaviour (reconcile.test.ts,
61
- // orca-placement.test.ts,
62
- // reconcile-live.test.ts); all three were changed deliberately, and the third is why this paragraph
63
- // exists rather than a shorter one — two flipped pins is a trade, three is a pattern.
64
- // OBS-777 RESTORES that reclamation: the CALLER passes `opts.endedRunIds`, a Set the daemon computes
65
- // ONCE at run start from this repository's own `run-end` journals and dead lock holders. This fold
66
- // stays pure — it gains one optional field, not a repo root — a foreign repository's runId is never
67
- // resolvable and so stays spared by construction, and no driver learns about workspaces.
68
- // ponytail: two conditions, no geometry reasoning, nothing driver-specific. `reconcile` is cosmetic by
69
- // contract, and a cosmetic cleanup must never be able to kill a live worker — which is why the
70
- // ended-run authority is the only safe way to restore the sweep without reviving the cross-run kill.
71
23
  export function panesToClose(agents, desired, ws, runId, opts) {
72
24
  const out = [];
73
25
  for (const a of agents) {
@@ -76,6 +28,8 @@ export function panesToClose(agents, desired, ws, runId, opts) {
76
28
  const owned = parseOwnedName(a.name);
77
29
  if (!owned || owned.role === "watch")
78
30
  continue;
31
+ if (opts?.liveSeats?.has(a.name))
32
+ continue;
79
33
  if (owned.runId !== runId && !opts?.endedRunIds?.has(owned.runId))
80
34
  continue;
81
35
  if (a.workspaceId !== ws)
@@ -49,12 +49,16 @@ export interface RunSummary {
49
49
  outstandingApprovals?: string[];
50
50
  }
51
51
  /**
52
- * T14: approvals the run accepted and never acted on. `approved` above is built ONCE at startup —
53
- * deliberately, replay determinism depends on it — so an approval written while the daemon is live is
54
- * inert for that run. Without this the run-end record stated only buckets and tipVerify, both
55
- * accurate, over a milestone that was silently incomplete: run …230 ended tipVerify "passed" with two
56
- * upheld approvals and zero subsequent dispatches. Scored per task on its NEWEST approval: a later
57
- * approval is the live decision, and the events that answer it are the ones after it.
52
+ * T14, amended by v2.2 T3: approvals the run accepted and never acted on. `approved` above is still
53
+ * built ONCE at startup — replay determinism depends on it — but a live approval is no longer inert:
54
+ * the boundary sweep in the task loop releases what lands while the daemon runs, so an approval
55
+ * written mid-run is normally enacted by this run. ONE window survives, and it is the reason this
56
+ * fold still exists: an approval accepted after the task loop exits — during tip verify, before the
57
+ * run-end sample below — meets no further boundary, so nothing can release it before this run ends.
58
+ * Without this the run-end record stated only buckets and tipVerify, both accurate, over a milestone
59
+ * that was silently incomplete: run …230 ended tipVerify "passed" with two upheld approvals and zero
60
+ * subsequent dispatches. Scored per task on its NEWEST approval: a later approval is the live
61
+ * decision, and the events that answer it are the ones after it.
58
62
  */
59
63
  export declare function outstandingApprovals(events: JournalEvent[]): string[];
60
64
  export declare function formatSummary(s: RunSummary): string;