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.
- package/dist/cli/commands/approve.d.ts +24 -0
- package/dist/cli/commands/approve.js +121 -34
- package/dist/compile/collateral.js +149 -4
- package/dist/compile/native.js +16 -0
- package/dist/drivers/herdr.d.ts +7 -5
- package/dist/drivers/herdr.js +142 -11
- package/dist/drivers/types.d.ts +5 -6
- package/dist/drivers/types.js +2 -48
- package/dist/run/daemon.d.ts +10 -6
- package/dist/run/daemon.js +275 -22
- package/dist/run/journal.d.ts +16 -0
- package/dist/run/journal.js +51 -6
- package/dist/tui/cockpit/setup-cockpit.d.ts +8 -10
- package/dist/tui/cockpit/setup-cockpit.js +92 -53
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +1 -1
- package/skills/tickmarkr-loop/SKILL.md +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +202 -24
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +109 -4
- package/skills/tickmarkr-overseer/scripts/watch-journal.sh +137 -0
package/dist/drivers/herdr.js
CHANGED
|
@@ -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
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/drivers/types.d.ts
CHANGED
|
@@ -54,10 +54,12 @@ export interface FleetAgent {
|
|
|
54
54
|
tabId?: string;
|
|
55
55
|
workspaceId?: string;
|
|
56
56
|
}
|
|
57
|
-
export
|
|
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
|
}
|
package/dist/drivers/types.js
CHANGED
|
@@ -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)
|
package/dist/run/daemon.d.ts
CHANGED
|
@@ -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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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;
|