@bakers-build/addon-herdr 0.0.0-stage → 0.1.0-next.38

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.
@@ -0,0 +1,809 @@
1
+ import { existsSync, rmSync } from 'node:fs';
2
+ import { exec, shellQuote, slugify } from '@bakers-build/core';
3
+ import { renderTemplate, resolveStateConfig, } from './config.js';
4
+ import { forceRemoveDir, restoreWritable } from './fs.js';
5
+ import { watchLaunch, writeLaunchScript, } from './launch-script.js';
6
+ import { applyPaneLayout, isRecord, parseJson, } from './pane-layout.js';
7
+ import { AGENT_SHELL_ENV, envArgs } from './panes.js';
8
+ import { deriveHerdrRoot } from './root-anchor.js';
9
+ import { parseWorkspaceList } from './workspace-list.js';
10
+ export { herdrAgentHost } from './agents.js';
11
+ export { envArgs, findTabIdByLabel, parsePaneInfo, parsePanesInTab, parseTabCreate, } from './panes.js';
12
+ // BB-139: the herdr workspace lives in the same package now (the separate
13
+ // @bakers-build/addon-herdr-workspace was merged in). The package DEFAULT export
14
+ // stays the executor (below); herdrWorkspace + HerdrWorkspace are named exports.
15
+ export { herdrProcessHost } from './process-host.js';
16
+ export { HerdrWorkspace, herdrWorkspace, } from './workspace.js';
17
+ export { parseWorkspaceList, } from './workspace-list.js';
18
+ async function defaultExec(args, opts) {
19
+ return exec('herdr', args, opts);
20
+ }
21
+ /** Default git runner: real `git` in the given cwd (the parent repo root). */
22
+ const defaultGitRunner = (args, cwd) => exec('git', args, { cwd });
23
+ /**
24
+ * Parse `git worktree list --porcelain`. Records are blank-line separated; each
25
+ * has a `worktree <path>` line and, for an attached HEAD, a `branch
26
+ * refs/heads/<branch>` line (a detached HEAD has none). `prunable <reason>`
27
+ * entries (a `.git/worktrees/<name>` admin entry whose dir has vanished) still
28
+ * carry their `worktree <path>` line, so they parse too — the caller decides
29
+ * what to do with a path no longer on disk.
30
+ */
31
+ function parseGitWorktreePorcelain(output) {
32
+ const entries = [];
33
+ let path = null;
34
+ let branch = null;
35
+ const flush = () => {
36
+ if (path) {
37
+ entries.push({ path, branch });
38
+ }
39
+ path = null;
40
+ branch = null;
41
+ };
42
+ for (const line of output.split('\n')) {
43
+ if (line.startsWith('worktree ')) {
44
+ flush(); // a new record begins — commit the previous one
45
+ path = line.slice('worktree '.length).trim();
46
+ }
47
+ else if (line.startsWith('branch ')) {
48
+ branch = line
49
+ .slice('branch '.length)
50
+ .trim()
51
+ .replace(/^refs\/heads\//, '');
52
+ }
53
+ }
54
+ flush();
55
+ return entries;
56
+ }
57
+ /** Default hook runner: `sh -c <command>` in the worktree — fails honestly. */
58
+ const defaultShellRunner = async (command, cwd, env) => {
59
+ await exec('sh', ['-c', command], { cwd, ...(env ? { env } : {}) });
60
+ };
61
+ const noopLogger = {
62
+ debug() { },
63
+ info() { },
64
+ warn() { },
65
+ error() { },
66
+ };
67
+ function parseWorktreeList(output) {
68
+ const parsed = parseJson(output, 'worktree list');
69
+ const worktrees = isRecord(parsed)
70
+ ? parsed.result?.worktrees
71
+ : undefined;
72
+ if (!Array.isArray(worktrees)) {
73
+ return [];
74
+ }
75
+ // Key on `path` only — herdr reports `branch: null` for a detached HEAD, and
76
+ // the branch is mutable anyway (see resolveWorktreeEntry). Dropping such
77
+ // entries is exactly the BB-114 defect: the worktree becomes invisible to
78
+ // a path lookup.
79
+ return worktrees
80
+ .filter(isRecord)
81
+ .filter((w) => typeof w.path === 'string')
82
+ .map((w) => ({
83
+ branch: typeof w.branch === 'string' ? w.branch : null,
84
+ path: w.path,
85
+ ...(typeof w.open_workspace_id === 'string' && w.open_workspace_id
86
+ ? { open_workspace_id: w.open_workspace_id }
87
+ : {}),
88
+ }));
89
+ }
90
+ /**
91
+ * Extract the open workspace id from `herdr worktree open`'s own JSON. herdr
92
+ * returns it in the result envelope under any of these keys (herdr 0.7.x):
93
+ * `workspace.workspace_id`, `worktree.open_workspace_id`, or
94
+ * `root_pane.workspace_id`. Returns null when the payload carries none (e.g. an
95
+ * error envelope), so the caller can fall back to a list lookup or surface the
96
+ * failure. This is the reliable success path — no branch match involved.
97
+ */
98
+ function parseWorkspaceOpen(output) {
99
+ const parsed = parseJson(output, 'worktree open');
100
+ const result = isRecord(parsed)
101
+ ? parsed.result
102
+ : undefined;
103
+ const candidates = [
104
+ result?.workspace?.workspace_id,
105
+ result?.worktree?.open_workspace_id,
106
+ result?.root_pane?.workspace_id,
107
+ ];
108
+ for (const c of candidates) {
109
+ if (typeof c === 'string' && c) {
110
+ return c;
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+ function parseTabList(output) {
116
+ const parsed = parseJson(output, 'tab list');
117
+ const tabs = isRecord(parsed)
118
+ ? parsed.result?.tabs
119
+ : undefined;
120
+ if (!Array.isArray(tabs)) {
121
+ return [];
122
+ }
123
+ return tabs
124
+ .filter(isRecord)
125
+ .filter((t) => typeof t.tab_id === 'string' && typeof t.label === 'string')
126
+ .map((t) => ({
127
+ tabId: t.tab_id,
128
+ label: t.label,
129
+ }));
130
+ }
131
+ /**
132
+ * Whether a tab label carries the exact `#<runId>` token that `startRun` emits
133
+ * (`<identifier> · <state> #<run.id>`). Anchored on the trailing token and
134
+ * compared numerically, so `#4339` never matches `#43390` / `#433` (BB-183
135
+ * AC-3). A label with no trailing `#<digits>` token (a non-run tab) never
136
+ * matches.
137
+ */
138
+ function labelMatchesRun(label, runId) {
139
+ const m = label.match(/#(\d+)$/);
140
+ return m !== null && Number(m[1]) === runId;
141
+ }
142
+ function parseTabCreate(output) {
143
+ const parsed = parseJson(output, 'tab create');
144
+ const result = isRecord(parsed)
145
+ ? parsed.result
146
+ : undefined;
147
+ const paneId = result?.root_pane?.pane_id;
148
+ const tabId = result?.tab?.tab_id;
149
+ if (typeof paneId !== 'string' || typeof tabId !== 'string') {
150
+ throw new Error('herdr tab create returned invalid schema');
151
+ }
152
+ return { paneId, tabId };
153
+ }
154
+ /** Normalise a worktree path for comparison (strip trailing slashes). */
155
+ function normPath(path) {
156
+ return path.replace(/\/+$/, '');
157
+ }
158
+ /**
159
+ * Resolve a worktree entry, preferring the stable `worktreePath` over `branch`.
160
+ * The checked-out branch is mutable (the coding agent may rename/switch it), so
161
+ * the path — deterministic and identifier-derived — is the reliable key. Fall
162
+ * back to a branch match so a missing/renamed path still resolves. Returns null
163
+ * only when neither key matches any listed worktree.
164
+ */
165
+ function resolveWorktreeEntry(entries, branch, worktreePath) {
166
+ if (worktreePath) {
167
+ const want = normPath(worktreePath);
168
+ const byPath = entries.find((e) => normPath(e.path) === want);
169
+ if (byPath) {
170
+ return byPath;
171
+ }
172
+ }
173
+ return entries.find((e) => e.branch === branch) ?? null;
174
+ }
175
+ export class HerdrExecutor {
176
+ options;
177
+ execHerdr;
178
+ logger;
179
+ runShell;
180
+ runGit;
181
+ id = 'herdr';
182
+ constructor(options, execHerdr = defaultExec, logger = noopLogger, runShell = defaultShellRunner, runGit = defaultGitRunner) {
183
+ this.options = options;
184
+ this.execHerdr = execHerdr;
185
+ this.logger = logger;
186
+ this.runShell = runShell;
187
+ this.runGit = runGit;
188
+ }
189
+ capabilities() {
190
+ return { persistent: true };
191
+ }
192
+ /**
193
+ * Run the lifecycle hook `name` best-effort — the single catch point for all
194
+ * lifecycle hooks (see {@link BbExecutor.runHook}). No configured command →
195
+ * silent no-op. A failing command → log loudly (hook + worktree + ticket +
196
+ * err) and return. NEVER throws, so a hook failure can't abort dispatch or
197
+ * wedge a run.
198
+ *
199
+ * BB-237: it also RETURNS what happened ({@link HookResult}). Not throwing
200
+ * kept a hook from wedging a run, but swallowing the outcome entirely left the
201
+ * caller unable to tell a provisioned worktree from a broken one — so an agent
202
+ * was dispatched into a worktree whose bootstrap had aborted, with nothing but
203
+ * a log line (which, on a TTY, went nowhere) to say so.
204
+ */
205
+ async runHook(name, cwd, ctx, env) {
206
+ const command = this.options.hooks?.[name];
207
+ if (!command) {
208
+ return { hook: name, ran: false, ok: true };
209
+ }
210
+ try {
211
+ await this.runShell(command, cwd, env);
212
+ return { hook: name, ran: true, ok: true };
213
+ }
214
+ catch (err) {
215
+ this.logger.error({ hook: name, worktree: cwd, ticket: ctx.ticket, err: String(err) }, 'lifecycle hook failed');
216
+ return { hook: name, ran: true, ok: false, error: String(err) };
217
+ }
218
+ }
219
+ /**
220
+ * List worktrees, ALWAYS anchored to the configured parent repo root
221
+ * (BB-211). `herdr worktree list` is machine-global, but run WITHOUT
222
+ * `--cwd` it resolves herdr's own ambient (previously selected / persisted /
223
+ * process / shell / daemon) repository context. When a preceding
224
+ * `bakers-cleanup-*` integration test selected then deleted a
225
+ * `/tmp/bakers-cleanup-*` repo, that stale context survives and the unanchored
226
+ * list exits (`cannot change to '/tmp/bakers-cleanup-…'`), aborting dispatch.
227
+ * Anchoring every list with `--cwd this.options.root` makes listing
228
+ * independent of that ambient context. This is the SINGLE choke point for
229
+ * all executor `worktree list` call paths — the sibling
230
+ * {@link HerdrWorkspace.ensure} anchors identically. The `--cwd` is only
231
+ * omitted when no root is configured (rootless test callers).
232
+ *
233
+ * On a herdr failure it throws an error naming the explicit configured root
234
+ * and the operation (AC-9) instead of surfacing herdr's raw stale-context
235
+ * message, so a future context failure is diagnosable.
236
+ */
237
+ async listWorktrees() {
238
+ const args = [
239
+ 'worktree',
240
+ 'list',
241
+ ...(this.options.root ? ['--cwd', this.options.root] : []),
242
+ '--json',
243
+ ];
244
+ let output;
245
+ try {
246
+ output = await this.execHerdr(args);
247
+ }
248
+ catch (err) {
249
+ throw new Error(`herdr worktree list failed for configured root ${this.options.root ?? '<unset>'}: ${String(err)}`);
250
+ }
251
+ return parseWorktreeList(output);
252
+ }
253
+ /**
254
+ * Find the worktree entry (path + open_workspace_id) for a branch via
255
+ * worktree list. Returns null if the branch has no worktree.
256
+ */
257
+ async findWorktreeByBranch(branch) {
258
+ const entries = await this.listWorktrees();
259
+ return entries.find((e) => e.branch === branch) ?? null;
260
+ }
261
+ /**
262
+ * Find the open_workspace_id for a branch via worktree list.
263
+ * Returns null if no workspace is open for that branch.
264
+ */
265
+ async findWorkspaceByBranch(branch) {
266
+ const entry = await this.findWorktreeByBranch(branch);
267
+ return entry?.open_workspace_id ?? null;
268
+ }
269
+ /**
270
+ * Find the open_workspace_id for a worktree by its stable `worktreePath`
271
+ * (falling back to `branch`; see {@link resolveWorktreeEntry}). Unlike
272
+ * {@link findWorkspaceByBranch}, this does not rely on the mutable branch —
273
+ * it is the startRun lookup the BB-114 fix hangs on. Returns null when the
274
+ * worktree is not listed as open.
275
+ */
276
+ async findOpenWorkspace(branch, worktreePath) {
277
+ const entries = await this.listWorktrees();
278
+ return (resolveWorktreeEntry(entries, branch, worktreePath)?.open_workspace_id ??
279
+ null);
280
+ }
281
+ /** Parent repo root for git worktree admin ops (falls back to `fallback`). */
282
+ gitRoot(fallback) {
283
+ return this.options.root ?? fallback;
284
+ }
285
+ /**
286
+ * Sweep orphaned `.git/worktrees/<name>` admin entries at the parent repo
287
+ * root — the ones whose on-disk dir has vanished (BB-141: they show up as
288
+ * `prunable` in `git worktree list`). Best-effort: a prune failure must never
289
+ * fail teardown.
290
+ */
291
+ async pruneWorktrees(root) {
292
+ try {
293
+ await this.runGit(['worktree', 'prune'], root);
294
+ }
295
+ catch {
296
+ // best-effort — pruning is a sweep, never the teardown's success gate
297
+ }
298
+ }
299
+ /**
300
+ * Remove an on-disk worktree GIT-AWARELY (BB-141 RC-1). The old fallback
301
+ * raw-`rm`'d the directory and left the parent repo's `.git/worktrees/<name>`
302
+ * admin entry behind → a `prunable` orphan. This:
303
+ * 1. restores owner-write (Drupal hardens web/sites/default 0555 +
304
+ * settings.php 0444 — chmod-restorable by the owning user, no sudo);
305
+ * 2. `git worktree remove --force <path>` from the PARENT repo root, which
306
+ * unlinks the directory AND drops the `.git/worktrees/<name>` entry —
307
+ * but ONLY when the path really is a registered worktree (BB-293):
308
+ * against an unregistered path git exits 128 and the shared exec helper
309
+ * logs `ERROR: exec failed` for a condition this code expects and
310
+ * handles. The guard lives here rather than at the caller because this
311
+ * method has a second call site (the path resolved via
312
+ * {@link findGitWorktree});
313
+ * 3. guarantees the directory is gone (a raw force-remove) in case git
314
+ * refused or was skipped — a no-op if git already removed it;
315
+ * 4. prunes the repo so any now-stale admin entry is swept.
316
+ *
317
+ * Returns whether the directory is gone afterwards. A reclaim that cannot
318
+ * remove it REPORTS instead of throwing (BB-293, spec D5): the raw EACCES
319
+ * used to propagate all the way out of {@link removeWorktree} and reach the
320
+ * reaper as an opaque exception.
321
+ */
322
+ async gitAwareRemove(path, registered) {
323
+ restoreWritable(path);
324
+ const root = this.gitRoot(path);
325
+ if (registered) {
326
+ try {
327
+ await this.runGit(['worktree', 'remove', '--force', path], root);
328
+ }
329
+ catch (err) {
330
+ // Registered yet refused — genuinely unexpected. Say so, then reclaim
331
+ // the directory directly below.
332
+ this.logger.warn({ path, err: String(err) }, 'git worktree remove failed on a registered worktree');
333
+ }
334
+ }
335
+ if (existsSync(path)) {
336
+ try {
337
+ forceRemoveDir(path);
338
+ }
339
+ catch (err) {
340
+ // BB-293 (spec D5): a leftover we cannot unlink — most likely
341
+ // foreign-owned (a container wrote it under another uid), which chmod
342
+ // cannot repair. Name the failing step; the reaper retries.
343
+ this.logger.warn({ path, err: String(err) }, 'worktree directory reclaim failed');
344
+ return false;
345
+ }
346
+ }
347
+ await this.pruneWorktrees(root);
348
+ return true;
349
+ }
350
+ /**
351
+ * When herdr has lost track of a worktree and its `worktreePath` was never
352
+ * persisted, the PARENT repo's `git worktree list` still knows it (BB-141
353
+ * RC-2 — the ~28 never-touched leftovers). Resolve the lost worktree's on-disk
354
+ * path there, preferring an exact `worktreePath` match, then a branch match;
355
+ * never the main worktree. Returns null when nothing matches. Best-effort: a
356
+ * git failure resolves to null.
357
+ *
358
+ * `cwd` is only where git is RUN. The main worktree is excluded by git's own
359
+ * porcelain ordering (it is always the first record) rather than by comparing
360
+ * against `cwd`, because the two coincide only when a parent root is
361
+ * configured. Without one the probe falls back to running git inside the
362
+ * worktree itself, and a `path !== cwd` filter then dropped the very entry
363
+ * being asked about — so `registered: true` was unreachable for a rootless
364
+ * executor (BB-293 review, finding 1). The configured root is excluded too,
365
+ * belt and braces, so a caller that anchors elsewhere still never resolves the
366
+ * main checkout as a ticket worktree.
367
+ */
368
+ async findGitWorktree(cwd, branch, worktreePath) {
369
+ let output;
370
+ try {
371
+ output = await this.runGit(['worktree', 'list', '--porcelain'], cwd);
372
+ }
373
+ catch {
374
+ return null;
375
+ }
376
+ const mainRoot = this.options.root;
377
+ const candidates = parseGitWorktreePorcelain(output).filter((e, i) => i > 0 && (!mainRoot || normPath(e.path) !== normPath(mainRoot)));
378
+ if (worktreePath) {
379
+ const want = normPath(worktreePath);
380
+ const byPath = candidates.find((e) => normPath(e.path) === want);
381
+ if (byPath) {
382
+ return byPath.path;
383
+ }
384
+ }
385
+ return candidates.find((e) => e.branch === branch)?.path ?? null;
386
+ }
387
+ /**
388
+ * Ask — passively — whether this worktree still exists, in BOTH dimensions
389
+ * that can come apart (BB-293): git's registration, and the directory on
390
+ * disk. The reported failure was exactly a case where they disagreed — the
391
+ * directory was there, the registration was not — so a single-dimension
392
+ * check cannot decide what to do.
393
+ *
394
+ * Passive by construction: it writes nothing, calls no herdr, and NEVER
395
+ * throws. A git failure yields `registered: false`, degrading the verdict to
396
+ * the on-disk answer rather than failing the teardown.
397
+ *
398
+ * The persisted `worktreePath` is the stable key and wins when given;
399
+ * `registered` then describes THAT path, never a branch match pointing
400
+ * somewhere else (the checked-out branch is mutable — BB-114).
401
+ *
402
+ * git is run at the configured parent root when there is one, else inside the
403
+ * worktree itself — `git worktree list` enumerates the whole repo from any of
404
+ * its worktrees, so the rootless answer is the same list.
405
+ */
406
+ async probeWorktree(branch, worktreePath) {
407
+ const cwd = this.options.root ?? worktreePath ?? null;
408
+ const gitPath = cwd
409
+ ? await this.findGitWorktree(cwd, branch, worktreePath)
410
+ : null;
411
+ const path = worktreePath ?? gitPath;
412
+ const registered = gitPath !== null && path !== null && normPath(gitPath) === normPath(path);
413
+ return { registered, onDisk: path !== null && existsSync(path), path };
414
+ }
415
+ /**
416
+ * BB-166 focus guard. herdr's `worktree remove` deletes the ticket
417
+ * worktree's on-disk directory; if that workspace is the CURRENTLY FOCUSED
418
+ * one, the interactive client is left in a now-deleted `cwd` and herdr spawns
419
+ * a stray empty workspace for the dead path. So before the remove, if the
420
+ * target workspace is focused, switch focus to the main-checkout (non-linked)
421
+ * workspace of the SAME repo. A background reap of a non-focused worktree
422
+ * (or one herdr no longer lists) focuses nothing.
423
+ *
424
+ * Returns whether it is SAFE to proceed with the remove: `false` when a
425
+ * required focus switch cannot be resolved or verified — the caller then
426
+ * aborts (returns `false` → `cleaned_up=0`) so the reaper retries later,
427
+ * rather than deleting the focused path out from under the user.
428
+ */
429
+ async ensureFocusSafeToRemove(workspaceId) {
430
+ const workspaces = parseWorkspaceList(await this.execHerdr(['workspace', 'list']));
431
+ const target = workspaces.find((w) => w.workspaceId === workspaceId);
432
+ // Not focused (or not listed) → removing it strands no client.
433
+ if (!target?.focused) {
434
+ return true;
435
+ }
436
+ // Focused: resolve the main-checkout workspace for the SAME repo — the
437
+ // non-linked workspace whose repo_root matches the target's (falling back
438
+ // to the configured parent root when herdr reports no repo_root).
439
+ const anchor = target.repoRoot ?? this.options.root ?? null;
440
+ const parent = anchor === null
441
+ ? undefined
442
+ : workspaces.find((w) => w.workspaceId !== workspaceId &&
443
+ !w.isLinkedWorktree &&
444
+ w.repoRoot !== null &&
445
+ normPath(w.repoRoot) === normPath(anchor));
446
+ // No safe target → do NOT delete the focused path; retry later.
447
+ if (!parent) {
448
+ return false;
449
+ }
450
+ try {
451
+ await this.execHerdr(['workspace', 'focus', parent.workspaceId]);
452
+ }
453
+ catch {
454
+ return false;
455
+ }
456
+ // Verify the switch actually took before deleting the (was-focused) dir.
457
+ const after = parseWorkspaceList(await this.execHerdr(['workspace', 'list']));
458
+ const parentNow = after.find((w) => w.workspaceId === parent.workspaceId);
459
+ const targetNow = after.find((w) => w.workspaceId === workspaceId);
460
+ return parentNow?.focused === true && targetNow?.focused !== true;
461
+ }
462
+ /**
463
+ * Tear down a ticket's entire worktree (git worktree + hosted workspace).
464
+ * `herdr worktree remove` is keyed by an open workspace id, so resolve the
465
+ * worktree first — by its stable `worktreePath`, falling back to `branch`
466
+ * (see {@link resolveWorktreeEntry}): if its workspace is already open use
467
+ * that id; otherwise open the on-disk worktree to obtain one (mirrors
468
+ * startRun's open-if-needed).
469
+ *
470
+ * `herdr worktree list` is machine-global — it enumerates every repo's
471
+ * worktrees on the host, most of which are not ours. So a no-match does NOT
472
+ * mean "error"; it means herdr no longer tracks this ticket's worktree. When
473
+ * herdr has lost track, teardown is resolved off git itself (never a foreign
474
+ * worktree), git-awarely so no `.git/worktrees/<name>` metadata is orphaned
475
+ * (BB-141 RC-1). Since BB-293 that resolution starts by ASKING — a passive
476
+ * {@link probeWorktree} reads both dimensions of existence and the four cases
477
+ * branch explicitly:
478
+ * - neither registered nor on disk → nothing to do at all;
479
+ * - registered but gone from disk → sweep the prunable admin entry;
480
+ * - on disk but not registered → reclaim the directory, WITHOUT asking git
481
+ * to remove a worktree it does not know (the bogus `ERROR: exec failed`);
482
+ * - both → reclaim it git-awarely, `git worktree remove` included.
483
+ * A path that resolves in NEITHER dimension is not "absent" — it is
484
+ * unresolved, and still reported `unverified` (BB-141 RC-2).
485
+ *
486
+ * Reports what the teardown DID (BB-293): `removed` when the worktree is
487
+ * gone because this host removed it (or reclaimed an on-disk orphan, or swept
488
+ * the prunable admin entry it left behind), `already_absent` when a RESOLVED
489
+ * worktree existed in neither dimension, and `unverified` ONLY when nothing
490
+ * was resolvable or the directory could not be reclaimed — the reaper then
491
+ * leaves the ticket on its work list for a later retry instead of falsely
492
+ * flagging it cleaned (BB-141 RC-2).
493
+ */
494
+ async removeWorktree(branch, worktreePath) {
495
+ // BB-468 / BB-228 (superseded MR !258, trigger folded in here): a
496
+ // ticket whose recorded `worktreePath` IS the configured parent-repo root
497
+ // must be refused rather than acted on. BB-293 already excludes the main
498
+ // checkout on the git side (`findGitWorktree` drops porcelain record 0 and
499
+ // the configured root), but neither `resolveWorktreeEntry` (herdr-tracked
500
+ // path) nor the probe fallback below carried the same filter, leaving two
501
+ // live branches: herdr tracks the workspace → `herdr worktree remove
502
+ // --force` throws `not_linked_worktree`, caught by the reaper and retried
503
+ // forever; herdr does NOT track it → the probe resolves `worktreePath`
504
+ // itself (unfiltered), `registered: false`, `onDisk: true`, and
505
+ // `gitAwareRemove` calls `forceRemoveDir` on the MAIN CHECKOUT. Refusing
506
+ // here, before either branch is reached, closes both without touching the
507
+ // git-side exclusion or the executor's `unverified`/`already_absent`
508
+ // contract for every other path.
509
+ if (this.options.root && worktreePath) {
510
+ const root = normPath(this.options.root);
511
+ if (normPath(worktreePath) === root) {
512
+ this.logger.warn({ branch, worktreePath, root: this.options.root }, 'refusing to tear down the main checkout');
513
+ return 'unverified';
514
+ }
515
+ }
516
+ const entries = await this.listWorktrees();
517
+ const entry = resolveWorktreeEntry(entries, branch, worktreePath);
518
+ if (!entry) {
519
+ // herdr no longer tracks it. BB-293: ask before acting. An
520
+ // already-absent worktree is a FINISHED teardown, not a failure to retry
521
+ // — and running `git worktree remove` against an unregistered path is
522
+ // what produced the bogus `ERROR: exec failed` (git exits 128 on an
523
+ // expected, handled condition). herdr still tracking the worktree is
524
+ // itself evidence that something is there to release, which is why the
525
+ // probe governs only this lost-track path and the tracked path below
526
+ // keeps its behaviour unchanged.
527
+ const probe = await this.probeWorktree(branch, worktreePath);
528
+ if (probe.path) {
529
+ if (!probe.onDisk) {
530
+ if (!probe.registered) {
531
+ this.logger.info({ branch, worktreePath: probe.path }, 'worktree already absent — nothing to tear down');
532
+ return 'already_absent';
533
+ }
534
+ // A prunable admin entry whose directory has vanished: the metadata
535
+ // sweep IS the teardown here (BB-141).
536
+ await this.pruneWorktrees(this.gitRoot(probe.path));
537
+ return 'removed';
538
+ }
539
+ return (await this.gitAwareRemove(probe.path, probe.registered))
540
+ ? 'removed'
541
+ : 'unverified';
542
+ }
543
+ // Nothing resolvable in either dimension — the path was never persisted
544
+ // and git does not know the branch either. Sweep pre-existing orphans,
545
+ // then report the teardown UNVERIFIED so the reaper retries rather than
546
+ // falsely cleaning (BB-141 RC-2). NOT `already_absent`: absence can
547
+ // only be claimed about a worktree that was actually located.
548
+ if (this.options.root) {
549
+ await this.pruneWorktrees(this.options.root);
550
+ }
551
+ return 'unverified';
552
+ }
553
+ let workspaceId = entry.open_workspace_id ?? null;
554
+ if (!workspaceId) {
555
+ // Reattach from the PARENT repo root, never the linked worktree itself:
556
+ // herdr rejects an open whose --cwd is a linked worktree with
557
+ // `linked_worktree_source` ("New and open actions start from the repo
558
+ // parent workspace"), which then yields no workspace id and the remove
559
+ // never runs. Fall back to the linked path only when no root is
560
+ // configured (keeps the old behaviour for rootless test callers).
561
+ await this.execHerdr([
562
+ 'worktree',
563
+ 'open',
564
+ '--cwd',
565
+ this.options.root ?? entry.path,
566
+ '--branch',
567
+ // entry.branch is null for a detached HEAD; fall back to the ticket
568
+ // branch so `open` still gets a branch arg.
569
+ entry.branch ?? branch,
570
+ '--no-focus',
571
+ '--json',
572
+ ]);
573
+ // Re-resolve by the same keys — open flips the entry's workspace id.
574
+ const reopened = resolveWorktreeEntry(await this.listWorktrees(), branch, worktreePath);
575
+ workspaceId = reopened?.open_workspace_id ?? null;
576
+ if (!workspaceId) {
577
+ throw new Error(`herdr worktree open did not yield a workspace id for path ${entry.path} (branch ${entry.branch})`);
578
+ }
579
+ }
580
+ // BB-166: before deleting the worktree's directory, if its workspace is
581
+ // the focused one, move focus to the main checkout — else the client is
582
+ // stranded in a deleted cwd. If a needed focus switch can't be made safely,
583
+ // abort UNVERIFIED so the reaper retries rather than stranding the user.
584
+ if (!(await this.ensureFocusSafeToRemove(workspaceId))) {
585
+ return 'unverified';
586
+ }
587
+ // Drupal hardens web/sites/default (0555) and settings.php/.htaccess (0444);
588
+ // herdr's remove performs the actual unlink and would hit EACCES on those
589
+ // read-only dirs. Restore owner-write on the on-disk worktree first.
590
+ restoreWritable(entry.path);
591
+ await this.execHerdr([
592
+ 'worktree',
593
+ 'remove',
594
+ '--workspace',
595
+ workspaceId,
596
+ '--force',
597
+ '--json',
598
+ ]);
599
+ // BB-166: verify the directory is actually gone on disk. If it survived,
600
+ // report the teardown UNVERIFIED (false) so the reaper retries instead of
601
+ // falsely flagging the ticket cleaned.
602
+ if (existsSync(entry.path)) {
603
+ return 'unverified';
604
+ }
605
+ // Sweep any orphaned `.git/worktrees/<name>` admin entries at the parent
606
+ // root (this teardown's and any pre-existing prunable ones — BB-141).
607
+ if (this.options.root) {
608
+ await this.pruneWorktrees(this.options.root);
609
+ }
610
+ return 'removed';
611
+ }
612
+ async startRun(input) {
613
+ const branchName = input.ticket.branchName;
614
+ const workspacePath = input.workspacePath;
615
+ const cfg = resolveStateConfig(this.options, input.ticket.state);
616
+ // 1. Already-open workspace for THIS worktree, keyed by the stable
617
+ // identifier-derived PATH — never the branch (BB-114): the checked-out
618
+ // branch is mutable (a coding agent may switch/rename it) and herdr
619
+ // reports `null` for a detached HEAD, so a branch match is unreliable.
620
+ let workspaceId = await this.findOpenWorkspace(branchName, workspacePath);
621
+ // 2. Open workspace if not already open. Reattach from the PARENT repo
622
+ // root, never the linked worktree path: herdr rejects an open whose
623
+ // --cwd is a linked worktree with `linked_worktree_source` ("New and
624
+ // open actions start from the repo parent workspace"), so passing
625
+ // input.workspacePath (the linked path) makes every first dispatch of a
626
+ // closed-on-disk worktree fail — the run is released and re-claimed in a
627
+ // loop. Mirrors removeWorktree's reattach. Fall back to the linked path
628
+ // only when no root is configured (rootless test callers).
629
+ if (!workspaceId) {
630
+ const openOutput = await this.execHerdr([
631
+ 'worktree',
632
+ 'open',
633
+ '--cwd',
634
+ this.options.root ?? workspacePath,
635
+ '--branch',
636
+ branchName,
637
+ '--no-focus',
638
+ '--json',
639
+ ]);
640
+ // Take the id from `open`'s OWN output — the reliable success path, with
641
+ // no branch match. Only if it carries none do we re-derive from the list
642
+ // (matched by the stable path, not the branch).
643
+ workspaceId = parseWorkspaceOpen(openOutput);
644
+ if (!workspaceId) {
645
+ const entries = await this.listWorktrees();
646
+ workspaceId =
647
+ resolveWorktreeEntry(entries, branchName, workspacePath)
648
+ ?.open_workspace_id ?? null;
649
+ if (!workspaceId) {
650
+ // Surface the ACTUAL cause (AC #3): herdr's own open output and the
651
+ // worktree state it listed — not just the generic per-branch message.
652
+ throw new Error(`herdr worktree open did not yield a workspace id for path ${workspacePath} (branch ${branchName}). ` +
653
+ `open output: ${openOutput.trim()}; listed worktrees: ${JSON.stringify(entries)}`);
654
+ }
655
+ }
656
+ }
657
+ const vars = {
658
+ identifier: input.ticket.identifier,
659
+ title: input.ticket.title,
660
+ titleSlug: slugify(input.ticket.title),
661
+ runUuid: input.run.uuid,
662
+ workspacePath: input.workspacePath,
663
+ };
664
+ // 3. Create tab — NO --json flag; label includes the run-id token. The tab's
665
+ // root pane IS the agent pane, so it is created carrying the agent-shell
666
+ // marker: the interactive shell must see it at startup for the user's opt-in
667
+ // history isolation to fire (BB-64). The run's own env (BB-99) stays out
668
+ // of here — it travels inside a 0600 launch script, so no secret moves into
669
+ // the pane environment or the typed line.
670
+ const { paneId: rootPaneId, tabId } = parseTabCreate(await this.execHerdr([
671
+ 'tab',
672
+ 'create',
673
+ '--workspace',
674
+ workspaceId,
675
+ '--label',
676
+ `${input.ticket.identifier} · ${input.ticket.state} #${input.run.id}`,
677
+ '--no-focus',
678
+ ...envArgs(AGENT_SHELL_ENV),
679
+ ]));
680
+ // BB-374: panel label on the state root pane (best-effort). Tab --label
681
+ // above stays the stop/state matching string and is intentionally not the
682
+ // resolved StateConfig.tabLabel path.
683
+ await this.execHerdr([
684
+ 'pane',
685
+ 'rename',
686
+ rootPaneId,
687
+ renderTemplate(cfg.panelLabel, vars),
688
+ ]).catch(() => { });
689
+ let launch;
690
+ try {
691
+ // 4+5. Run the agent via a short `sh '<script>'` line in the root pane,
692
+ // then split each configured pane — the shared pane-layout primitive
693
+ // (same code path as the workspace open pane). The assembled command
694
+ // (env prefixes + agent argv) lives in a 0600 file, so the typed line
695
+ // stays under the tty canonical cap and secrets never appear in the
696
+ // pane scrollback or exec log. startRun then waits for `started`
697
+ // (slow shells) and a short exit window after that (immediate death).
698
+ // The layout panes belong to the run's tab too, so they are marked as
699
+ // agent shells as well — the executor opts in, the workspace's open-layout
700
+ // caller does not (BB-64). commandFor / writeLaunchScript sit inside
701
+ // the try so an invalid env key or a temp-dir write error still closes
702
+ // the tab.
703
+ launch = writeLaunchScript(this.commandFor(input));
704
+ await applyPaneLayout(this.execHerdr, rootPaneId, { command: launch.typedCommand, panes: cfg.panes }, input.workspacePath, vars, { env: AGENT_SHELL_ENV });
705
+ await watchLaunch(launch, {
706
+ ...(this.options.launchWatch ?? {}),
707
+ readPane: () => this.execHerdr(['pane', 'read', rootPaneId, '--lines', '40']),
708
+ });
709
+ // 6. Return branch as sessionRef (for logging)
710
+ return { sessionRef: branchName };
711
+ }
712
+ catch (err) {
713
+ // 7. Drop the 0600 command file (secrets) then close only the new tab.
714
+ if (launch) {
715
+ rmSync(launch.dir, { recursive: true, force: true });
716
+ }
717
+ await this.rollback(tabId);
718
+ throw err;
719
+ }
720
+ }
721
+ /** Best-effort cleanup: close only the just-created tab. Swallows errors. */
722
+ async rollback(tabId) {
723
+ try {
724
+ await this.execHerdr(['tab', 'close', tabId]);
725
+ }
726
+ catch {
727
+ // ignore — best-effort
728
+ }
729
+ }
730
+ /**
731
+ * No-op: at finalise time the agent is idle and its transcript is already on
732
+ * disk; the {@link cleanupRun} tab-close kills the PTY, so the agent dies
733
+ * implicitly. Kept to satisfy the run lifecycle seam.
734
+ */
735
+ async stopRun(_branch) { }
736
+ /**
737
+ * Close ONLY the branch-workspace tab whose label carries the `#<runId>`
738
+ * token (`herdr tab close <tab_id>`), killing that PTY (BB-183). The run
739
+ * identity lives in the tab label, so teardown is run-scoped: a sibling run's
740
+ * tab and any non-run tab in the same workspace are left untouched, and a
741
+ * missing match is a quiet no-op. Best-effort: a failing close is swallowed.
742
+ * No `/exit`/C-c and no `(done)` rename — the matched tab simply goes away.
743
+ * The branch worktree is untouched (that is the reap/{@link removeWorktree}
744
+ * lifecycle).
745
+ */
746
+ async cleanupRun(branch, runId) {
747
+ const workspaceId = await this.findWorkspaceByBranch(branch);
748
+ if (!workspaceId) {
749
+ return;
750
+ }
751
+ const tabs = parseTabList(await this.execHerdr(['tab', 'list', '--workspace', workspaceId]));
752
+ const match = tabs.find((tab) => labelMatchesRun(tab.label, runId));
753
+ if (!match) {
754
+ return; // no tab for this run — quiet best-effort no-op (AC-4)
755
+ }
756
+ try {
757
+ await this.execHerdr(['tab', 'close', match.tabId]);
758
+ }
759
+ catch {
760
+ // ignore — best-effort: a failing tab-close must not abort finalisation
761
+ }
762
+ }
763
+ commandFor(input) {
764
+ const env = Object.entries(input.env ?? {}).map(([key, value]) => {
765
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
766
+ throw new Error(`invalid environment variable name for herdr: ${key}`);
767
+ }
768
+ return `${key}=${shellQuote(value)}`;
769
+ });
770
+ // herdr is agent-agnostic: it runs the command the conductor (via the agent
771
+ // plugin) provides, falling back to the configured default. No prompt build
772
+ // and no agent-specific flags here. Non-interactive `sh` eval's the line,
773
+ // so the binary must resolve through PATH (aliases are not loaded).
774
+ const command = input.command ?? this.options.command ?? 'claude';
775
+ return [...env, command].join(' ');
776
+ }
777
+ }
778
+ export function herdrExecutor(options = {}) {
779
+ return {
780
+ kind: 'executor',
781
+ id: 'herdr',
782
+ requiredModules: [],
783
+ async createExecutor(config, deps) {
784
+ // Derive the parent repo root via the single shared anchor (BB-177) so
785
+ // removeWorktree / worktree open reattach from the main clone, not the
786
+ // linked worktree — identical derivation to the sibling herdrWorkspace.
787
+ const root = await deriveHerdrRoot(config.config_path, {
788
+ ...(options.root !== undefined ? { override: options.root } : {}),
789
+ ...(options.execGit ? { execGit: options.execGit } : {}),
790
+ });
791
+ // Lifecycle hooks are top-level config (BB-84); the executor runs them
792
+ // best-effort through its injected logger.
793
+ return new HerdrExecutor({
794
+ command: options.command ?? 'claude',
795
+ root,
796
+ ...(options.hooks ? { hooks: options.hooks } : {}),
797
+ ...(config.hooks ? { hooks: config.hooks } : {}),
798
+ ...(options.default ? { default: options.default } : {}),
799
+ ...(options.states ? { states: options.states } : {}),
800
+ ...(options.launchWatch ? { launchWatch: options.launchWatch } : {}),
801
+ }, defaultExec, deps.logger);
802
+ },
803
+ };
804
+ }
805
+ // Default export: this module also exports the `HerdrExecutor` class, so the
806
+ // config resolver's auto-pick (no `export:`) sees 2 function exports and
807
+ // would otherwise throw "specify export" — see conductor/src/config.ts
808
+ // `loadNamedPlugin`. The default export makes the factory win.
809
+ export default herdrExecutor;