tickmarkr 1.84.0 → 1.85.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/claude-code.d.ts +1 -0
- package/dist/adapters/claude-code.js +57 -1
- package/dist/adapters/fake.js +9 -0
- package/dist/adapters/types.d.ts +3 -0
- package/dist/adapters/types.js +21 -0
- package/dist/cli/commands/status.js +160 -30
- package/dist/compile/collateral.d.ts +86 -2
- package/dist/compile/collateral.js +294 -3
- package/dist/config/config.d.ts +62 -0
- package/dist/config/config.js +157 -2
- package/dist/drivers/herdr.d.ts +20 -3
- package/dist/drivers/herdr.js +288 -105
- package/dist/gates/baseline.d.ts +1 -0
- package/dist/gates/baseline.js +91 -13
- package/dist/gates/review.d.ts +7 -0
- package/dist/gates/review.js +99 -6
- package/dist/gates/run-gates.d.ts +9 -0
- package/dist/gates/run-gates.js +285 -41
- package/dist/run/daemon.d.ts +48 -2
- package/dist/run/daemon.js +1417 -315
- package/dist/run/journal.d.ts +56 -3
- package/dist/run/journal.js +275 -1
- package/dist/run/stall.d.ts +35 -1
- package/dist/run/stall.js +118 -8
- package/dist/tui/cockpit/components.d.ts +2 -0
- package/dist/tui/cockpit/derive.d.ts +29 -2
- package/dist/tui/cockpit/derive.js +219 -23
- package/dist/tui/cockpit/run-cockpit.js +128 -27
- package/package.json +1 -1
package/dist/drivers/herdr.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { declaredInputBoxForWorkerName, matchesEmptyInputBox, matchesInputBox, matchesOccupiedInputBox, missingInputStateDeclarations, shq } from "../adapters/types.js";
|
|
2
6
|
import { PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
|
|
3
7
|
import { createWorktree, sh } from "../run/git.js";
|
|
8
|
+
import { Journal } from "../run/journal.js";
|
|
4
9
|
import { herdrSealShellPrefix } from "./subprocess.js";
|
|
5
10
|
import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
|
|
6
11
|
// VIS-09 P43-03: adopted safety floor from 43-MEASUREMENT.md (narrowest safe 53 → floor 108).
|
|
@@ -14,6 +19,15 @@ const DELIVERY_READ_LINES = 80;
|
|
|
14
19
|
const DELIVERY_SETTLE_READ_ATTEMPTS = 6;
|
|
15
20
|
const DELIVERY_SETTLE_POLL_MS = 100;
|
|
16
21
|
const DELIVERY_READINESS_TIMEOUT_MS = 1_000;
|
|
22
|
+
// OBS-140/253: a shell or bootstrap dispatch is acknowledged by a START nonce the delivered line
|
|
23
|
+
// PRINTS as its first statement. The printf splits the nonce across two arguments, so the joined
|
|
24
|
+
// marker exists only after the shell actually ran the line — a pane that merely echoed the text it
|
|
25
|
+
// was given never produces it. The marker is written to a private file as well as the pane, because
|
|
26
|
+
// a pane snapshot is not a channel: a full-screen TUI or a noisy launch can scroll a printed nonce
|
|
27
|
+
// out of any bounded read, and a successful dispatch read as corrupt is dispatched TWICE.
|
|
28
|
+
export const DISPATCH_START_PREFIX = "TICKMARKR_START_";
|
|
29
|
+
const DISPATCH_ACK_TIMEOUT_MS = 15_000;
|
|
30
|
+
const DISPATCH_ACK_POLL_MS = 100;
|
|
17
31
|
const SYSTEM_TIME = {
|
|
18
32
|
now: () => Date.now(),
|
|
19
33
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
@@ -32,6 +46,21 @@ export class DeliveryReadinessError extends Error {
|
|
|
32
46
|
this.name = "DeliveryReadinessError";
|
|
33
47
|
}
|
|
34
48
|
}
|
|
49
|
+
// OBS-253: the dispatch-corruption class, typed so the driver's own one-shot recovery can recognise
|
|
50
|
+
// it and the daemon can keep classifying an unrecovered one as `kind: dispatch`. A dispatch that
|
|
51
|
+
// never registered produced no output to trust, so a fresh-pane retry risks nothing a first dispatch
|
|
52
|
+
// does not already risk — the second consecutive one is what parks the task.
|
|
53
|
+
export class DeliveryCorruptedError extends Error {
|
|
54
|
+
pane;
|
|
55
|
+
transcript;
|
|
56
|
+
phase = "DISPATCH";
|
|
57
|
+
constructor(pane, reason, transcript) {
|
|
58
|
+
super(`herdr delivery corrupted on pane ${pane} — ${reason} (OBS-140/253); pane transcript:\n${transcript}`);
|
|
59
|
+
this.pane = pane;
|
|
60
|
+
this.transcript = transcript;
|
|
61
|
+
this.name = "DeliveryCorruptedError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
35
64
|
/** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
|
|
36
65
|
export function workerSplitDirection(paneCols, safeFloor = TRAILER_SAFE_FLOOR_COLS, margin = TRAILER_WIDTH_MARGIN) {
|
|
37
66
|
if (paneCols == null || paneCols <= 0)
|
|
@@ -42,6 +71,7 @@ export class HerdrDriver {
|
|
|
42
71
|
bin;
|
|
43
72
|
workersPerTab;
|
|
44
73
|
time;
|
|
74
|
+
journal;
|
|
45
75
|
id = "herdr";
|
|
46
76
|
interactive = true;
|
|
47
77
|
groups = new Map();
|
|
@@ -54,16 +84,44 @@ export class HerdrDriver {
|
|
|
54
84
|
dispatchLeases = new WeakMap();
|
|
55
85
|
deliveredPanes = new WeakMap();
|
|
56
86
|
inputBoxes = new WeakMap();
|
|
87
|
+
// OBS-253: the adapter-declared bootstrap this slot has already delivered. A fresh pane is a bare
|
|
88
|
+
// shell, so it is the only thing that can put a TUI back under a typed turn that has to move.
|
|
89
|
+
bootstraps = new WeakMap();
|
|
90
|
+
// runDaemon gives the driver the authoritative repo root at its worktree seam before it allocates
|
|
91
|
+
// that task's slot. Keep the binding by cwd so a dispatch retry opens THAT run's Journal even when
|
|
92
|
+
// the caller launched tickmarkr elsewhere (process.cwd is not run identity). The repo itself is
|
|
93
|
+
// also bound for judge/review/consult slots whose cwd is the root rather than a task worktree.
|
|
94
|
+
journalRoots = new Map();
|
|
57
95
|
// VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
|
|
58
96
|
// operator's env before the driver is built). Required at slot() time, never in the constructor —
|
|
59
97
|
// pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
|
|
60
98
|
ws = process.env.HERDR_WORKSPACE_ID;
|
|
61
99
|
callerPane = process.env.HERDR_PANE_ID;
|
|
62
100
|
watches = new Map();
|
|
63
|
-
constructor(bin = "herdr", workersPerTab = 3, time = SYSTEM_TIME) {
|
|
101
|
+
constructor(bin = "herdr", workersPerTab = 3, time = SYSTEM_TIME, journal) {
|
|
64
102
|
this.bin = bin;
|
|
65
103
|
this.workersPerTab = workersPerTab;
|
|
66
104
|
this.time = time;
|
|
105
|
+
this.journal = journal;
|
|
106
|
+
}
|
|
107
|
+
// The dispatch-retry record is mandatory: it is the only durable fact left by a recovered pane
|
|
108
|
+
// swap. Tests may inject a sink, while production resolves the daemon's real Journal from the
|
|
109
|
+
// worktree binding plus the canonical slot name. Every resolution/open/append failure propagates;
|
|
110
|
+
// an unjournaled recovery is never performed and its audit failure is never hidden by the original
|
|
111
|
+
// corruption error.
|
|
112
|
+
appendDispatchRetry(slot, data) {
|
|
113
|
+
if (this.journal) {
|
|
114
|
+
this.journal("dispatch-retry", slot.name, data);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const owned = parseOwnedName(slot.name);
|
|
118
|
+
if (!owned)
|
|
119
|
+
throw new Error(`cannot journal dispatch-retry: slot ${slot.name} carries no run identity`);
|
|
120
|
+
const repoRoot = this.journalRoots.get(slot.cwd);
|
|
121
|
+
if (!repoRoot) {
|
|
122
|
+
throw new Error(`cannot journal dispatch-retry: slot ${slot.name} has no daemon repo binding for ${slot.cwd}`);
|
|
123
|
+
}
|
|
124
|
+
Journal.open(repoRoot, owned.runId).append("dispatch-retry", owned.taskId, data);
|
|
67
125
|
}
|
|
68
126
|
serial(fn) {
|
|
69
127
|
const p = this.groupSerial.then(fn, fn);
|
|
@@ -124,62 +182,18 @@ export class HerdrDriver {
|
|
|
124
182
|
const needle = norm(cmd);
|
|
125
183
|
return needle.length > 0 && hay.includes(needle);
|
|
126
184
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (/[│┃╭╰┌└]/.test(prefix))
|
|
138
|
-
return false;
|
|
139
|
-
return /^(?:[$%#>]|[➜❯❱›»λ])(?:\s|$)/.test(prefix)
|
|
140
|
-
|| /[$%#>✗❯❱›»λ]$/.test(prefix);
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
// Submission requires positive post-Enter evidence. A prompt echoed above a fresh input target
|
|
144
|
-
// proves registration; a declared box that is visibly empty after the verified paste also proves
|
|
145
|
-
// it consumed the prompt. At the launch stage, a structurally prompt-prefixed shell execution echo
|
|
146
|
-
// is stronger evidence than waiting for first paint from the launched interface (OBS-144).
|
|
147
|
-
// Prompt absence alone is never success (OBS-142).
|
|
148
|
-
submissionRegistered(transcript, cmd, inputBox) {
|
|
149
|
-
const norm = (s) => s.replace(/\s+/g, "");
|
|
150
|
-
const hay = norm(transcript);
|
|
151
|
-
const needle = norm(cmd);
|
|
152
|
-
const promptAt = hay.lastIndexOf(needle);
|
|
153
|
-
if (needle.length === 0)
|
|
185
|
+
// v1.85 T5: submission is acknowledged CAUSALLY or not at all. The adapter's declared OCCUPIED
|
|
186
|
+
// state means the prompt is still sitting in the box — never submitted; its declared EMPTY state
|
|
187
|
+
// means the box consumed it. Nothing else counts: the positional-transcript fallback this used to
|
|
188
|
+
// end with (prompt found, bytes exist after it ⇒ "delivered") answered yes for panes that had
|
|
189
|
+
// processed nothing (OBS-181), and it is DELETED. A box that is off-screen or unrecognisable is an
|
|
190
|
+
// absence of evidence, which fails closed — the same posture every gate takes. The launch-stage
|
|
191
|
+
// shell-echo reader is gone with it: a bootstrap line is now delivered atomically and acknowledged
|
|
192
|
+
// by its own START nonce, so there is no echo left to read (OBS-144 closed by construction).
|
|
193
|
+
submissionRegistered(transcript, inputBox) {
|
|
194
|
+
if (matchesOccupiedInputBox(transcript, inputBox))
|
|
154
195
|
return false;
|
|
155
|
-
|
|
156
|
-
return true;
|
|
157
|
-
if (promptAt < 0)
|
|
158
|
-
return inputBox !== undefined && matchesEmptyInputBox(transcript, inputBox);
|
|
159
|
-
if (inputBox) {
|
|
160
|
-
// OBS-181: an EMPTY declared input box is positive evidence of submission even while the
|
|
161
|
-
// prompt is still visible above as the echoed user turn — which is exactly how every TUI
|
|
162
|
-
// renders the moment after Enter lands. Test it FIRST: `match` means "the box is painted" and
|
|
163
|
-
// is deliberately true for an empty box, so asking `match` first cannot decide submission.
|
|
164
|
-
if (matchesEmptyInputBox(transcript, inputBox))
|
|
165
|
-
return true;
|
|
166
|
-
// The box is painted and is NOT empty: the prompt is still sitting in it. Only a fingerprint
|
|
167
|
-
// appearing AFTER the prompt — a fresh input line below the submitted text — counts.
|
|
168
|
-
//
|
|
169
|
-
// This is the branch a wedged kimi pane belongs in, and could not reach: its occupied editor
|
|
170
|
-
// renders a blank continuation row, the matcher demanded the bottom border immediately below
|
|
171
|
-
// the prompt, so NEITHER matcher fired and the positional fallback below answered "delivered"
|
|
172
|
-
// for a pane that had processed nothing. The matcher fix (adapters/kimi.ts) is what routes it
|
|
173
|
-
// here; keeping the fallback unreachable for a painted box is what stops the next one.
|
|
174
|
-
if (matchesInputBox(transcript, inputBox)) {
|
|
175
|
-
return hay.lastIndexOf(norm(inputBox.fingerprint)) > promptAt;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
// No declared box, or the box is not on screen at all (it may have scrolled out of the read
|
|
179
|
-
// window). Positional evidence only — weaker, and unsound for any surface that paints chrome
|
|
180
|
-
// below its prompt, which is why a declared box must be able to recognise its own OCCUPIED
|
|
181
|
-
// state. Every adapter declaring an inputBox needs a captured occupied frame in its tests.
|
|
182
|
-
return promptAt + needle.length < hay.length;
|
|
196
|
+
return matchesEmptyInputBox(transcript, inputBox);
|
|
183
197
|
}
|
|
184
198
|
static available() {
|
|
185
199
|
return process.env.HERDR_ENV === "1";
|
|
@@ -285,8 +299,6 @@ export class HerdrDriver {
|
|
|
285
299
|
throw new Error(`herdr tab create returned no tab_id (refusing untargeted placement): ${t.stdout}`);
|
|
286
300
|
if (typeof id !== "string" || !id)
|
|
287
301
|
throw new Error(`herdr tab create returned no root pane id (refusing untargeted placement): ${t.stdout}`);
|
|
288
|
-
// T5: the banner's pane identity line, derived from the T1 owned name (legacy names pass through).
|
|
289
|
-
const identity = shq(paneIdentityLine(canonicalizeLegacyName(name, "")));
|
|
290
302
|
// durable identity: label the PANE (resolution + reconcile read it back via `pane list`); fail closed
|
|
291
303
|
// and reap the tab we just made if the rename fails (no orphan tab).
|
|
292
304
|
const rn = await this.herdr(`pane rename ${shq(id)} ${shq(name)}`);
|
|
@@ -303,7 +315,7 @@ export class HerdrDriver {
|
|
|
303
315
|
// not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
|
|
304
316
|
// v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
|
|
305
317
|
// the operator's session. Daemon-side this.herdr() calls keep process.env (unsealed).
|
|
306
|
-
const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${
|
|
318
|
+
const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${shq(paneIdentityLine(canonicalizeLegacyName(name, "")))}; ${herdrSealShellPrefix()}`)}`);
|
|
307
319
|
if (seed.code !== 0)
|
|
308
320
|
throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
|
|
309
321
|
return { id, name, cwd, tabId };
|
|
@@ -450,22 +462,18 @@ export class HerdrDriver {
|
|
|
450
462
|
await this.renameGroupTab(entry);
|
|
451
463
|
return { id: pane, name, cwd, tabId: entry.tabId, group };
|
|
452
464
|
}
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
// only when that read-back contains the typed command. A corrupted paste is captured (pane read),
|
|
459
|
-
// cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
|
|
460
|
-
// transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
|
|
465
|
+
// v1.85 T5 (OBS-140/253): every dispatch is routed by what the target actually IS. A shell or
|
|
466
|
+
// bootstrap line goes out atomically through `pane run` and is acknowledged by its own START
|
|
467
|
+
// nonce; only a real TUI turn — an adapter-declared input box, and not that adapter's own launch
|
|
468
|
+
// command — earns the typed pincer. One dispatch corruption is then retried in-process against a
|
|
469
|
+
// FRESH pane before anything is reported as a failure.
|
|
461
470
|
async run(slot, cmd) {
|
|
462
471
|
const lease = this.dispatchLeases.get(slot);
|
|
463
472
|
if (lease) {
|
|
464
473
|
this.dispatchLeases.delete(slot);
|
|
465
474
|
try {
|
|
466
475
|
const paneId = await this.verifyPaneIdentityBinding(slot);
|
|
467
|
-
await this.
|
|
468
|
-
this.deliveredPanes.set(slot, paneId);
|
|
476
|
+
await this.dispatchWithRetry(slot, cmd, paneId);
|
|
469
477
|
}
|
|
470
478
|
finally {
|
|
471
479
|
lease.release();
|
|
@@ -473,13 +481,195 @@ export class HerdrDriver {
|
|
|
473
481
|
return;
|
|
474
482
|
}
|
|
475
483
|
return this.deliveryQueue(async () => {
|
|
476
|
-
|
|
477
|
-
await this.deliver(slot, cmd, paneId);
|
|
478
|
-
this.deliveredPanes.set(slot, paneId);
|
|
484
|
+
await this.dispatchWithRetry(slot, cmd, await this.paneId(slot));
|
|
479
485
|
});
|
|
480
486
|
}
|
|
481
|
-
|
|
482
|
-
|
|
487
|
+
// OBS-253: dispatch corruption is self-clearing — all ten recorded occurrences cleared on a fresh
|
|
488
|
+
// pane and a fresh submit, and every one of them cost an operator `resume --retry-failed`. Retry
|
|
489
|
+
// ONCE here against a pane that has never seen this delivery; the wedged pane is closed, never
|
|
490
|
+
// re-pressed. A second consecutive corruption propagates, and the daemon parks the task on it.
|
|
491
|
+
async dispatchWithRetry(slot, cmd, pane) {
|
|
492
|
+
try {
|
|
493
|
+
await this.dispatch(slot, cmd, pane);
|
|
494
|
+
this.deliveredPanes.set(slot, pane);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
if (!(error instanceof DeliveryCorruptedError))
|
|
499
|
+
throw error;
|
|
500
|
+
// A fresh pane is a bare shell. A shell dispatch is simply re-run on it; a typed turn first
|
|
501
|
+
// needs its interface back, which is possible only where the adapter declared the bootstrap
|
|
502
|
+
// this slot already delivered. Retyping a turn into a bare shell would guarantee the second
|
|
503
|
+
// failure rather than recover from the first, so without a replayable bootstrap the
|
|
504
|
+
// corruption propagates untouched.
|
|
505
|
+
const typedTurn = this.isTuiTurn(slot, cmd);
|
|
506
|
+
const relaunch = typedTurn ? this.bootstraps.get(slot) : undefined;
|
|
507
|
+
if (typedTurn && relaunch === undefined)
|
|
508
|
+
throw error;
|
|
509
|
+
// Record BEFORE acting: a retry that cannot be written to the run's ledger is not taken at all,
|
|
510
|
+
// because an unrecorded pane swap is exactly the silence OBS-253 cost ten times. The journal
|
|
511
|
+
// failure itself propagates visibly; the original corruption then remains terminal rather than
|
|
512
|
+
// triggering an off-record recovery.
|
|
513
|
+
this.appendDispatchRetry(slot, { wedgedPane: pane, reason: error.message });
|
|
514
|
+
const fresh = await this.freshPane(slot, pane);
|
|
515
|
+
if (fresh === null)
|
|
516
|
+
throw error;
|
|
517
|
+
if (relaunch !== undefined)
|
|
518
|
+
await this.deliverAtomic(slot, relaunch, fresh);
|
|
519
|
+
await this.dispatch(slot, cmd, fresh);
|
|
520
|
+
this.deliveredPanes.set(slot, fresh);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// Is this delivery a turn TYPED into a running interface, rather than a line a shell runs? The
|
|
524
|
+
// adapter's declaration decides it wherever there is one: `launchCommand` names the bootstrap,
|
|
525
|
+
// everything else is a turn. With NO declaration the only evidence is the slot's own history — a
|
|
526
|
+
// pane that already accepted a delivery is running whatever that delivery started, so a second
|
|
527
|
+
// delivery is a turn into it. Answering "shell" there is what would run an adapter's seed prompt
|
|
528
|
+
// as a shell command; answering "turn" routes it to deliverTyped, which refuses it by name
|
|
529
|
+
// because the adapter declared no input box (fail closed, never a guess).
|
|
530
|
+
isTuiTurn(slot, cmd) {
|
|
531
|
+
const inputBox = this.inputBoxes.get(slot);
|
|
532
|
+
return inputBox ? inputBox.launchCommand?.(cmd) !== true : this.deliveredPanes.has(slot);
|
|
533
|
+
}
|
|
534
|
+
dispatch(slot, cmd, pane) {
|
|
535
|
+
if (this.isTuiTurn(slot, cmd))
|
|
536
|
+
return this.deliverTyped(slot, cmd, pane);
|
|
537
|
+
// An adapter-declared bootstrap is the one command that can restore this slot's interface on a
|
|
538
|
+
// fresh pane, so remember it for the recovery above.
|
|
539
|
+
if (this.inputBoxes.get(slot)?.launchCommand?.(cmd) === true)
|
|
540
|
+
this.bootstraps.set(slot, cmd);
|
|
541
|
+
return this.deliverAtomic(slot, cmd, pane);
|
|
542
|
+
}
|
|
543
|
+
// The OBS-140 class dies here. `pane run` puts text and Enter in ONE herdr request, so there is no
|
|
544
|
+
// seam in which a swallowed Enter can leave a verified-but-unsubmitted prompt, and nothing about
|
|
545
|
+
// the delivery is inferred from typed-text read-back. The line's first two statements print a
|
|
546
|
+
// per-dispatch START nonce assembled from two printf arguments — once to a file whose name only
|
|
547
|
+
// this process knows, then once to the pane for the operator. The joined marker cannot exist
|
|
548
|
+
// unless the shell RAN our line, so the acknowledgment is causal; and because the file is written
|
|
549
|
+
// before the agent the line launches produces a byte, no amount of later output can hide it. Both
|
|
550
|
+
// statements are shell builtins: the ack cannot fail for want of a binary on the pane's PATH.
|
|
551
|
+
//
|
|
552
|
+
// v1.85 T5 review: the durable half GATES the launch, and goes FIRST. A dispatch whose ack the
|
|
553
|
+
// pane could not write is indistinguishable, from here, from a line that never ran — so if the
|
|
554
|
+
// command ran anyway, the miss buys a fresh pane and a SECOND live agent for the same task. The
|
|
555
|
+
// `|| exit 1` makes that impossible: no ack, no command. Ordering carries the same weight in the
|
|
556
|
+
// other direction — a pane-visible marker printed before a failed ack write would satisfy the
|
|
557
|
+
// event watch and report a launch that never happened.
|
|
558
|
+
async deliverAtomic(slot, cmd, pane) {
|
|
559
|
+
const suffix = randomUUID(); // unguessable: nothing the delivered command runs can forge the ack
|
|
560
|
+
const nonce = `${DISPATCH_START_PREFIX}${suffix}`;
|
|
561
|
+
const ackPath = join(tmpdir(), `tickmarkr-dispatch-${suffix}.ack`);
|
|
562
|
+
// Open the channel before spending a pane on it: an ack path this process cannot write is a
|
|
563
|
+
// broken host, not a wedged pane, and the fresh-pane retry would fail there for the same reason.
|
|
564
|
+
// Throwing something other than DeliveryCorruptedError is what keeps that retry unspent.
|
|
565
|
+
try {
|
|
566
|
+
writeFileSync(ackPath, ""); // empty: only the delivered line may ever put the nonce here
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
throw new Error(`dispatch acknowledgment channel unavailable (${ackPath}): ${error.message}`);
|
|
570
|
+
}
|
|
571
|
+
const marker = `printf '%s%s\\n' ${shq(DISPATCH_START_PREFIX)} ${shq(suffix)}`;
|
|
572
|
+
const line = `${marker} > ${shq(ackPath)} || exit 1; ${marker}; ${cmd}`;
|
|
573
|
+
const deadline = this.time.now() + DISPATCH_ACK_TIMEOUT_MS;
|
|
574
|
+
try {
|
|
575
|
+
const sent = await this.herdr(`pane run ${shq(pane)} ${shq(line)}`, slot.cwd);
|
|
576
|
+
if (sent.code !== 0) {
|
|
577
|
+
throw new DeliveryCorruptedError(pane, `pane run failed: ${sent.stderr || sent.stdout}`, "");
|
|
578
|
+
}
|
|
579
|
+
// Fast path: herdr's own event-driven watch on the pane's output. A match is causal — the pane
|
|
580
|
+
// cannot emit the joined marker without having RUN the line. A MISS is not evidence of
|
|
581
|
+
// anything: this watch subscribes after the request, and a full-screen repaint can swallow the
|
|
582
|
+
// very line it is watching for, which is how a successful launch got dispatched twice. So a
|
|
583
|
+
// miss falls through to the channel the line wrote for itself, which nothing can repaint.
|
|
584
|
+
const seen = await this.herdr(`pane wait-output ${shq(pane)} --match ${shq(nonce)} --timeout ${DISPATCH_ACK_TIMEOUT_MS}`, slot.cwd, DISPATCH_ACK_TIMEOUT_MS + 15_000);
|
|
585
|
+
if (this.waitOk(seen.code, seen.stdout))
|
|
586
|
+
return;
|
|
587
|
+
if (await this.awaitDispatchAck(ackPath, nonce, deadline))
|
|
588
|
+
return;
|
|
589
|
+
// Only now, and only as evidence for the operator: the transcript explains the failure, it
|
|
590
|
+
// never decides one. A pane that looks like it ran the line but never wrote the ack did not.
|
|
591
|
+
const transcript = (await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, slot.cwd)).stdout;
|
|
592
|
+
throw new DeliveryCorruptedError(pane, `START nonce ${nonce} never appeared — the line never ran`, transcript);
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
try {
|
|
596
|
+
unlinkSync(ackPath);
|
|
597
|
+
}
|
|
598
|
+
catch { /* never written, or already reaped — nothing to clean */ }
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// The durable half of the acknowledgment: the file the delivered line wrote before it launched
|
|
602
|
+
// anything. Checked at least once even when the shared dispatch deadline has already passed, so a
|
|
603
|
+
// watch that spent the whole window missing the line still gets the truth from the line itself.
|
|
604
|
+
async awaitDispatchAck(ackPath, nonce, deadline) {
|
|
605
|
+
for (;;) {
|
|
606
|
+
try {
|
|
607
|
+
if (readFileSync(ackPath, "utf8").includes(nonce))
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
/* the line has not reached the marker yet — or never will, which the deadline decides */
|
|
612
|
+
}
|
|
613
|
+
if (this.time.now() >= deadline)
|
|
614
|
+
return false;
|
|
615
|
+
await this.time.sleep(DISPATCH_ACK_POLL_MS);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// OBS-253: a fresh pane is a SIBLING inside this slot's own tab. Split first so the tab can never
|
|
619
|
+
// empty, then close the wedged pane and take its durable label — every other driver call addresses
|
|
620
|
+
// this slot through that label or the delivered-pane pin, so no caller observes the swap.
|
|
621
|
+
async freshPane(slot, wedged) {
|
|
622
|
+
if (!this.ws)
|
|
623
|
+
return null;
|
|
624
|
+
const sp = await this.herdr(`pane split ${shq(wedged)} --direction down --no-focus --cwd ${shq(slot.cwd)}`);
|
|
625
|
+
if (sp.code !== 0)
|
|
626
|
+
return null;
|
|
627
|
+
let pane;
|
|
628
|
+
try {
|
|
629
|
+
pane = JSON.parse(sp.stdout).result?.pane?.pane_id ?? undefined;
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
if (typeof pane !== "string" || !pane)
|
|
635
|
+
return null;
|
|
636
|
+
// The wedged pane is never re-pressed, only reaped — and the reap is a POSTCONDITION, not a
|
|
637
|
+
// best-effort. It still carries this slot's durable label, so a close that fails (or succeeds
|
|
638
|
+
// and frees nothing) leaves two panes answering to one name and `pane list` resolving it by
|
|
639
|
+
// whichever comes first. Verify the name is free BEFORE the replacement is renamed, seeded or
|
|
640
|
+
// dispatched: a fresh pane that is only PROBABLY the slot is not a fresh pane (v1.85 T5 review).
|
|
641
|
+
const closed = await this.herdr(`pane close ${shq(wedged)}`);
|
|
642
|
+
if (closed.code !== 0 || (await this.staleLabelPanes(slot.name, pane)).length > 0) {
|
|
643
|
+
await this.herdr(`pane close ${shq(pane)}`);
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
const rn = await this.herdr(`pane rename ${shq(pane)} ${shq(slot.name)}`);
|
|
647
|
+
if (rn.code !== 0 || (await this.namedPaneId(slot.name)) !== pane) {
|
|
648
|
+
await this.herdr(`pane close ${shq(pane)}`);
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
// VIS-10 hole 3: a split pane is a bare shell with FRESH env, so it is untargeted until seeded.
|
|
652
|
+
const seed = await this.herdr(`pane run ${shq(pane)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${shq(paneIdentityLine(canonicalizeLegacyName(slot.name, "")))}; ${herdrSealShellPrefix()}`)}`, slot.cwd);
|
|
653
|
+
if (seed.code !== 0) {
|
|
654
|
+
await this.herdr(`pane close ${shq(pane)}`);
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
return pane;
|
|
658
|
+
}
|
|
659
|
+
// OBS-85 typed delivery, now reserved for real TUI turns: type WITHOUT enter, read the pane back
|
|
660
|
+
// — `wait output --match` checks the same unwrapped transcript pane read exposes, event-driven so
|
|
661
|
+
// wrap and render timing can't race the check — and press Enter only when that read-back contains
|
|
662
|
+
// the typed command. A corrupted paste is captured (pane read), cleared (C-u), and retyped,
|
|
663
|
+
// bounded; persistent corruption fails closed WITH the captured transcript.
|
|
664
|
+
async deliverTyped(slot, cmd, pane) {
|
|
665
|
+
const inputBox = this.inputBoxes.get(slot);
|
|
666
|
+
const missing = missingInputStateDeclarations(inputBox);
|
|
667
|
+
if (missing.length > 0) {
|
|
668
|
+
throw new Error(`herdr refuses typed delivery to ${slot.name}: the adapter has not declared its input states `
|
|
669
|
+
+ `(missing: ${missing.join(", ")}) — only a declared box can acknowledge a submission, and `
|
|
670
|
+
+ `nothing else may stand in for it (OBS-140)`);
|
|
671
|
+
}
|
|
672
|
+
const readiness = await this.awaitDeliveryReadiness(slot, cmd, pane, inputBox);
|
|
483
673
|
let transcript = "";
|
|
484
674
|
for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
|
|
485
675
|
if (attempt > 0) {
|
|
@@ -487,7 +677,7 @@ export class HerdrDriver {
|
|
|
487
677
|
// line only after two consecutive pane reads agree; an already-stable frame returns on the
|
|
488
678
|
// first fresh read without a timer. A changing pane is bounded and preserves OBS-85's
|
|
489
679
|
// fail-closed error instead of guessing from an adapter fingerprint.
|
|
490
|
-
const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript,
|
|
680
|
+
const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox);
|
|
491
681
|
transcript = settled.transcript;
|
|
492
682
|
if (!settled.ok) {
|
|
493
683
|
throw new Error(`herdr delivery clear failed — refusing to retype onto a corrupted line (OBS-85); pane transcript:\n${transcript}`);
|
|
@@ -506,24 +696,14 @@ export class HerdrDriver {
|
|
|
506
696
|
throw new Error(`herdr pane send-text failed: ${typed.stderr || typed.stdout}`);
|
|
507
697
|
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);
|
|
508
698
|
if (this.waitOk(back.code, back.stdout) || await this.deliveryReadMatches(pane, cmd, slot.cwd)) {
|
|
509
|
-
|
|
510
|
-
await this.submitVerifiedDelivery(slot, cmd, pane, readiness);
|
|
511
|
-
}
|
|
512
|
-
else {
|
|
513
|
-
const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
|
|
514
|
-
if (enter.code !== 0)
|
|
515
|
-
throw new Error(`herdr pane send-keys Enter failed: ${enter.stderr || enter.stdout}`);
|
|
516
|
-
}
|
|
699
|
+
await this.submitVerifiedDelivery(slot, pane, inputBox, readiness);
|
|
517
700
|
return;
|
|
518
701
|
}
|
|
519
702
|
// capture the corrupted delivery BEFORE clearing it — the OBS-85 byte-level evidence
|
|
520
703
|
transcript = (await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, slot.cwd)).stdout;
|
|
521
704
|
}
|
|
522
|
-
throw new
|
|
705
|
+
throw new DeliveryCorruptedError(pane, `${DELIVERY_ATTEMPTS} typed attempts — enter never pressed (OBS-85)`, transcript);
|
|
523
706
|
}
|
|
524
|
-
// The narrator launches a perpetual shell watch, not an adapter-backed input interface. Keep
|
|
525
|
-
// OBS-85's readiness + paste read-back and the historical single Enter, while worker/gate runs
|
|
526
|
-
// continue through positive post-Enter evidence in submitVerifiedDelivery.
|
|
527
707
|
// OBS-201: liveness nudge — deliver one message into the live worker TUI through the exact
|
|
528
708
|
// pincer every dispatch uses (readiness stable-frame, type-without-Enter, read-back, C-u clear
|
|
529
709
|
// on corruption, verified submit), serialized on the deliveryQueue so it can never interleave
|
|
@@ -536,23 +716,24 @@ export class HerdrDriver {
|
|
|
536
716
|
if (!pinned)
|
|
537
717
|
return false;
|
|
538
718
|
try {
|
|
539
|
-
await this.deliveryQueue(() => this.
|
|
719
|
+
await this.deliveryQueue(() => this.deliverTyped(slot, message, pinned));
|
|
540
720
|
return true;
|
|
541
721
|
}
|
|
542
722
|
catch {
|
|
543
723
|
return false; // the daemon journals worker-nudge-failed and falls back to the stall window
|
|
544
724
|
}
|
|
545
725
|
}
|
|
726
|
+
// The narrator launches a perpetual shell watch, not an adapter-backed input interface — a shell
|
|
727
|
+
// dispatch like any other, delivered atomically and acknowledged by its own START nonce.
|
|
546
728
|
async deliverPersistentShellCommand(slot, cmd) {
|
|
547
729
|
return this.deliveryQueue(async () => {
|
|
548
730
|
const pane = await this.paneId(slot);
|
|
549
|
-
await this.
|
|
731
|
+
await this.deliverAtomic(slot, cmd, pane);
|
|
550
732
|
this.deliveredPanes.set(slot, pane);
|
|
551
733
|
});
|
|
552
734
|
}
|
|
553
|
-
async submitVerifiedDelivery(slot,
|
|
735
|
+
async submitVerifiedDelivery(slot, pane, inputBox, readiness) {
|
|
554
736
|
let transcript = "";
|
|
555
|
-
const inputBox = this.inputBoxes.get(slot);
|
|
556
737
|
// The base window preserves OBS-140's bounded behavior. A slow readiness observation grants
|
|
557
738
|
// the same measured time once more for submit paint, capped by the readiness bound itself.
|
|
558
739
|
const baseVerifyMs = (DELIVERY_SETTLE_READ_ATTEMPTS - 1) * DELIVERY_SETTLE_POLL_MS;
|
|
@@ -564,15 +745,15 @@ export class HerdrDriver {
|
|
|
564
745
|
// Reuse the existing settle-read window. A first-read success returns before any timer; only
|
|
565
746
|
// a prompt that still occupies the delivery target spends the bounded settle window. This
|
|
566
747
|
// verification always completes before a possible re-press, so a slow submit cannot duplicate.
|
|
567
|
-
const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox, (candidate) => this.submissionRegistered(candidate,
|
|
748
|
+
const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox, (candidate) => this.submissionRegistered(candidate, inputBox), verifyWindowMs);
|
|
568
749
|
transcript = settled.transcript;
|
|
569
750
|
if (settled.ok)
|
|
570
751
|
return;
|
|
571
752
|
if (settled.readFailed) {
|
|
572
|
-
throw new
|
|
753
|
+
throw new DeliveryCorruptedError(pane, "submission verification read failed, refusing to re-press Enter (OBS-140)", transcript);
|
|
573
754
|
}
|
|
574
755
|
}
|
|
575
|
-
throw new
|
|
756
|
+
throw new DeliveryCorruptedError(pane, `${DELIVERY_SUBMIT_ATTEMPTS} submit attempts — submission never registered (OBS-140)`, transcript);
|
|
576
757
|
}
|
|
577
758
|
async settleDeliveryLine(pane, cwd, initialTranscript, inputBox, accept, settleWindowMs) {
|
|
578
759
|
let transcript = initialTranscript;
|
|
@@ -604,10 +785,11 @@ export class HerdrDriver {
|
|
|
604
785
|
}
|
|
605
786
|
return { ok: false, transcript, recognizedInputBox: false };
|
|
606
787
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
788
|
+
// Readiness now serves typed delivery only, so the target is always the adapter's declared box:
|
|
789
|
+
// the "no declared box, so assume ready when the pane doesn't already show our command" branch is
|
|
790
|
+
// gone with the rest of the typed-text inference (v1.85 T5).
|
|
791
|
+
async awaitDeliveryReadiness(slot, cmd, pane, inputBox) {
|
|
792
|
+
const timeoutMs = inputBox.readinessTimeoutMs ?? DELIVERY_READINESS_TIMEOUT_MS;
|
|
611
793
|
const started = this.time.now();
|
|
612
794
|
let previous;
|
|
613
795
|
let transcript = "";
|
|
@@ -635,11 +817,9 @@ export class HerdrDriver {
|
|
|
635
817
|
}
|
|
636
818
|
if (previous !== undefined) {
|
|
637
819
|
const stableFrame = read.stdout === previous;
|
|
638
|
-
|
|
639
|
-
? matchesInputBox(previous, inputBox) && matchesInputBox(read.stdout, inputBox)
|
|
640
|
-
: !this.deliveryMatches(read.stdout, cmd));
|
|
641
|
-
if (targetReady)
|
|
820
|
+
if (stableFrame && matchesInputBox(previous, inputBox) && matchesInputBox(read.stdout, inputBox)) {
|
|
642
821
|
return { waitedMs, timeoutMs, transcript: read.stdout };
|
|
822
|
+
}
|
|
643
823
|
}
|
|
644
824
|
previous = read.stdout;
|
|
645
825
|
const remaining = timeoutMs - waitedMs;
|
|
@@ -888,7 +1068,10 @@ export class HerdrDriver {
|
|
|
888
1068
|
/* cosmetic — visibility hygiene never fails the run */
|
|
889
1069
|
}
|
|
890
1070
|
}
|
|
891
|
-
worktree(repo, branch, baseRef) {
|
|
892
|
-
|
|
1071
|
+
async worktree(repo, branch, baseRef) {
|
|
1072
|
+
const worktree = await createWorktree(repo, branch, baseRef);
|
|
1073
|
+
this.journalRoots.set(repo, repo);
|
|
1074
|
+
this.journalRoots.set(worktree, repo);
|
|
1075
|
+
return worktree;
|
|
893
1076
|
}
|
|
894
1077
|
}
|
package/dist/gates/baseline.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface BaselineWarning {
|
|
|
14
14
|
commands: string[];
|
|
15
15
|
reason: string;
|
|
16
16
|
}
|
|
17
|
+
export declare const UNRECOGNIZED_FAILURE = "<unrecognized failure output>";
|
|
17
18
|
export declare function fingerprint(output: string): string[];
|
|
18
19
|
export declare function detectGateCommands(repoRoot: string, cfg: TickmarkrConfig): Record<string, string>;
|
|
19
20
|
export declare function captureBaseline(cwd: string, commands: Record<string, string>): Promise<Baseline>;
|