tickmarkr 1.71.0 → 1.73.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/adapters/codex.js +13 -3
- package/dist/cli/commands/approve.js +21 -3
- package/dist/cli/commands/fleet.d.ts +2 -0
- package/dist/cli/commands/fleet.js +212 -449
- package/dist/cli/commands/resume.js +10 -2
- package/dist/cli/commands/status.d.ts +2 -0
- package/dist/cli/commands/status.js +219 -30
- package/dist/cli/commands/ui.d.ts +6 -2
- package/dist/cli/commands/ui.js +2 -4
- package/dist/config/config.d.ts +4 -0
- package/dist/config/config.js +6 -1
- package/dist/drivers/herdr.d.ts +6 -0
- package/dist/drivers/herdr.js +169 -71
- package/dist/drivers/subprocess.d.ts +3 -3
- package/dist/drivers/subprocess.js +10 -5
- package/dist/gates/acceptance.js +41 -17
- package/dist/route/preference.d.ts +2 -1
- package/dist/route/preference.js +8 -2
- package/dist/run/daemon.d.ts +1 -0
- package/dist/run/daemon.js +139 -4
- package/dist/run/git.d.ts +2 -0
- package/dist/run/git.js +55 -0
- package/dist/run/journal.d.ts +6 -1
- package/dist/run/journal.js +28 -1
- package/dist/tui/ink/components.d.ts +38 -0
- package/dist/tui/ink/components.js +42 -0
- package/dist/tui/ink/fleet-app.d.ts +113 -0
- package/dist/tui/ink/fleet-app.js +678 -0
- package/dist/tui/ink/studio-app.d.ts +59 -0
- package/dist/tui/ink/studio-app.js +320 -0
- package/package.json +4 -1
- package/dist/tui/app.d.ts +0 -72
- package/dist/tui/app.js +0 -334
- package/dist/tui/engine.d.ts +0 -52
- package/dist/tui/engine.js +0 -92
- package/dist/tui/frame.d.ts +0 -22
- package/dist/tui/frame.js +0 -56
- package/dist/tui/input.d.ts +0 -41
- package/dist/tui/input.js +0 -100
- package/dist/tui/views/consult-dossier.d.ts +0 -49
- package/dist/tui/views/consult-dossier.js +0 -169
- package/dist/tui/views/diff-modal.d.ts +0 -9
- package/dist/tui/views/diff-modal.js +0 -19
- package/dist/tui/views/fleet-view.d.ts +0 -91
- package/dist/tui/views/fleet-view.js +0 -337
- package/dist/tui/views/preview-view.d.ts +0 -7
- package/dist/tui/views/preview-view.js +0 -80
- package/dist/tui/views/profile-view.d.ts +0 -15
- package/dist/tui/views/profile-view.js +0 -93
- package/dist/tui/views/routing-view.d.ts +0 -48
- package/dist/tui/views/routing-view.js +0 -234
- package/dist/tui/views/runs-view.d.ts +0 -70
- package/dist/tui/views/runs-view.js +0 -387
package/dist/drivers/herdr.js
CHANGED
|
@@ -25,8 +25,11 @@ export class HerdrDriver {
|
|
|
25
25
|
// grouped slot()/close() mutate shared group state across awaits — serialize them so two
|
|
26
26
|
// concurrent first members can never both create the group tab (mergeSerial idiom, daemon.ts)
|
|
27
27
|
groupSerial = Promise.resolve();
|
|
28
|
-
// OBS-
|
|
28
|
+
// OBS-120: canonical dispatch slots hold this chain from allocation through verified binding and
|
|
29
|
+
// delivery. Legacy/manual slots still serialize each delivery on the same chain (OBS-119).
|
|
29
30
|
deliverySerial = Promise.resolve();
|
|
31
|
+
dispatchLeases = new WeakMap();
|
|
32
|
+
deliveredPanes = new WeakMap();
|
|
30
33
|
// VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
|
|
31
34
|
// operator's env before the driver is built). Required at slot() time, never in the constructor —
|
|
32
35
|
// pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
|
|
@@ -47,6 +50,41 @@ export class HerdrDriver {
|
|
|
47
50
|
this.deliverySerial = p.catch(() => undefined);
|
|
48
51
|
return p;
|
|
49
52
|
}
|
|
53
|
+
// OBS-120: ExecutorDriver exposes slot() and run() separately, so a canonical tickmarkr slot
|
|
54
|
+
// carries a lease across that API seam. The next dispatch cannot allocate until this slot's first
|
|
55
|
+
// run either delivers or fails. Allocation errors release immediately; no failed dispatch poisons
|
|
56
|
+
// the queue. The daemon calls run() directly after preparing the command for every owned slot.
|
|
57
|
+
reserveDispatch(allocate) {
|
|
58
|
+
let release;
|
|
59
|
+
const held = new Promise((resolve) => { release = resolve; });
|
|
60
|
+
const start = this.deliverySerial.then(() => undefined, () => undefined);
|
|
61
|
+
this.deliverySerial = start.then(() => held);
|
|
62
|
+
return start.then(async () => {
|
|
63
|
+
let slot;
|
|
64
|
+
try {
|
|
65
|
+
slot = await allocate();
|
|
66
|
+
this.dispatchLeases.set(slot, { release });
|
|
67
|
+
return slot;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (slot) {
|
|
71
|
+
try {
|
|
72
|
+
await this.close(slot);
|
|
73
|
+
}
|
|
74
|
+
catch { /* allocation already failed closed; cleanup is best-effort */ }
|
|
75
|
+
}
|
|
76
|
+
release();
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async verifyPaneIdentityBinding(slot) {
|
|
82
|
+
const paneId = await this.namedPaneId(slot.name);
|
|
83
|
+
if (paneId !== slot.id) {
|
|
84
|
+
throw new Error(`herdr pane identity binding verification failed for ${slot.name}: allocated ${slot.id}, resolved ${paneId ?? "none"} — refusing delivery`);
|
|
85
|
+
}
|
|
86
|
+
return paneId;
|
|
87
|
+
}
|
|
50
88
|
// ponytail: narrow panes hard-wrap the input line — collapse whitespace before comparing.
|
|
51
89
|
deliveryMatches(transcript, cmd) {
|
|
52
90
|
const norm = (s) => s.replace(/\s+/g, "");
|
|
@@ -60,20 +98,30 @@ export class HerdrDriver {
|
|
|
60
98
|
herdr(args, cwd = process.cwd(), timeoutMs) {
|
|
61
99
|
return sh(`${shq(this.bin)} ${args}`, cwd, timeoutMs);
|
|
62
100
|
}
|
|
101
|
+
// herdr 0.7.5 (OBS-123): an "agent" is a DETECTED agent-CLI, so `agent get <name>` no longer resolves
|
|
102
|
+
// a tickmarkr-named bash pane. Durable identity is the pane LABEL (set via `pane rename`); resolve it
|
|
103
|
+
// by reading the label back from `pane list`.
|
|
63
104
|
async namedPaneId(name) {
|
|
64
|
-
const r = await this.herdr(`
|
|
105
|
+
const r = await this.herdr(`pane list`);
|
|
65
106
|
if (r.code !== 0)
|
|
66
107
|
return null;
|
|
67
108
|
try {
|
|
68
|
-
const
|
|
69
|
-
|
|
109
|
+
const panes = JSON.parse(r.stdout).result?.panes;
|
|
110
|
+
if (!Array.isArray(panes))
|
|
111
|
+
return null;
|
|
112
|
+
const hit = panes.find((p) => p.label === name && typeof p.pane_id === "string" && p.pane_id);
|
|
113
|
+
return hit?.pane_id ?? null;
|
|
70
114
|
}
|
|
71
115
|
catch {
|
|
72
116
|
return null;
|
|
73
117
|
}
|
|
74
118
|
}
|
|
75
|
-
//
|
|
119
|
+
// Before delivery, resolve fresh via the durable name because pane ids can compact. After delivery,
|
|
120
|
+
// pin the verified target so early liveness cannot drift to a label rebound onto another pane.
|
|
76
121
|
async paneId(slot) {
|
|
122
|
+
const delivered = this.deliveredPanes.get(slot);
|
|
123
|
+
if (delivered)
|
|
124
|
+
return delivered;
|
|
77
125
|
return await this.namedPaneId(slot.name) ?? slot.id;
|
|
78
126
|
}
|
|
79
127
|
// VIS-09 P43-03: runtime width for the layout gate (43-MEASUREMENT.md licensing condition 2).
|
|
@@ -98,12 +146,18 @@ export class HerdrDriver {
|
|
|
98
146
|
// reconcile.ts and this driver's own renameGroupTab/glyphFor decode role/taskId/attempt from
|
|
99
147
|
// those shapes without a call-site migration; T2 retires this branch by always passing `owned`.
|
|
100
148
|
const resolved = opts?.owned ? formatOwnedName(opts.owned) : name;
|
|
149
|
+
const allocate = opts?.group
|
|
150
|
+
? () => this.serial(() => this.groupSlot(cwd, resolved, opts.group))
|
|
151
|
+
: () => this.tabSlot(cwd, resolved, opts?.label);
|
|
152
|
+
// Production dispatch names are canonical even when the gate call site supplies the already-
|
|
153
|
+
// formatted name rather than SlotOpts.owned. Hold one lease across slot() → run(); legacy/manual
|
|
154
|
+
// slots retain their existing allocation-only semantics for compatibility.
|
|
155
|
+
if (parseOwnedName(resolved))
|
|
156
|
+
return this.reserveDispatch(allocate);
|
|
101
157
|
// group wins if both are set (a group tab is already stage-labeled; passing both is a caller bug).
|
|
102
158
|
// label (without group) → dedicated labeled tab via tabSlot's third param: no groups-map entry, no
|
|
103
159
|
// refcount, no groupSerial, no degrade latch — dedicated tabs have no shared state to guard (SUP-01).
|
|
104
|
-
|
|
105
|
-
return this.serial(() => this.groupSlot(cwd, resolved, opts.group));
|
|
106
|
-
return this.tabSlot(cwd, resolved, opts?.label); // label undefined → defaults to name (today's behavior)
|
|
160
|
+
return allocate(); // label undefined → defaults to name (today's behavior)
|
|
107
161
|
}
|
|
108
162
|
// today's per-slot tab path, plus the VIS-04 orphan reap
|
|
109
163
|
// label defaults to the slot name; group tabs pass the STAGE name instead — a first-member label
|
|
@@ -120,7 +174,11 @@ export class HerdrDriver {
|
|
|
120
174
|
throw new Error("herdr placement requires HERDR_WORKSPACE_ID — refusing untargeted pane (VIS-10: fail closed, never place by focus)");
|
|
121
175
|
// pin the tab to the RUN's workspace, UNCONDITIONALLY (inherited via HERDR_WORKSPACE_ID), never the
|
|
122
176
|
// operator's focused one (Intl-Dossier run-20260709-104447 incident: worker tabs opened in the tickmarkr repo workspace)
|
|
123
|
-
|
|
177
|
+
// herdr 0.7.5 (OBS-123): `tab create --cwd` spawns the tab's root shell pane already at a prompt in
|
|
178
|
+
// the worktree — that root pane IS the worker pane. The old one-shot `agent start … -- bash` verb
|
|
179
|
+
// (which named a fresh bash pane) was removed; 0.7.5's `agent start` only ATTACHES to a DETECTED
|
|
180
|
+
// agent CLI, so tickmarkr names the bash pane itself via `pane rename` and types the worker command in.
|
|
181
|
+
const t = await this.herdr(`tab create --label ${shq(label)} --no-focus --workspace ${shq(this.ws)} --cwd ${shq(cwd)}`);
|
|
124
182
|
if (t.code !== 0)
|
|
125
183
|
throw new Error(`herdr tab create failed (exit ${t.code}, refusing untargeted placement): ${t.stderr || t.stdout}`);
|
|
126
184
|
let res;
|
|
@@ -131,43 +189,27 @@ export class HerdrDriver {
|
|
|
131
189
|
throw new Error(`herdr tab create returned unparseable JSON (refusing untargeted placement): ${t.stdout}`);
|
|
132
190
|
}
|
|
133
191
|
const tabId = res?.tab?.tab_id;
|
|
134
|
-
const
|
|
192
|
+
const id = res?.root_pane?.pane_id; // the tab's root shell pane, already in cwd — this run's worker pane
|
|
135
193
|
if (typeof tabId !== "string" || !tabId)
|
|
136
194
|
throw new Error(`herdr tab create returned no tab_id (refusing untargeted placement): ${t.stdout}`);
|
|
195
|
+
if (typeof id !== "string" || !id)
|
|
196
|
+
throw new Error(`herdr tab create returned no root pane id (refusing untargeted placement): ${t.stdout}`);
|
|
137
197
|
// T5: the banner's pane identity line, derived from the T1 owned name (legacy names pass through).
|
|
138
198
|
const identity = shq(paneIdentityLine(canonicalizeLegacyName(name, "")));
|
|
139
|
-
|
|
140
|
-
if
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
await this.herdr(`pane close ${shq(stale)}`);
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
/* holder vanished between calls (agent_not_found) — retry the start regardless */
|
|
154
|
-
}
|
|
155
|
-
r = await this.herdr(`agent start ${shq(name)} --cwd ${shq(cwd)} --tab ${shq(tabId)} --no-focus -- bash`); // once
|
|
156
|
-
}
|
|
157
|
-
if (r.code !== 0)
|
|
158
|
-
throw new Error(`herdr agent start failed: ${r.stderr || r.stdout}`); // fail closed, no loop
|
|
159
|
-
const id = JSON.parse(r.stdout).result?.agent?.pane_id;
|
|
160
|
-
if (typeof id !== "string" || !id)
|
|
161
|
-
throw new Error(`herdr agent start returned no pane id: ${r.stdout}`);
|
|
162
|
-
// tab create auto-spawns a root shell pane and agent start --tab adds the agent as a SECOND
|
|
163
|
-
// pane — reap the idle shell so no tab shows a dead "tickmarkr git:" prompt beside its agent
|
|
164
|
-
// (VIS-04 orphan fix). Best-effort: a failed reap costs cosmetics only.
|
|
165
|
-
if (typeof rootPane === "string" && rootPane && rootPane !== id) {
|
|
166
|
-
await this.herdr(`pane close ${shq(rootPane)}`);
|
|
167
|
-
}
|
|
199
|
+
// durable identity: label the PANE (resolution + reconcile read it back via `pane list`); fail closed
|
|
200
|
+
// and reap the tab we just made if the rename fails (no orphan tab).
|
|
201
|
+
const rn = await this.herdr(`pane rename ${shq(id)} ${shq(name)}`);
|
|
202
|
+
if (rn.code !== 0) {
|
|
203
|
+
await this.herdr(`tab close ${shq(tabId)}`);
|
|
204
|
+
throw new Error(`herdr pane rename failed: ${rn.stderr || rn.stdout}`);
|
|
205
|
+
}
|
|
206
|
+
// DEFECT-01: a prior (killed) process's kept pane may still carry this durable label (resume +
|
|
207
|
+
// keepPanes re-dispatches at attempt=0 into the same name). 0.7.5 rename never collides, so instead
|
|
208
|
+
// of agent_name_taken we sweep stale same-label panes and verify this fresh pane is the sole holder.
|
|
209
|
+
await this.reclaimStaleLabel(name, id);
|
|
168
210
|
// VIS-10 hole 3: seed the run's workspace id into the agent pane's shell so a worker's own ad-hoc
|
|
169
|
-
// `herdr
|
|
170
|
-
//
|
|
211
|
+
// `herdr` call is workspace-targeted BY CONSTRUCTION — correct placement is the default a worker GETS,
|
|
212
|
+
// not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
|
|
171
213
|
// v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
|
|
172
214
|
// the operator's session. Daemon-side this.herdr() calls keep process.env (unsealed).
|
|
173
215
|
const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${identity}; ${herdrSealShellPrefix()}`)}`);
|
|
@@ -175,6 +217,32 @@ export class HerdrDriver {
|
|
|
175
217
|
throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
|
|
176
218
|
return { id, name, cwd, tabId };
|
|
177
219
|
}
|
|
220
|
+
// DEFECT-01 (0.7.5): close any OTHER pane still carrying this durable label (a prior killed attempt's
|
|
221
|
+
// kept pane), then verify none remain — else fail closed, since `pane list` would otherwise resolve the
|
|
222
|
+
// name to an ambiguous/stale pane. No-op on the common path (no dup labels). Best-effort closes; the
|
|
223
|
+
// post-sweep re-list is the fail-closed gate (a close that does not free the name must reject).
|
|
224
|
+
async reclaimStaleLabel(name, fresh) {
|
|
225
|
+
const stale = await this.staleLabelPanes(name, fresh);
|
|
226
|
+
if (stale.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
for (const p of stale)
|
|
229
|
+
await this.herdr(`pane close ${shq(p)}`);
|
|
230
|
+
if ((await this.staleLabelPanes(name, fresh)).length > 0) {
|
|
231
|
+
throw new Error(`herdr pane rename reclaim failed — durable name ${name} still held by a stale pane`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async staleLabelPanes(name, fresh) {
|
|
235
|
+
const r = await this.herdr(`pane list`);
|
|
236
|
+
try {
|
|
237
|
+
const panes = JSON.parse(r.stdout).result?.panes ?? [];
|
|
238
|
+
return panes
|
|
239
|
+
.filter((p) => p.label === name && typeof p.pane_id === "string" && p.pane_id !== fresh)
|
|
240
|
+
.map((p) => p.pane_id);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
178
246
|
// VIS-04 role-tab (extended VIS-09 item 2): first member bootstraps generation 1 (WORKERS);
|
|
179
247
|
// later members join the latest generation with live-member room via joinGroup, else a NEW
|
|
180
248
|
// overflow generation tab (cleanup, cleanup, …) opens — a second `tab create`, NOT a further
|
|
@@ -253,7 +321,7 @@ export class HerdrDriver {
|
|
|
253
321
|
// stack down. Rightward splits below the measured floor shred the marker (e8aa003 at COLUMNS≈2;
|
|
254
322
|
// unrecoverable at 25 cols per measurement). Introspection failure → down (fail closed).
|
|
255
323
|
const direction = entry.members.length === 1 ? workerSplitDirection(await this.paneWidth(srcPane)) : "down";
|
|
256
|
-
const sp = await this.herdr(`pane split ${shq(srcPane)} --direction ${direction} --no-focus`);
|
|
324
|
+
const sp = await this.herdr(`pane split ${shq(srcPane)} --direction ${direction} --no-focus --cwd ${shq(cwd)}`);
|
|
257
325
|
if (sp.code !== 0)
|
|
258
326
|
return null;
|
|
259
327
|
let pane;
|
|
@@ -265,13 +333,13 @@ export class HerdrDriver {
|
|
|
265
333
|
}
|
|
266
334
|
if (typeof pane !== "string" || !pane)
|
|
267
335
|
return null;
|
|
268
|
-
// the split pane is a bare shell: give it a durable
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
336
|
+
// the split pane is a bare shell: give it a durable PANE label (0.7.5 — agent rename only binds
|
|
337
|
+
// detected agents; SKILL:197 was the old agent-name path) and VERIFY the label resolves back to this
|
|
338
|
+
// pane (research A1 is checked live per join, never assumed). `pane split --cwd` already placed the
|
|
339
|
+
// shell in this member's worktree, so no separate `cd` is needed.
|
|
340
|
+
const rn = await this.herdr(`pane rename ${shq(pane)} ${shq(name)}`);
|
|
272
341
|
const verified = rn.code === 0 && (await this.paneId({ id: "", name, cwd })) === pane;
|
|
273
|
-
|
|
274
|
-
if (!cd || cd.code !== 0) {
|
|
342
|
+
if (!verified) {
|
|
275
343
|
await this.herdr(`pane close ${shq(pane)}`); // reap the failed join, best-effort
|
|
276
344
|
return null;
|
|
277
345
|
}
|
|
@@ -300,10 +368,26 @@ export class HerdrDriver {
|
|
|
300
368
|
// cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
|
|
301
369
|
// transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
|
|
302
370
|
async run(slot, cmd) {
|
|
303
|
-
|
|
371
|
+
const lease = this.dispatchLeases.get(slot);
|
|
372
|
+
if (lease) {
|
|
373
|
+
this.dispatchLeases.delete(slot);
|
|
374
|
+
try {
|
|
375
|
+
const paneId = await this.verifyPaneIdentityBinding(slot);
|
|
376
|
+
await this.deliver(slot, cmd, paneId);
|
|
377
|
+
this.deliveredPanes.set(slot, paneId);
|
|
378
|
+
}
|
|
379
|
+
finally {
|
|
380
|
+
lease.release();
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
return this.deliveryQueue(async () => {
|
|
385
|
+
const paneId = await this.paneId(slot);
|
|
386
|
+
await this.deliver(slot, cmd, paneId);
|
|
387
|
+
this.deliveredPanes.set(slot, paneId);
|
|
388
|
+
});
|
|
304
389
|
}
|
|
305
|
-
async deliver(slot, cmd) {
|
|
306
|
-
const pane = await this.paneId(slot);
|
|
390
|
+
async deliver(slot, cmd, pane) {
|
|
307
391
|
let transcript = "";
|
|
308
392
|
for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
|
|
309
393
|
if (attempt > 0) {
|
|
@@ -316,7 +400,7 @@ export class HerdrDriver {
|
|
|
316
400
|
const typed = await this.herdr(`pane send-text ${shq(pane)} ${shq(cmd)}`, slot.cwd);
|
|
317
401
|
if (typed.code !== 0)
|
|
318
402
|
throw new Error(`herdr pane send-text failed: ${typed.stderr || typed.stdout}`);
|
|
319
|
-
const back = await this.herdr(`wait
|
|
403
|
+
const back = await this.herdr(`pane wait-output ${shq(pane)} --match ${shq(cmd)} --timeout ${DELIVERY_VERIFY_TIMEOUT_MS}`, slot.cwd, DELIVERY_VERIFY_TIMEOUT_MS + 15_000);
|
|
320
404
|
if (this.waitOk(back.code, back.stdout) || await this.deliveryReadMatches(pane, cmd, slot.cwd)) {
|
|
321
405
|
const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
|
|
322
406
|
if (enter.code !== 0)
|
|
@@ -344,12 +428,19 @@ export class HerdrDriver {
|
|
|
344
428
|
}
|
|
345
429
|
async waitOutput(slot, pattern, timeoutMs, opts) {
|
|
346
430
|
const pane = await this.paneId(slot);
|
|
347
|
-
|
|
431
|
+
// 0.7.5: `--match` (literal) and `--regex` (pattern) are MUTUALLY EXCLUSIVE — the old
|
|
432
|
+
// `--match <p> --regex` form is rejected ("mutually exclusive"), which made the exit-marker wait
|
|
433
|
+
// error instantly instead of waiting, so LLM-gate/consult verdicts were read before they rendered
|
|
434
|
+
// and came back unparseable. Pick exactly one flag by whether the caller wants regex.
|
|
435
|
+
const r = await this.herdr(`pane wait-output ${shq(pane)} ${opts?.regex ? "--regex" : "--match"} ${shq(pattern)} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
|
|
348
436
|
return this.waitOk(r.code, r.stdout); // dead pane: exit 0 + top-level error envelope (herdr bite)
|
|
349
437
|
}
|
|
350
438
|
async waitAgentStatus(slot, status, timeoutMs) {
|
|
351
439
|
const pane = await this.paneId(slot);
|
|
352
|
-
|
|
440
|
+
// 0.7.5: `wait agent-status` (top-level) was removed; `agent wait <target> --until <status>` replaces
|
|
441
|
+
// it. Target is the pane id; when herdr has detected the worker's agent CLI there this resolves, else
|
|
442
|
+
// it returns an error envelope → waitOk false (a best-effort settle, same as the old timeout path).
|
|
443
|
+
const r = await this.herdr(`agent wait ${shq(pane)} --until ${shq(status)} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
|
|
353
444
|
return this.waitOk(r.code, r.stdout);
|
|
354
445
|
}
|
|
355
446
|
async status(slot) {
|
|
@@ -358,9 +449,13 @@ export class HerdrDriver {
|
|
|
358
449
|
// shared by status() and the VIS-13 blocked glyph (renameGroupTab): resolve the live agent_status
|
|
359
450
|
// by durable name; "unknown" on any failure (dead pane, unparseable json) — never throws.
|
|
360
451
|
async statusByName(name) {
|
|
361
|
-
|
|
452
|
+
// 0.7.5: read the pane's herdr-detected agent_status off `pane list` (keyed by the durable label);
|
|
453
|
+
// "unknown" on any failure (no such label, unparseable json) — never throws.
|
|
454
|
+
const r = await this.herdr(`pane list`);
|
|
362
455
|
try {
|
|
363
|
-
const
|
|
456
|
+
const panes = JSON.parse(r.stdout).result?.panes;
|
|
457
|
+
const hit = Array.isArray(panes) ? panes.find((p) => p.label === name) : undefined;
|
|
458
|
+
const s = hit?.agent_status;
|
|
364
459
|
return typeof s === "string" ? s : "unknown";
|
|
365
460
|
}
|
|
366
461
|
catch {
|
|
@@ -428,21 +523,21 @@ export class HerdrDriver {
|
|
|
428
523
|
async priorWatch(runId) {
|
|
429
524
|
if (!this.ws)
|
|
430
525
|
throw new Error("herdr watch placement requires HERDR_WORKSPACE_ID — refusing unseeded pane");
|
|
431
|
-
const list = await this.herdr("
|
|
526
|
+
const list = await this.herdr("pane list");
|
|
432
527
|
if (list.code !== 0)
|
|
433
|
-
throw new Error(`herdr
|
|
434
|
-
let
|
|
528
|
+
throw new Error(`herdr pane list failed: ${list.stderr || list.stdout}`);
|
|
529
|
+
let panes;
|
|
435
530
|
try {
|
|
436
|
-
|
|
531
|
+
panes = JSON.parse(list.stdout).result?.panes;
|
|
437
532
|
}
|
|
438
533
|
catch {
|
|
439
|
-
throw new Error(`herdr
|
|
534
|
+
throw new Error(`herdr pane list returned unparseable JSON: ${list.stdout}`);
|
|
440
535
|
}
|
|
441
|
-
if (!Array.isArray(
|
|
442
|
-
throw new Error(`herdr
|
|
443
|
-
const prior =
|
|
444
|
-
const owned = typeof
|
|
445
|
-
return
|
|
536
|
+
if (!Array.isArray(panes))
|
|
537
|
+
throw new Error(`herdr pane list returned no panes: ${list.stdout}`);
|
|
538
|
+
const prior = panes.find((p) => {
|
|
539
|
+
const owned = typeof p.label === "string" ? parseOwnedName(p.label) : null;
|
|
540
|
+
return p.workspace_id === this.ws && typeof p.pane_id === "string" && owned?.role === "watch" && owned.taskId === "run" && owned.runId !== runId;
|
|
446
541
|
});
|
|
447
542
|
return prior?.pane_id ?? null;
|
|
448
543
|
}
|
|
@@ -465,7 +560,7 @@ export class HerdrDriver {
|
|
|
465
560
|
}
|
|
466
561
|
if (typeof pane !== "string" || !pane)
|
|
467
562
|
throw new Error(`herdr watch split returned no pane id: ${sp.stdout}`);
|
|
468
|
-
const renamed = await this.herdr(`
|
|
563
|
+
const renamed = await this.herdr(`pane rename ${shq(pane)} ${shq(name)}`);
|
|
469
564
|
if (renamed.code !== 0 || await this.namedPaneId(name) !== pane) {
|
|
470
565
|
await this.herdr(`pane close ${shq(pane)}`);
|
|
471
566
|
throw new Error(`herdr watch rename failed: ${renamed.stderr || renamed.stdout}`);
|
|
@@ -495,7 +590,7 @@ export class HerdrDriver {
|
|
|
495
590
|
}
|
|
496
591
|
const prior = runId ? await this.priorWatch(runId) : null;
|
|
497
592
|
if (prior) {
|
|
498
|
-
const renamed = await this.herdr(`
|
|
593
|
+
const renamed = await this.herdr(`pane rename ${shq(prior)} ${shq(name)}`);
|
|
499
594
|
if (renamed.code !== 0 || await this.namedPaneId(name) !== prior) {
|
|
500
595
|
throw new Error(`herdr watch reclaim failed: ${renamed.stderr || renamed.stdout}`);
|
|
501
596
|
}
|
|
@@ -528,9 +623,12 @@ export class HerdrDriver {
|
|
|
528
623
|
try {
|
|
529
624
|
if (!this.ws)
|
|
530
625
|
return;
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
626
|
+
// 0.7.5: enumerate owned panes from `pane list` (labels), not `agent list` (detected agents only).
|
|
627
|
+
// panesToClose skips any pane whose label doesn't parse as tickmarkr-owned (orchestrator/operator
|
|
628
|
+
// shells, undetected agents), so a fuller pane listing never widens the blast radius.
|
|
629
|
+
const list = await this.herdr("pane list");
|
|
630
|
+
const panes = JSON.parse(list.stdout).result?.panes ?? [];
|
|
631
|
+
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);
|
|
534
632
|
const touched = new Set();
|
|
535
633
|
for (const c of toClose) {
|
|
536
634
|
if (typeof c.tabId === "string")
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ExecutorDriver, NotifyOpts, Slot } from "./types.js";
|
|
2
2
|
export declare const MAX_BUF: number;
|
|
3
3
|
export declare const HERDR_CONTROL_VARS: readonly ["HERDR_ENV", "HERDR_SOCKET_PATH"];
|
|
4
|
-
/** Copy of env with herdr control-plane vars stripped.
|
|
4
|
+
/** Copy of worker env with the fork cap applied and herdr control-plane vars stripped. */
|
|
5
5
|
export declare function sealHerdrEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
6
|
-
/**
|
|
7
|
-
export declare function herdrSealShellPrefix(): string;
|
|
6
|
+
/** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
|
|
7
|
+
export declare function herdrSealShellPrefix(env?: NodeJS.ProcessEnv): string;
|
|
8
8
|
export declare class SubprocessDriver implements ExecutorDriver {
|
|
9
9
|
id: string;
|
|
10
10
|
interactive: boolean;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { shq } from "../adapters/types.js";
|
|
3
|
+
import { createWorktree, DEFAULT_FORK_CAP, FORK_CAP_ENV } from "../run/git.js";
|
|
3
4
|
// HARD-03: cap retained worker output so a chatty worker can't grow tickmarkr's heap unbounded.
|
|
4
5
|
// 2MB is safe against BOTH consumers: the largest read() call site asks for 1000 lines
|
|
5
6
|
// (daemon.ts:217/247), and every marker (TICKMARKR_RESULT, TICKMARKR_EXIT_<nonce>) is emitted at the
|
|
@@ -12,16 +13,20 @@ export const MAX_BUF = 2 * 1024 * 1024;
|
|
|
12
13
|
// (and its own herdr driver CLI calls) keep the live session. Socket path is the wire; HERDR_ENV
|
|
13
14
|
// is the "I am inside herdr" gate every agent skill checks before mutating panes.
|
|
14
15
|
export const HERDR_CONTROL_VARS = ["HERDR_ENV", "HERDR_SOCKET_PATH"];
|
|
15
|
-
/** Copy of env with herdr control-plane vars stripped.
|
|
16
|
+
/** Copy of worker env with the fork cap applied and herdr control-plane vars stripped. */
|
|
16
17
|
export function sealHerdrEnv(env = process.env) {
|
|
17
18
|
const out = { ...env };
|
|
19
|
+
if (!(FORK_CAP_ENV in out))
|
|
20
|
+
out[FORK_CAP_ENV] = DEFAULT_FORK_CAP;
|
|
18
21
|
for (const k of HERDR_CONTROL_VARS)
|
|
19
22
|
delete out[k];
|
|
20
23
|
return out;
|
|
21
24
|
}
|
|
22
|
-
/**
|
|
23
|
-
export function herdrSealShellPrefix() {
|
|
24
|
-
|
|
25
|
+
/** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
|
|
26
|
+
export function herdrSealShellPrefix(env = process.env) {
|
|
27
|
+
const forkCap = sealHerdrEnv(env)[FORK_CAP_ENV] ?? DEFAULT_FORK_CAP;
|
|
28
|
+
return `export ${FORK_CAP_ENV}=${shq(forkCap)}; ` +
|
|
29
|
+
HERDR_CONTROL_VARS.map((k) => `unset ${k}`).join("; ") + "; ";
|
|
25
30
|
}
|
|
26
31
|
export class SubprocessDriver {
|
|
27
32
|
id = "subprocess";
|
package/dist/gates/acceptance.js
CHANGED
|
@@ -104,10 +104,15 @@ function tail(out, n = 8) {
|
|
|
104
104
|
return "";
|
|
105
105
|
return "\n" + t.split("\n").slice(-n).join("\n");
|
|
106
106
|
}
|
|
107
|
-
// v1.70: the new-file line numbers each
|
|
108
|
-
// unified-diff
|
|
109
|
-
//
|
|
110
|
-
//
|
|
107
|
+
// v1.70 / OBS-129: the new-file line numbers each changed HUNK spans, per path — parsed from the
|
|
108
|
+
// unified-diff so a citation is validated against real changed regions, not a substring of the whole
|
|
109
|
+
// diff text (which also matches the diff's own +++/@@ headers). A hunk's span is every new-file line it
|
|
110
|
+
// carries: the added (`+`) lines AND the context lines git includes around them.
|
|
111
|
+
// OBS-129: this is a changed-HUNK check, not an exact-added-LINE check. Requiring the exact `+` line made
|
|
112
|
+
// honest judges fail closed on correct work — two frontier judges cited an adjacent line of the same
|
|
113
|
+
// change (LLMs miscount exact line numbers), voiding the whole verdict. The anti-hallucination property
|
|
114
|
+
// holds: a citation outside every hunk, or to an untouched file, still fails (matching the documented
|
|
115
|
+
// "outside every changed hunk → rejected" contract in acceptance.test.ts).
|
|
111
116
|
// ponytail: assumes git's default a/ b/ prefixes (fetchTaskDiff uses plain `git diff`); revisit if a
|
|
112
117
|
// caller passes a --no-prefix diff.
|
|
113
118
|
function changedLinesByFile(diff) {
|
|
@@ -137,7 +142,9 @@ function changedLinesByFile(diff) {
|
|
|
137
142
|
if (!inHunk || path === null)
|
|
138
143
|
continue;
|
|
139
144
|
const c = raw[0];
|
|
140
|
-
if (c === "+") {
|
|
145
|
+
if (c === "+" || c === " ") {
|
|
146
|
+
// OBS-129: record every new-file line the hunk spans — added AND context — so a citation to an
|
|
147
|
+
// adjacent line of the same change validates, not only the exact `+` line.
|
|
141
148
|
let set = byFile.get(path);
|
|
142
149
|
if (!set) {
|
|
143
150
|
set = new Set();
|
|
@@ -146,17 +153,28 @@ function changedLinesByFile(diff) {
|
|
|
146
153
|
set.add(newLine);
|
|
147
154
|
newLine++;
|
|
148
155
|
}
|
|
149
|
-
else if (c === " ") {
|
|
150
|
-
newLine++;
|
|
151
|
-
}
|
|
152
156
|
else if (c !== "-") {
|
|
153
157
|
inHunk = false; // "\ No newline", a trailing blank, or the next section — hunk body ended
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
return byFile;
|
|
157
161
|
}
|
|
158
|
-
//
|
|
159
|
-
|
|
162
|
+
// Compact a sorted set of line numbers into "3, 7-9, 14" form for the judge's citable-lines index.
|
|
163
|
+
function compressLines(lines) {
|
|
164
|
+
const sorted = [...new Set(lines)].sort((a, b) => a - b);
|
|
165
|
+
const parts = [];
|
|
166
|
+
for (let i = 0; i < sorted.length;) {
|
|
167
|
+
let j = i;
|
|
168
|
+
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
|
|
169
|
+
j++;
|
|
170
|
+
parts.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`);
|
|
171
|
+
i = j + 1;
|
|
172
|
+
}
|
|
173
|
+
return parts.join(", ");
|
|
174
|
+
}
|
|
175
|
+
// A citation is valid evidence iff the cited line falls inside a changed hunk of the cited file (OBS-129:
|
|
176
|
+
// changed-hunk span, not exact-added-line). A legacy free-text quote (string) keeps v1.64's substring
|
|
177
|
+
// check — non-empty and present somewhere in the diff.
|
|
160
178
|
function citesChangedLocation(evidence, changed, diff) {
|
|
161
179
|
if (typeof evidence === "string")
|
|
162
180
|
return evidence.trim().length > 0 && diff.includes(evidence);
|
|
@@ -213,6 +231,10 @@ export async function acceptanceGate(task, worktree, baseRef, judge, via, opts =
|
|
|
213
231
|
if (capFail)
|
|
214
232
|
return capFail;
|
|
215
233
|
const expectedIds = judgeItems.map((_, index) => judgeCriterionId(index));
|
|
234
|
+
// OBS-129: give the judge the exact citable new-file line numbers per changed file, so it grounds each
|
|
235
|
+
// citation in a real changed line instead of miscounting. Validated below against this same `changed`.
|
|
236
|
+
const changed = changedLinesByFile(diff);
|
|
237
|
+
const citable = [...changed.entries()].map(([p, set]) => `- ${p}: ${compressLines([...set])}`).join("\n");
|
|
216
238
|
const nonce = generateVerdictNonce();
|
|
217
239
|
const prompt = `TICKMARKR-JUDGE
|
|
218
240
|
You are a strict acceptance judge. Decide whether the diff satisfies EVERY acceptance criterion.
|
|
@@ -232,12 +254,15 @@ ${judgeItems.map((a, index) => `- ${renderJudgeCriterion(a, index)}`).join("\n")
|
|
|
232
254
|
${diff}
|
|
233
255
|
\`\`\`
|
|
234
256
|
|
|
257
|
+
## Citable evidence lines (new-file line numbers inside the changed hunks above)
|
|
258
|
+
${citable || "(the diff changes no lines)"}
|
|
259
|
+
|
|
235
260
|
${verdictNonceLine(nonce)}
|
|
236
261
|
|
|
237
262
|
Respond with ONLY this JSON (no prose before or after):
|
|
238
263
|
{"nonce": "${nonce}", "pass": true|false, "criteria": [{"criterion": "c1", "met": true|false, "reason": "...", "evidence": {"path": "path/to/file", "line": 42}}]}
|
|
239
264
|
Each criteria[].criterion MUST be the stable id from the rubric (c1, c2, ...) exactly once.
|
|
240
|
-
Each criteria[].evidence MUST be a structured citation {"path", "line"}
|
|
265
|
+
Each criteria[].evidence MUST be a structured citation {"path", "line"} whose "path" and "line" appear in the "Citable evidence lines" list above (a new-file line number inside a changed hunk); a citation outside every changed hunk or to an untouched file voids the whole verdict.
|
|
241
266
|
`;
|
|
242
267
|
const raw = await runLlm(judge.adapter, judge.model, prompt, worktree, via, JUDGE_TIMEOUT_MS);
|
|
243
268
|
const extracted = extractVerdictJson(raw, nonce);
|
|
@@ -250,12 +275,11 @@ Each criteria[].evidence MUST be a structured citation {"path", "line"} pointing
|
|
|
250
275
|
meta: { unparseable: true, judge: channelKey({ adapter: judge.adapter.id, model: judge.model }) } };
|
|
251
276
|
}
|
|
252
277
|
const { verdict: v, inconsistencies } = checkJudgeVerdict(extracted, expectedIds);
|
|
253
|
-
// v1.70: each citation must
|
|
254
|
-
//
|
|
255
|
-
// A citation to an untouched file or
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
const changed = changedLinesByFile(diff);
|
|
278
|
+
// v1.70 / OBS-129: each citation must fall inside a changed hunk of `diff` (the exact string embedded in
|
|
279
|
+
// the prompt above and enumerated in the citable-lines index), never the worktree or any other artifact.
|
|
280
|
+
// A citation to an untouched file or outside every changed hunk is a hallucinated verdict: treated as
|
|
281
|
+
// unparseable so GATE-09 retries the judge on a failover channel. (A legacy free-text quote still
|
|
282
|
+
// validates by substring, for the fake seam and pre-v1.70 fixtures.) `changed` is computed above.
|
|
259
283
|
const fabricated = v.criteria.filter((row) => !citesChangedLocation(row.evidence, changed, diff));
|
|
260
284
|
if (fabricated.length) {
|
|
261
285
|
return { gate: "acceptance", pass: false,
|
|
@@ -4,6 +4,7 @@ export interface Disallowed {
|
|
|
4
4
|
by: "deny" | "allow";
|
|
5
5
|
entry: string;
|
|
6
6
|
}
|
|
7
|
+
export type PreferenceRole = "worker" | "judge" | "review" | "consult";
|
|
7
8
|
export declare function excludedChannels(cfg: TickmarkrConfig, adapters: {
|
|
8
9
|
id: string;
|
|
9
10
|
}[] | string[], health: Record<string, AuthHealth>): {
|
|
@@ -24,7 +25,7 @@ export declare function preferRanks(c: {
|
|
|
24
25
|
export declare function disallowedBy(c: {
|
|
25
26
|
adapter: string;
|
|
26
27
|
model: string;
|
|
27
|
-
}, routing: TickmarkrConfig["routing"]): Disallowed | null;
|
|
28
|
+
}, routing: TickmarkrConfig["routing"], role?: PreferenceRole): Disallowed | null;
|
|
28
29
|
export interface DenyPreferCollision {
|
|
29
30
|
kind: "prefer" | "pin";
|
|
30
31
|
shape: string;
|
package/dist/route/preference.js
CHANGED
|
@@ -36,10 +36,16 @@ export function preferRanks(c, cfg) {
|
|
|
36
36
|
return out;
|
|
37
37
|
}
|
|
38
38
|
// entries in either list accept adapter id, model id, or adapter:model (grammar A1)
|
|
39
|
-
export function disallowedBy(c, routing) {
|
|
39
|
+
export function disallowedBy(c, routing, role = "worker") {
|
|
40
40
|
const { allow, deny } = routing;
|
|
41
41
|
const matches = (e) => e === c.adapter || e === c.model || e === channelKey(c);
|
|
42
|
-
const
|
|
42
|
+
const workerDeny = role === "worker" ? deny?.workers : undefined;
|
|
43
|
+
const d = [
|
|
44
|
+
...(deny?.adapters ?? []),
|
|
45
|
+
...(deny?.models ?? []),
|
|
46
|
+
...(workerDeny?.adapters ?? []),
|
|
47
|
+
...(workerDeny?.models ?? []),
|
|
48
|
+
].find(matches);
|
|
43
49
|
if (d)
|
|
44
50
|
return { by: "deny", entry: d };
|
|
45
51
|
if (allow) {
|