tickmarkr 1.84.0 → 1.86.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/README.md +4 -2
- package/dist/adapters/catalog-remote.d.ts +64 -0
- package/dist/adapters/catalog-remote.js +287 -0
- package/dist/adapters/catalog.d.ts +96 -0
- package/dist/adapters/catalog.js +176 -0
- package/dist/adapters/claude-code.d.ts +1 -0
- package/dist/adapters/claude-code.js +59 -1
- package/dist/adapters/fake.js +42 -4
- package/dist/adapters/model-lints.d.ts +25 -5
- package/dist/adapters/model-lints.js +184 -50
- package/dist/adapters/model-windows.d.ts +31 -0
- package/dist/adapters/model-windows.js +69 -0
- package/dist/adapters/prompt.d.ts +5 -1
- package/dist/adapters/prompt.js +13 -4
- package/dist/adapters/registry.d.ts +25 -26
- package/dist/adapters/registry.js +173 -110
- package/dist/adapters/types.d.ts +3 -0
- package/dist/adapters/types.js +36 -3
- package/dist/brand.d.ts +5 -1
- package/dist/brand.js +18 -2
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.js +43 -21
- package/dist/cli/commands/fleet.d.ts +7 -0
- package/dist/cli/commands/fleet.js +94 -74
- package/dist/cli/commands/init.js +118 -5
- package/dist/cli/commands/status.js +202 -46
- package/dist/compile/collateral.d.ts +86 -2
- package/dist/compile/collateral.js +294 -3
- package/dist/compile/gsd.d.ts +2 -1
- package/dist/compile/gsd.js +68 -2
- package/dist/compile/native.d.ts +14 -0
- package/dist/compile/native.js +161 -12
- package/dist/config/config.d.ts +82 -5
- package/dist/config/config.js +253 -66
- package/dist/config/fleet-overlay.d.ts +25 -20
- package/dist/config/fleet-overlay.js +195 -77
- package/dist/config/fleet-why.d.ts +23 -0
- package/dist/config/fleet-why.js +42 -0
- package/dist/drivers/herdr.d.ts +21 -3
- package/dist/drivers/herdr.js +344 -110
- package/dist/gates/acceptance.js +7 -2
- package/dist/gates/baseline.d.ts +1 -0
- package/dist/gates/baseline.js +91 -13
- package/dist/gates/llm.d.ts +0 -1
- package/dist/gates/llm.js +5 -30
- package/dist/gates/review.d.ts +9 -1
- package/dist/gates/review.js +105 -10
- package/dist/gates/run-gates.d.ts +9 -0
- package/dist/gates/run-gates.js +285 -41
- package/dist/gates/verdict-cause.d.ts +4 -0
- package/dist/gates/verdict-cause.js +63 -0
- package/dist/graph/schema.d.ts +6 -0
- package/dist/graph/schema.js +8 -5
- package/dist/route/router.d.ts +0 -5
- package/dist/route/router.js +16 -20
- package/dist/run/consult.d.ts +6 -0
- package/dist/run/consult.js +35 -25
- package/dist/run/daemon.d.ts +48 -2
- package/dist/run/daemon.js +1488 -330
- package/dist/run/journal.d.ts +56 -3
- package/dist/run/journal.js +358 -4
- package/dist/run/stall.d.ts +35 -1
- package/dist/run/stall.js +118 -8
- package/dist/tui/cockpit/capture.d.ts +12 -0
- package/dist/tui/cockpit/capture.js +37 -1
- package/dist/tui/cockpit/components.d.ts +2 -0
- package/dist/tui/cockpit/components.js +8 -8
- 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/dist/tui/cockpit/theme.d.ts +32 -26
- package/dist/tui/cockpit/theme.js +11 -5
- package/dist/tui/ink/components.d.ts +0 -15
- package/dist/tui/ink/components.js +0 -17
- package/dist/tui/ink/fleet-app.d.ts +4 -1
- package/dist/tui/ink/fleet-app.js +134 -13
- package/fixtures/sample.native.md +1 -1
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +354 -34
- package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +70 -0
- package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
- package/dist/tui/ink/studio-app.d.ts +0 -59
- package/dist/tui/ink/studio-app.js +0 -320
- package/dist/tui/save.d.ts +0 -38
- package/dist/tui/save.js +0 -96
- package/dist/tui/staging.d.ts +0 -29
- package/dist/tui/staging.js +0 -78
package/dist/drivers/herdr.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
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";
|
|
6
|
+
import { consumePaneLaunchIntent, 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,16 +46,53 @@ 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)
|
|
38
67
|
return "down";
|
|
39
68
|
return paneCols / 2 >= safeFloor + margin ? "right" : "down";
|
|
40
69
|
}
|
|
70
|
+
/** The tab a slot belongs to: its TASK — worker, judge, review and consult panes for one task share it.
|
|
71
|
+
* Returns undefined for everything else, which keeps those on the dedicated-tab path.
|
|
72
|
+
*
|
|
73
|
+
* The ROLE gate is load-bearing and was added after it bit: `canonicalizeLegacyName` returns
|
|
74
|
+
* `role:"other", taskId:"<the whole name>"` for any unrecognised string, so keying on taskId alone
|
|
75
|
+
* makes EVERY one-off pane its own "task" and gives it a group tab. `watch` is excluded for the same
|
|
76
|
+
* reason in the other direction — its taskId is the literal "run", which is a board, not a task. */
|
|
77
|
+
const TASK_TAB_ROLES = new Set(["worker", "judge", "review", "consult"]);
|
|
78
|
+
export function taskGroupOf(name) {
|
|
79
|
+
const { role, taskId } = canonicalizeLegacyName(name, "");
|
|
80
|
+
if (!TASK_TAB_ROLES.has(role))
|
|
81
|
+
return undefined;
|
|
82
|
+
return taskId && taskId.trim() ? taskId : undefined;
|
|
83
|
+
}
|
|
84
|
+
/** Gate panes ride with the task they belong to and never consume the tab cap; everything else does.
|
|
85
|
+
* Scoped to the three GATE roles deliberately — an earlier cut of this said "not a worker", which let
|
|
86
|
+
* role:"other" members (any unrecognised name) bypass the cap and silently disabled overflow for
|
|
87
|
+
* explicit stage groups. The cap still governs every member it governed before. */
|
|
88
|
+
const GATE_ROLES = new Set(["judge", "review", "consult"]);
|
|
89
|
+
const ridesWithTask = (name) => GATE_ROLES.has(canonicalizeLegacyName(name, "").role);
|
|
90
|
+
const cappedMembers = (g) => g.members.filter((m) => !ridesWithTask(m.name)).length;
|
|
41
91
|
export class HerdrDriver {
|
|
42
92
|
bin;
|
|
43
93
|
workersPerTab;
|
|
44
94
|
time;
|
|
95
|
+
journal;
|
|
45
96
|
id = "herdr";
|
|
46
97
|
interactive = true;
|
|
47
98
|
groups = new Map();
|
|
@@ -54,16 +105,44 @@ export class HerdrDriver {
|
|
|
54
105
|
dispatchLeases = new WeakMap();
|
|
55
106
|
deliveredPanes = new WeakMap();
|
|
56
107
|
inputBoxes = new WeakMap();
|
|
108
|
+
// OBS-253: the adapter-declared bootstrap this slot has already delivered. A fresh pane is a bare
|
|
109
|
+
// shell, so it is the only thing that can put a TUI back under a typed turn that has to move.
|
|
110
|
+
bootstraps = new WeakMap();
|
|
111
|
+
// runDaemon gives the driver the authoritative repo root at its worktree seam before it allocates
|
|
112
|
+
// that task's slot. Keep the binding by cwd so a dispatch retry opens THAT run's Journal even when
|
|
113
|
+
// the caller launched tickmarkr elsewhere (process.cwd is not run identity). The repo itself is
|
|
114
|
+
// also bound for judge/review/consult slots whose cwd is the root rather than a task worktree.
|
|
115
|
+
journalRoots = new Map();
|
|
57
116
|
// VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
|
|
58
117
|
// operator's env before the driver is built). Required at slot() time, never in the constructor —
|
|
59
118
|
// pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
|
|
60
119
|
ws = process.env.HERDR_WORKSPACE_ID;
|
|
61
120
|
callerPane = process.env.HERDR_PANE_ID;
|
|
62
121
|
watches = new Map();
|
|
63
|
-
constructor(bin = "herdr", workersPerTab = 3, time = SYSTEM_TIME) {
|
|
122
|
+
constructor(bin = "herdr", workersPerTab = 3, time = SYSTEM_TIME, journal) {
|
|
64
123
|
this.bin = bin;
|
|
65
124
|
this.workersPerTab = workersPerTab;
|
|
66
125
|
this.time = time;
|
|
126
|
+
this.journal = journal;
|
|
127
|
+
}
|
|
128
|
+
// The dispatch-retry record is mandatory: it is the only durable fact left by a recovered pane
|
|
129
|
+
// swap. Tests may inject a sink, while production resolves the daemon's real Journal from the
|
|
130
|
+
// worktree binding plus the canonical slot name. Every resolution/open/append failure propagates;
|
|
131
|
+
// an unjournaled recovery is never performed and its audit failure is never hidden by the original
|
|
132
|
+
// corruption error.
|
|
133
|
+
appendDispatchRetry(slot, data) {
|
|
134
|
+
if (this.journal) {
|
|
135
|
+
this.journal("dispatch-retry", slot.name, data);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const owned = parseOwnedName(slot.name);
|
|
139
|
+
if (!owned)
|
|
140
|
+
throw new Error(`cannot journal dispatch-retry: slot ${slot.name} carries no run identity`);
|
|
141
|
+
const repoRoot = this.journalRoots.get(slot.cwd);
|
|
142
|
+
if (!repoRoot) {
|
|
143
|
+
throw new Error(`cannot journal dispatch-retry: slot ${slot.name} has no daemon repo binding for ${slot.cwd}`);
|
|
144
|
+
}
|
|
145
|
+
Journal.open(repoRoot, owned.runId).append("dispatch-retry", owned.taskId, data);
|
|
67
146
|
}
|
|
68
147
|
serial(fn) {
|
|
69
148
|
const p = this.groupSerial.then(fn, fn);
|
|
@@ -124,62 +203,18 @@ export class HerdrDriver {
|
|
|
124
203
|
const needle = norm(cmd);
|
|
125
204
|
return needle.length > 0 && hay.includes(needle);
|
|
126
205
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
206
|
+
// v1.85 T5: submission is acknowledged CAUSALLY or not at all. The adapter's declared OCCUPIED
|
|
207
|
+
// state means the prompt is still sitting in the box — never submitted; its declared EMPTY state
|
|
208
|
+
// means the box consumed it. Nothing else counts: the positional-transcript fallback this used to
|
|
209
|
+
// end with (prompt found, bytes exist after it ⇒ "delivered") answered yes for panes that had
|
|
210
|
+
// processed nothing (OBS-181), and it is DELETED. A box that is off-screen or unrecognisable is an
|
|
211
|
+
// absence of evidence, which fails closed — the same posture every gate takes. The launch-stage
|
|
212
|
+
// shell-echo reader is gone with it: a bootstrap line is now delivered atomically and acknowledged
|
|
213
|
+
// by its own START nonce, so there is no echo left to read (OBS-144 closed by construction).
|
|
214
|
+
submissionRegistered(transcript, inputBox) {
|
|
215
|
+
if (matchesOccupiedInputBox(transcript, inputBox))
|
|
131
216
|
return false;
|
|
132
|
-
return transcript
|
|
133
|
-
const line = norm(rawLine);
|
|
134
|
-
if (line === needle || !line.endsWith(needle))
|
|
135
|
-
return false;
|
|
136
|
-
const prefix = line.slice(0, -needle.length).trimEnd();
|
|
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)
|
|
154
|
-
return false;
|
|
155
|
-
if (inputBox?.launchCommand?.(cmd) === true && this.shellExecutionEchoed(transcript, cmd))
|
|
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;
|
|
217
|
+
return matchesEmptyInputBox(transcript, inputBox);
|
|
183
218
|
}
|
|
184
219
|
static available() {
|
|
185
220
|
return process.env.HERDR_ENV === "1";
|
|
@@ -236,8 +271,22 @@ export class HerdrDriver {
|
|
|
236
271
|
// reconcile.ts and this driver's own renameGroupTab/glyphFor decode role/taskId/attempt from
|
|
237
272
|
// those shapes without a call-site migration; T2 retires this branch by always passing `owned`.
|
|
238
273
|
const resolved = opts?.owned ? formatOwnedName(opts.owned) : name;
|
|
239
|
-
|
|
240
|
-
|
|
274
|
+
// ONE TAB PER TASK. The group defaults to the task id, derived from the same parser that already
|
|
275
|
+
// decodes role/taskId for tab labels — so a worker and every gate pane it earns (judge, review,
|
|
276
|
+
// consult) land in that task's tab instead of scattering. Deriving it HERE rather than at each
|
|
277
|
+
// call site is the point: the defect this replaces was three call sites of which exactly one
|
|
278
|
+
// passed a group, so judge/review/consult each opened a tab of their own. A caller may still pass
|
|
279
|
+
// `group` explicitly to override, and a name with no task id (or an explicit `label`) keeps the
|
|
280
|
+
// dedicated-tab path unchanged.
|
|
281
|
+
// PRECEDENCE: a task-bearing name ALWAYS groups by its task; an explicit `group` applies only to
|
|
282
|
+
// names with no task identity. That direction is deliberate — the invariant is "a task's panes are
|
|
283
|
+
// never scattered", and a caller passing a stage group (the daemon still passes "workers", which now
|
|
284
|
+
// serves as the fallback for task-less names) must not be able to override it by omission or habit.
|
|
285
|
+
// An explicit `label` still wins outright: that is the dedicated-role-tab path, chosen on purpose.
|
|
286
|
+
const derivedGroup = opts?.label ? undefined : taskGroupOf(resolved);
|
|
287
|
+
const group = derivedGroup ?? opts?.group;
|
|
288
|
+
const allocate = group
|
|
289
|
+
? () => this.serial(() => this.groupSlot(cwd, resolved, group))
|
|
241
290
|
: () => this.tabSlot(cwd, resolved, opts?.label);
|
|
242
291
|
// Production dispatch names are canonical even when the gate call site supplies the already-
|
|
243
292
|
// formatted name rather than SlotOpts.owned. Hold one lease across slot() → run(); legacy/manual
|
|
@@ -285,8 +334,6 @@ export class HerdrDriver {
|
|
|
285
334
|
throw new Error(`herdr tab create returned no tab_id (refusing untargeted placement): ${t.stdout}`);
|
|
286
335
|
if (typeof id !== "string" || !id)
|
|
287
336
|
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
337
|
// durable identity: label the PANE (resolution + reconcile read it back via `pane list`); fail closed
|
|
291
338
|
// and reap the tab we just made if the rename fails (no orphan tab).
|
|
292
339
|
const rn = await this.herdr(`pane rename ${shq(id)} ${shq(name)}`);
|
|
@@ -303,7 +350,7 @@ export class HerdrDriver {
|
|
|
303
350
|
// not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
|
|
304
351
|
// v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
|
|
305
352
|
// 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}=${
|
|
353
|
+
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
354
|
if (seed.code !== 0)
|
|
308
355
|
throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
|
|
309
356
|
return { id, name, cwd, tabId };
|
|
@@ -346,7 +393,10 @@ export class HerdrDriver {
|
|
|
346
393
|
return this.tabSlot(cwd, name); // D-09: degrade, NOT a shared-tab member
|
|
347
394
|
if (state) {
|
|
348
395
|
const latest = state.generations[state.generations.length - 1];
|
|
349
|
-
|
|
396
|
+
// A task tab's judge/review/consult panes belong with their worker and must never overflow into a
|
|
397
|
+
// `cleanup` tab — that would re-scatter exactly what the per-task grouping exists to gather. They
|
|
398
|
+
// therefore join freely and do not consume `workersPerTab`; every other member still does.
|
|
399
|
+
if (latest && (ridesWithTask(name) || cappedMembers(latest) < this.workersPerTab)) {
|
|
350
400
|
const joined = await this.joinGroup(cwd, name, group, latest);
|
|
351
401
|
if (joined)
|
|
352
402
|
return joined;
|
|
@@ -381,7 +431,11 @@ export class HerdrDriver {
|
|
|
381
431
|
const newest = [...entry.members].reverse().find((m) => canonicalizeLegacyName(m.name, "").role === "worker");
|
|
382
432
|
const token = newest ? canonicalizeLegacyName(newest.name, "").taskId : undefined;
|
|
383
433
|
const glyph = newest ? await this.glyphFor(newest) : "";
|
|
384
|
-
|
|
434
|
+
// A per-task tab is already labelled with its task id; appending the same token again reads as a
|
|
435
|
+
// duplicate rather than as state, so the glyph rides the existing label instead.
|
|
436
|
+
const label = !token ? entry.label
|
|
437
|
+
: entry.label === token ? `${token}${glyph}`
|
|
438
|
+
: `${entry.label} · ${token}${glyph}`;
|
|
385
439
|
const cmd = `tab rename ${shq(entry.tabId)} ${shq(label)}`;
|
|
386
440
|
const ok = async () => (await this.herdr(cmd)).code === 0;
|
|
387
441
|
if (await ok() || await ok())
|
|
@@ -450,22 +504,22 @@ export class HerdrDriver {
|
|
|
450
504
|
await this.renameGroupTab(entry);
|
|
451
505
|
return { id: pane, name, cwd, tabId: entry.tabId, group };
|
|
452
506
|
}
|
|
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.
|
|
507
|
+
// v1.85 T5 (OBS-140/253): every dispatch is routed by what the target actually IS. A shell or
|
|
508
|
+
// bootstrap line goes out atomically through `pane run` and is acknowledged by its own START
|
|
509
|
+
// nonce; only a real TUI turn — an adapter-declared input box, and not that adapter's own launch
|
|
510
|
+
// command — earns the typed pincer. One dispatch corruption is then retried in-process against a
|
|
511
|
+
// FRESH pane before anything is reported as a failure.
|
|
461
512
|
async run(slot, cmd) {
|
|
513
|
+
// paneLaunchCommand is a linear same-call handoff: consume before the first await so concurrent
|
|
514
|
+
// task dispatches cannot exchange intent. The command remains an ordinary string for every
|
|
515
|
+
// ExecutorDriver implementation; classification never reads its bytes when the builder spoke.
|
|
516
|
+
const recordedLaunch = consumePaneLaunchIntent();
|
|
462
517
|
const lease = this.dispatchLeases.get(slot);
|
|
463
518
|
if (lease) {
|
|
464
519
|
this.dispatchLeases.delete(slot);
|
|
465
520
|
try {
|
|
466
521
|
const paneId = await this.verifyPaneIdentityBinding(slot);
|
|
467
|
-
await this.
|
|
468
|
-
this.deliveredPanes.set(slot, paneId);
|
|
522
|
+
await this.dispatchWithRetry(slot, cmd, paneId, recordedLaunch);
|
|
469
523
|
}
|
|
470
524
|
finally {
|
|
471
525
|
lease.release();
|
|
@@ -473,13 +527,200 @@ export class HerdrDriver {
|
|
|
473
527
|
return;
|
|
474
528
|
}
|
|
475
529
|
return this.deliveryQueue(async () => {
|
|
476
|
-
|
|
477
|
-
await this.deliver(slot, cmd, paneId);
|
|
478
|
-
this.deliveredPanes.set(slot, paneId);
|
|
530
|
+
await this.dispatchWithRetry(slot, cmd, await this.paneId(slot), recordedLaunch);
|
|
479
531
|
});
|
|
480
532
|
}
|
|
481
|
-
|
|
482
|
-
|
|
533
|
+
// OBS-253: dispatch corruption is self-clearing — all ten recorded occurrences cleared on a fresh
|
|
534
|
+
// pane and a fresh submit, and every one of them cost an operator `resume --retry-failed`. Retry
|
|
535
|
+
// ONCE here against a pane that has never seen this delivery; the wedged pane is closed, never
|
|
536
|
+
// re-pressed. A second consecutive corruption propagates, and the daemon parks the task on it.
|
|
537
|
+
async dispatchWithRetry(slot, cmd, pane, recordedLaunch = false) {
|
|
538
|
+
try {
|
|
539
|
+
await this.dispatch(slot, cmd, pane, recordedLaunch);
|
|
540
|
+
this.deliveredPanes.set(slot, pane);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
if (!(error instanceof DeliveryCorruptedError))
|
|
545
|
+
throw error;
|
|
546
|
+
// A fresh pane is a bare shell. A shell dispatch is simply re-run on it; a typed turn first
|
|
547
|
+
// needs its interface back, which is possible only where the adapter declared the bootstrap
|
|
548
|
+
// this slot already delivered. Retyping a turn into a bare shell would guarantee the second
|
|
549
|
+
// failure rather than recover from the first, so without a replayable bootstrap the
|
|
550
|
+
// corruption propagates untouched.
|
|
551
|
+
const typedTurn = this.isTuiTurn(slot, cmd, recordedLaunch);
|
|
552
|
+
const relaunch = typedTurn ? this.bootstraps.get(slot) : undefined;
|
|
553
|
+
if (typedTurn && relaunch === undefined)
|
|
554
|
+
throw error;
|
|
555
|
+
// Record BEFORE acting: a retry that cannot be written to the run's ledger is not taken at all,
|
|
556
|
+
// because an unrecorded pane swap is exactly the silence OBS-253 cost ten times. The journal
|
|
557
|
+
// failure itself propagates visibly; the original corruption then remains terminal rather than
|
|
558
|
+
// triggering an off-record recovery.
|
|
559
|
+
this.appendDispatchRetry(slot, { wedgedPane: pane, reason: error.message });
|
|
560
|
+
const fresh = await this.freshPane(slot, pane);
|
|
561
|
+
if (fresh === null)
|
|
562
|
+
throw error;
|
|
563
|
+
if (relaunch !== undefined)
|
|
564
|
+
await this.deliverAtomic(slot, relaunch, fresh);
|
|
565
|
+
await this.dispatch(slot, cmd, fresh, recordedLaunch);
|
|
566
|
+
this.deliveredPanes.set(slot, fresh);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// Is this delivery a turn TYPED into a running interface, rather than a line a shell runs? The
|
|
570
|
+
// The builder's recorded fact decides first, regardless of command bytes. For direct driver users,
|
|
571
|
+
// an adapter may declare the fresh-slot lifecycle rather than a prefix list. Legacy declarations
|
|
572
|
+
// still decide where neither stronger fact exists. With NO declaration the only evidence is history — a
|
|
573
|
+
// pane that already accepted a delivery is running whatever that delivery started, so a second
|
|
574
|
+
// delivery is a turn into it. Answering "shell" there is what would run an adapter's seed prompt
|
|
575
|
+
// as a shell command; answering "turn" routes it to deliverTyped, which refuses it by name
|
|
576
|
+
// because the adapter declared no input box (fail closed, never a guess).
|
|
577
|
+
isTuiTurn(slot, cmd, recordedLaunch = false) {
|
|
578
|
+
if (recordedLaunch)
|
|
579
|
+
return false;
|
|
580
|
+
const inputBox = this.inputBoxes.get(slot);
|
|
581
|
+
if (inputBox?.firstDeliveryIsLaunch && !this.deliveredPanes.has(slot))
|
|
582
|
+
return false;
|
|
583
|
+
return inputBox ? inputBox.launchCommand?.(cmd) !== true : this.deliveredPanes.has(slot);
|
|
584
|
+
}
|
|
585
|
+
dispatch(slot, cmd, pane, recordedLaunch = false) {
|
|
586
|
+
if (this.isTuiTurn(slot, cmd, recordedLaunch))
|
|
587
|
+
return this.deliverTyped(slot, cmd, pane);
|
|
588
|
+
// A classified bootstrap is the one command that can restore this slot's interface on a fresh
|
|
589
|
+
// pane, so remember the fact's command without asking its bytes to prove the classification again.
|
|
590
|
+
if (this.inputBoxes.has(slot))
|
|
591
|
+
this.bootstraps.set(slot, cmd);
|
|
592
|
+
return this.deliverAtomic(slot, cmd, pane);
|
|
593
|
+
}
|
|
594
|
+
// The OBS-140 class dies here. `pane run` puts text and Enter in ONE herdr request, so there is no
|
|
595
|
+
// seam in which a swallowed Enter can leave a verified-but-unsubmitted prompt, and nothing about
|
|
596
|
+
// the delivery is inferred from typed-text read-back. The line's first two statements print a
|
|
597
|
+
// per-dispatch START nonce assembled from two printf arguments — once to a file whose name only
|
|
598
|
+
// this process knows, then once to the pane for the operator. The joined marker cannot exist
|
|
599
|
+
// unless the shell RAN our line, so the acknowledgment is causal; and because the file is written
|
|
600
|
+
// before the agent the line launches produces a byte, no amount of later output can hide it. Both
|
|
601
|
+
// statements are shell builtins: the ack cannot fail for want of a binary on the pane's PATH.
|
|
602
|
+
//
|
|
603
|
+
// v1.85 T5 review: the durable half GATES the launch, and goes FIRST. A dispatch whose ack the
|
|
604
|
+
// pane could not write is indistinguishable, from here, from a line that never ran — so if the
|
|
605
|
+
// command ran anyway, the miss buys a fresh pane and a SECOND live agent for the same task. The
|
|
606
|
+
// `|| exit 1` makes that impossible: no ack, no command. Ordering carries the same weight in the
|
|
607
|
+
// other direction — a pane-visible marker printed before a failed ack write would satisfy the
|
|
608
|
+
// event watch and report a launch that never happened.
|
|
609
|
+
async deliverAtomic(slot, cmd, pane) {
|
|
610
|
+
const suffix = randomUUID(); // unguessable: nothing the delivered command runs can forge the ack
|
|
611
|
+
const nonce = `${DISPATCH_START_PREFIX}${suffix}`;
|
|
612
|
+
const ackPath = join(tmpdir(), `tickmarkr-dispatch-${suffix}.ack`);
|
|
613
|
+
// Open the channel before spending a pane on it: an ack path this process cannot write is a
|
|
614
|
+
// broken host, not a wedged pane, and the fresh-pane retry would fail there for the same reason.
|
|
615
|
+
// Throwing something other than DeliveryCorruptedError is what keeps that retry unspent.
|
|
616
|
+
try {
|
|
617
|
+
writeFileSync(ackPath, ""); // empty: only the delivered line may ever put the nonce here
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
throw new Error(`dispatch acknowledgment channel unavailable (${ackPath}): ${error.message}`);
|
|
621
|
+
}
|
|
622
|
+
const marker = `printf '%s%s\\n' ${shq(DISPATCH_START_PREFIX)} ${shq(suffix)}`;
|
|
623
|
+
const line = `${marker} > ${shq(ackPath)} || exit 1; ${marker}; ${cmd}`;
|
|
624
|
+
const deadline = this.time.now() + DISPATCH_ACK_TIMEOUT_MS;
|
|
625
|
+
try {
|
|
626
|
+
const sent = await this.herdr(`pane run ${shq(pane)} ${shq(line)}`, slot.cwd);
|
|
627
|
+
if (sent.code !== 0) {
|
|
628
|
+
throw new DeliveryCorruptedError(pane, `pane run failed: ${sent.stderr || sent.stdout}`, "");
|
|
629
|
+
}
|
|
630
|
+
// Fast path: herdr's own event-driven watch on the pane's output. A match is causal — the pane
|
|
631
|
+
// cannot emit the joined marker without having RUN the line. A MISS is not evidence of
|
|
632
|
+
// anything: this watch subscribes after the request, and a full-screen repaint can swallow the
|
|
633
|
+
// very line it is watching for, which is how a successful launch got dispatched twice. So a
|
|
634
|
+
// miss falls through to the channel the line wrote for itself, which nothing can repaint.
|
|
635
|
+
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);
|
|
636
|
+
if (this.waitOk(seen.code, seen.stdout))
|
|
637
|
+
return;
|
|
638
|
+
if (await this.awaitDispatchAck(ackPath, nonce, deadline))
|
|
639
|
+
return;
|
|
640
|
+
// Only now, and only as evidence for the operator: the transcript explains the failure, it
|
|
641
|
+
// never decides one. A pane that looks like it ran the line but never wrote the ack did not.
|
|
642
|
+
const transcript = (await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, slot.cwd)).stdout;
|
|
643
|
+
throw new DeliveryCorruptedError(pane, `START nonce ${nonce} never appeared — the line never ran`, transcript);
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
try {
|
|
647
|
+
unlinkSync(ackPath);
|
|
648
|
+
}
|
|
649
|
+
catch { /* never written, or already reaped — nothing to clean */ }
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// The durable half of the acknowledgment: the file the delivered line wrote before it launched
|
|
653
|
+
// anything. Checked at least once even when the shared dispatch deadline has already passed, so a
|
|
654
|
+
// watch that spent the whole window missing the line still gets the truth from the line itself.
|
|
655
|
+
async awaitDispatchAck(ackPath, nonce, deadline) {
|
|
656
|
+
for (;;) {
|
|
657
|
+
try {
|
|
658
|
+
if (readFileSync(ackPath, "utf8").includes(nonce))
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
/* the line has not reached the marker yet — or never will, which the deadline decides */
|
|
663
|
+
}
|
|
664
|
+
if (this.time.now() >= deadline)
|
|
665
|
+
return false;
|
|
666
|
+
await this.time.sleep(DISPATCH_ACK_POLL_MS);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// OBS-253: a fresh pane is a SIBLING inside this slot's own tab. Split first so the tab can never
|
|
670
|
+
// empty, then close the wedged pane and take its durable label — every other driver call addresses
|
|
671
|
+
// this slot through that label or the delivered-pane pin, so no caller observes the swap.
|
|
672
|
+
async freshPane(slot, wedged) {
|
|
673
|
+
if (!this.ws)
|
|
674
|
+
return null;
|
|
675
|
+
const sp = await this.herdr(`pane split ${shq(wedged)} --direction down --no-focus --cwd ${shq(slot.cwd)}`);
|
|
676
|
+
if (sp.code !== 0)
|
|
677
|
+
return null;
|
|
678
|
+
let pane;
|
|
679
|
+
try {
|
|
680
|
+
pane = JSON.parse(sp.stdout).result?.pane?.pane_id ?? undefined;
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
if (typeof pane !== "string" || !pane)
|
|
686
|
+
return null;
|
|
687
|
+
// The wedged pane is never re-pressed, only reaped — and the reap is a POSTCONDITION, not a
|
|
688
|
+
// best-effort. It still carries this slot's durable label, so a close that fails (or succeeds
|
|
689
|
+
// and frees nothing) leaves two panes answering to one name and `pane list` resolving it by
|
|
690
|
+
// whichever comes first. Verify the name is free BEFORE the replacement is renamed, seeded or
|
|
691
|
+
// dispatched: a fresh pane that is only PROBABLY the slot is not a fresh pane (v1.85 T5 review).
|
|
692
|
+
const closed = await this.herdr(`pane close ${shq(wedged)}`);
|
|
693
|
+
if (closed.code !== 0 || (await this.staleLabelPanes(slot.name, pane)).length > 0) {
|
|
694
|
+
await this.herdr(`pane close ${shq(pane)}`);
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
const rn = await this.herdr(`pane rename ${shq(pane)} ${shq(slot.name)}`);
|
|
698
|
+
if (rn.code !== 0 || (await this.namedPaneId(slot.name)) !== pane) {
|
|
699
|
+
await this.herdr(`pane close ${shq(pane)}`);
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
// VIS-10 hole 3: a split pane is a bare shell with FRESH env, so it is untargeted until seeded.
|
|
703
|
+
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);
|
|
704
|
+
if (seed.code !== 0) {
|
|
705
|
+
await this.herdr(`pane close ${shq(pane)}`);
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
return pane;
|
|
709
|
+
}
|
|
710
|
+
// OBS-85 typed delivery, now reserved for real TUI turns: type WITHOUT enter, read the pane back
|
|
711
|
+
// — `wait output --match` checks the same unwrapped transcript pane read exposes, event-driven so
|
|
712
|
+
// wrap and render timing can't race the check — and press Enter only when that read-back contains
|
|
713
|
+
// the typed command. A corrupted paste is captured (pane read), cleared (C-u), and retyped,
|
|
714
|
+
// bounded; persistent corruption fails closed WITH the captured transcript.
|
|
715
|
+
async deliverTyped(slot, cmd, pane) {
|
|
716
|
+
const inputBox = this.inputBoxes.get(slot);
|
|
717
|
+
const missing = missingInputStateDeclarations(inputBox);
|
|
718
|
+
if (missing.length > 0) {
|
|
719
|
+
throw new Error(`herdr refuses typed delivery to ${slot.name}: the adapter has not declared its input states `
|
|
720
|
+
+ `(missing: ${missing.join(", ")}) — only a declared box can acknowledge a submission, and `
|
|
721
|
+
+ `nothing else may stand in for it (OBS-140)`);
|
|
722
|
+
}
|
|
723
|
+
const readiness = await this.awaitDeliveryReadiness(slot, cmd, pane, inputBox);
|
|
483
724
|
let transcript = "";
|
|
484
725
|
for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
|
|
485
726
|
if (attempt > 0) {
|
|
@@ -487,7 +728,7 @@ export class HerdrDriver {
|
|
|
487
728
|
// line only after two consecutive pane reads agree; an already-stable frame returns on the
|
|
488
729
|
// first fresh read without a timer. A changing pane is bounded and preserves OBS-85's
|
|
489
730
|
// fail-closed error instead of guessing from an adapter fingerprint.
|
|
490
|
-
const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript,
|
|
731
|
+
const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox);
|
|
491
732
|
transcript = settled.transcript;
|
|
492
733
|
if (!settled.ok) {
|
|
493
734
|
throw new Error(`herdr delivery clear failed — refusing to retype onto a corrupted line (OBS-85); pane transcript:\n${transcript}`);
|
|
@@ -506,24 +747,14 @@ export class HerdrDriver {
|
|
|
506
747
|
throw new Error(`herdr pane send-text failed: ${typed.stderr || typed.stdout}`);
|
|
507
748
|
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
749
|
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
|
-
}
|
|
750
|
+
await this.submitVerifiedDelivery(slot, pane, inputBox, readiness);
|
|
517
751
|
return;
|
|
518
752
|
}
|
|
519
753
|
// capture the corrupted delivery BEFORE clearing it — the OBS-85 byte-level evidence
|
|
520
754
|
transcript = (await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, slot.cwd)).stdout;
|
|
521
755
|
}
|
|
522
|
-
throw new
|
|
756
|
+
throw new DeliveryCorruptedError(pane, `${DELIVERY_ATTEMPTS} typed attempts — enter never pressed (OBS-85)`, transcript);
|
|
523
757
|
}
|
|
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
758
|
// OBS-201: liveness nudge — deliver one message into the live worker TUI through the exact
|
|
528
759
|
// pincer every dispatch uses (readiness stable-frame, type-without-Enter, read-back, C-u clear
|
|
529
760
|
// on corruption, verified submit), serialized on the deliveryQueue so it can never interleave
|
|
@@ -536,23 +767,24 @@ export class HerdrDriver {
|
|
|
536
767
|
if (!pinned)
|
|
537
768
|
return false;
|
|
538
769
|
try {
|
|
539
|
-
await this.deliveryQueue(() => this.
|
|
770
|
+
await this.deliveryQueue(() => this.deliverTyped(slot, message, pinned));
|
|
540
771
|
return true;
|
|
541
772
|
}
|
|
542
773
|
catch {
|
|
543
774
|
return false; // the daemon journals worker-nudge-failed and falls back to the stall window
|
|
544
775
|
}
|
|
545
776
|
}
|
|
777
|
+
// The narrator launches a perpetual shell watch, not an adapter-backed input interface — a shell
|
|
778
|
+
// dispatch like any other, delivered atomically and acknowledged by its own START nonce.
|
|
546
779
|
async deliverPersistentShellCommand(slot, cmd) {
|
|
547
780
|
return this.deliveryQueue(async () => {
|
|
548
781
|
const pane = await this.paneId(slot);
|
|
549
|
-
await this.
|
|
782
|
+
await this.deliverAtomic(slot, cmd, pane);
|
|
550
783
|
this.deliveredPanes.set(slot, pane);
|
|
551
784
|
});
|
|
552
785
|
}
|
|
553
|
-
async submitVerifiedDelivery(slot,
|
|
786
|
+
async submitVerifiedDelivery(slot, pane, inputBox, readiness) {
|
|
554
787
|
let transcript = "";
|
|
555
|
-
const inputBox = this.inputBoxes.get(slot);
|
|
556
788
|
// The base window preserves OBS-140's bounded behavior. A slow readiness observation grants
|
|
557
789
|
// the same measured time once more for submit paint, capped by the readiness bound itself.
|
|
558
790
|
const baseVerifyMs = (DELIVERY_SETTLE_READ_ATTEMPTS - 1) * DELIVERY_SETTLE_POLL_MS;
|
|
@@ -564,15 +796,15 @@ export class HerdrDriver {
|
|
|
564
796
|
// Reuse the existing settle-read window. A first-read success returns before any timer; only
|
|
565
797
|
// a prompt that still occupies the delivery target spends the bounded settle window. This
|
|
566
798
|
// 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,
|
|
799
|
+
const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox, (candidate) => this.submissionRegistered(candidate, inputBox), verifyWindowMs);
|
|
568
800
|
transcript = settled.transcript;
|
|
569
801
|
if (settled.ok)
|
|
570
802
|
return;
|
|
571
803
|
if (settled.readFailed) {
|
|
572
|
-
throw new
|
|
804
|
+
throw new DeliveryCorruptedError(pane, "submission verification read failed, refusing to re-press Enter (OBS-140)", transcript);
|
|
573
805
|
}
|
|
574
806
|
}
|
|
575
|
-
throw new
|
|
807
|
+
throw new DeliveryCorruptedError(pane, `${DELIVERY_SUBMIT_ATTEMPTS} submit attempts — submission never registered (OBS-140)`, transcript);
|
|
576
808
|
}
|
|
577
809
|
async settleDeliveryLine(pane, cwd, initialTranscript, inputBox, accept, settleWindowMs) {
|
|
578
810
|
let transcript = initialTranscript;
|
|
@@ -604,10 +836,11 @@ export class HerdrDriver {
|
|
|
604
836
|
}
|
|
605
837
|
return { ok: false, transcript, recognizedInputBox: false };
|
|
606
838
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
839
|
+
// Readiness now serves typed delivery only, so the target is always the adapter's declared box:
|
|
840
|
+
// the "no declared box, so assume ready when the pane doesn't already show our command" branch is
|
|
841
|
+
// gone with the rest of the typed-text inference (v1.85 T5).
|
|
842
|
+
async awaitDeliveryReadiness(slot, cmd, pane, inputBox) {
|
|
843
|
+
const timeoutMs = inputBox.readinessTimeoutMs ?? DELIVERY_READINESS_TIMEOUT_MS;
|
|
611
844
|
const started = this.time.now();
|
|
612
845
|
let previous;
|
|
613
846
|
let transcript = "";
|
|
@@ -635,11 +868,9 @@ export class HerdrDriver {
|
|
|
635
868
|
}
|
|
636
869
|
if (previous !== undefined) {
|
|
637
870
|
const stableFrame = read.stdout === previous;
|
|
638
|
-
|
|
639
|
-
? matchesInputBox(previous, inputBox) && matchesInputBox(read.stdout, inputBox)
|
|
640
|
-
: !this.deliveryMatches(read.stdout, cmd));
|
|
641
|
-
if (targetReady)
|
|
871
|
+
if (stableFrame && matchesInputBox(previous, inputBox) && matchesInputBox(read.stdout, inputBox)) {
|
|
642
872
|
return { waitedMs, timeoutMs, transcript: read.stdout };
|
|
873
|
+
}
|
|
643
874
|
}
|
|
644
875
|
previous = read.stdout;
|
|
645
876
|
const remaining = timeoutMs - waitedMs;
|
|
@@ -888,7 +1119,10 @@ export class HerdrDriver {
|
|
|
888
1119
|
/* cosmetic — visibility hygiene never fails the run */
|
|
889
1120
|
}
|
|
890
1121
|
}
|
|
891
|
-
worktree(repo, branch, baseRef) {
|
|
892
|
-
|
|
1122
|
+
async worktree(repo, branch, baseRef) {
|
|
1123
|
+
const worktree = await createWorktree(repo, branch, baseRef);
|
|
1124
|
+
this.journalRoots.set(repo, repo);
|
|
1125
|
+
this.journalRoots.set(worktree, repo);
|
|
1126
|
+
return worktree;
|
|
893
1127
|
}
|
|
894
1128
|
}
|