@gaia-ai/addon-herdr 0.6.1

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,708 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { exec, shellQuote, slugify } from '@gaia-ai/core';
3
+ import { resolveStateConfig, } from './config.js';
4
+ import { forceRemoveDir, restoreWritable } from './fs.js';
5
+ import { applyPaneLayout, isRecord, parseJson, } from './pane-layout.js';
6
+ import { deriveHerdrRoot } from './root-anchor.js';
7
+ export { herdrAgentHost } from './agents.js';
8
+ export { envArgs, findTabIdByLabel, parsePaneInfo, parsePanesInTab, parseTabCreate, } from './panes.js';
9
+ // GAIA-139: the herdr workspace lives in the same package now (the separate
10
+ // @gaia-ai/addon-herdr-workspace was merged in). The package DEFAULT export
11
+ // stays the executor (below); herdrWorkspace + HerdrWorkspace are named exports.
12
+ export { HerdrWorkspace, herdrWorkspace, } from './workspace.js';
13
+ async function defaultExec(args, opts) {
14
+ return exec('herdr', args, opts);
15
+ }
16
+ /** Default git runner: real `git` in the given cwd (the parent repo root). */
17
+ const defaultGitRunner = (args, cwd) => exec('git', args, { cwd });
18
+ /**
19
+ * Parse `git worktree list --porcelain`. Records are blank-line separated; each
20
+ * has a `worktree <path>` line and, for an attached HEAD, a `branch
21
+ * refs/heads/<branch>` line (a detached HEAD has none). `prunable <reason>`
22
+ * entries (a `.git/worktrees/<name>` admin entry whose dir has vanished) still
23
+ * carry their `worktree <path>` line, so they parse too — the caller decides
24
+ * what to do with a path no longer on disk.
25
+ */
26
+ function parseGitWorktreePorcelain(output) {
27
+ const entries = [];
28
+ let path = null;
29
+ let branch = null;
30
+ const flush = () => {
31
+ if (path) {
32
+ entries.push({ path, branch });
33
+ }
34
+ path = null;
35
+ branch = null;
36
+ };
37
+ for (const line of output.split('\n')) {
38
+ if (line.startsWith('worktree ')) {
39
+ flush(); // a new record begins — commit the previous one
40
+ path = line.slice('worktree '.length).trim();
41
+ }
42
+ else if (line.startsWith('branch ')) {
43
+ branch = line
44
+ .slice('branch '.length)
45
+ .trim()
46
+ .replace(/^refs\/heads\//, '');
47
+ }
48
+ }
49
+ flush();
50
+ return entries;
51
+ }
52
+ /** Default hook runner: `sh -c <command>` in the worktree — fails honestly. */
53
+ const defaultShellRunner = async (command, cwd, env) => {
54
+ await exec('sh', ['-c', command], { cwd, ...(env ? { env } : {}) });
55
+ };
56
+ const noopLogger = {
57
+ debug() { },
58
+ info() { },
59
+ warn() { },
60
+ error() { },
61
+ };
62
+ function parseWorktreeList(output) {
63
+ const parsed = parseJson(output, 'worktree list');
64
+ const worktrees = isRecord(parsed)
65
+ ? parsed.result?.worktrees
66
+ : undefined;
67
+ if (!Array.isArray(worktrees)) {
68
+ return [];
69
+ }
70
+ // Key on `path` only — herdr reports `branch: null` for a detached HEAD, and
71
+ // the branch is mutable anyway (see resolveWorktreeEntry). Dropping such
72
+ // entries is exactly the GAIA-114 defect: the worktree becomes invisible to
73
+ // a path lookup.
74
+ return worktrees
75
+ .filter(isRecord)
76
+ .filter((w) => typeof w.path === 'string')
77
+ .map((w) => ({
78
+ branch: typeof w.branch === 'string' ? w.branch : null,
79
+ path: w.path,
80
+ ...(typeof w.open_workspace_id === 'string' && w.open_workspace_id
81
+ ? { open_workspace_id: w.open_workspace_id }
82
+ : {}),
83
+ }));
84
+ }
85
+ /**
86
+ * Parse `herdr workspace list` (which always emits JSON; it takes no `--json`
87
+ * flag). Enumerates every workspace on the host with its `focused` flag and its
88
+ * `worktree.{is_linked_worktree,repo_root}` — the inputs the GAIA-166 focus
89
+ * guard needs to tell a focused ticket worktree from its main-checkout sibling.
90
+ */
91
+ function parseWorkspaceList(output) {
92
+ const parsed = parseJson(output, 'workspace list');
93
+ const workspaces = isRecord(parsed)
94
+ ? parsed.result?.workspaces
95
+ : undefined;
96
+ if (!Array.isArray(workspaces)) {
97
+ return [];
98
+ }
99
+ return workspaces
100
+ .filter(isRecord)
101
+ .filter((w) => typeof w.workspace_id === 'string')
102
+ .map((w) => {
103
+ const wt = isRecord(w.worktree) ? w.worktree : undefined;
104
+ return {
105
+ workspaceId: w.workspace_id,
106
+ focused: w.focused === true,
107
+ isLinkedWorktree: wt?.is_linked_worktree === true,
108
+ repoRoot: typeof wt?.repo_root === 'string' ? wt.repo_root : null,
109
+ };
110
+ });
111
+ }
112
+ /**
113
+ * Extract the open workspace id from `herdr worktree open`'s own JSON. herdr
114
+ * returns it in the result envelope under any of these keys (herdr 0.7.x):
115
+ * `workspace.workspace_id`, `worktree.open_workspace_id`, or
116
+ * `root_pane.workspace_id`. Returns null when the payload carries none (e.g. an
117
+ * error envelope), so the caller can fall back to a list lookup or surface the
118
+ * failure. This is the reliable success path — no branch match involved.
119
+ */
120
+ function parseWorkspaceOpen(output) {
121
+ const parsed = parseJson(output, 'worktree open');
122
+ const result = isRecord(parsed)
123
+ ? parsed.result
124
+ : undefined;
125
+ const candidates = [
126
+ result?.workspace?.workspace_id,
127
+ result?.worktree?.open_workspace_id,
128
+ result?.root_pane?.workspace_id,
129
+ ];
130
+ for (const c of candidates) {
131
+ if (typeof c === 'string' && c) {
132
+ return c;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+ function parseTabList(output) {
138
+ const parsed = parseJson(output, 'tab list');
139
+ const tabs = isRecord(parsed)
140
+ ? parsed.result?.tabs
141
+ : undefined;
142
+ if (!Array.isArray(tabs)) {
143
+ return [];
144
+ }
145
+ return tabs
146
+ .filter(isRecord)
147
+ .filter((t) => typeof t.tab_id === 'string' && typeof t.label === 'string')
148
+ .map((t) => ({
149
+ tabId: t.tab_id,
150
+ label: t.label,
151
+ }));
152
+ }
153
+ /**
154
+ * Whether a tab label carries the exact `#<runId>` token that `startRun` emits
155
+ * (`<identifier> · <state> #<run.id>`). Anchored on the trailing token and
156
+ * compared numerically, so `#4339` never matches `#43390` / `#433` (GAIA-183
157
+ * AC-3). A label with no trailing `#<digits>` token (a non-run tab) never
158
+ * matches.
159
+ */
160
+ function labelMatchesRun(label, runId) {
161
+ const m = label.match(/#(\d+)$/);
162
+ return m !== null && Number(m[1]) === runId;
163
+ }
164
+ function parseTabCreate(output) {
165
+ const parsed = parseJson(output, 'tab create');
166
+ const result = isRecord(parsed)
167
+ ? parsed.result
168
+ : undefined;
169
+ const paneId = result?.root_pane?.pane_id;
170
+ const tabId = result?.tab?.tab_id;
171
+ if (typeof paneId !== 'string' || typeof tabId !== 'string') {
172
+ throw new Error('herdr tab create returned invalid schema');
173
+ }
174
+ return { paneId, tabId };
175
+ }
176
+ /** Normalise a worktree path for comparison (strip trailing slashes). */
177
+ function normPath(path) {
178
+ return path.replace(/\/+$/, '');
179
+ }
180
+ /**
181
+ * Resolve a worktree entry, preferring the stable `worktreePath` over `branch`.
182
+ * The checked-out branch is mutable (the coding agent may rename/switch it), so
183
+ * the path — deterministic and identifier-derived — is the reliable key. Fall
184
+ * back to a branch match so a missing/renamed path still resolves. Returns null
185
+ * only when neither key matches any listed worktree.
186
+ */
187
+ function resolveWorktreeEntry(entries, branch, worktreePath) {
188
+ if (worktreePath) {
189
+ const want = normPath(worktreePath);
190
+ const byPath = entries.find((e) => normPath(e.path) === want);
191
+ if (byPath) {
192
+ return byPath;
193
+ }
194
+ }
195
+ return entries.find((e) => e.branch === branch) ?? null;
196
+ }
197
+ export class HerdrExecutor {
198
+ options;
199
+ execHerdr;
200
+ logger;
201
+ runShell;
202
+ runGit;
203
+ id = 'herdr';
204
+ constructor(options, execHerdr = defaultExec, logger = noopLogger, runShell = defaultShellRunner, runGit = defaultGitRunner) {
205
+ this.options = options;
206
+ this.execHerdr = execHerdr;
207
+ this.logger = logger;
208
+ this.runShell = runShell;
209
+ this.runGit = runGit;
210
+ }
211
+ capabilities() {
212
+ return { persistent: true };
213
+ }
214
+ /**
215
+ * Run the lifecycle hook `name` best-effort — the single catch point for all
216
+ * lifecycle hooks (see {@link GaiaExecutor.runHook}). No configured command →
217
+ * silent no-op. A failing command → log loudly (hook + worktree + ticket +
218
+ * err) and return. NEVER throws, so a hook failure can't abort dispatch or
219
+ * wedge a run.
220
+ */
221
+ async runHook(name, cwd, ctx, env) {
222
+ const command = this.options.hooks?.[name];
223
+ if (!command) {
224
+ return;
225
+ }
226
+ try {
227
+ await this.runShell(command, cwd, env);
228
+ }
229
+ catch (err) {
230
+ this.logger.error({ hook: name, worktree: cwd, ticket: ctx.ticket, err: String(err) }, 'lifecycle hook failed');
231
+ }
232
+ }
233
+ /**
234
+ * List worktrees, ALWAYS anchored to the configured parent repo root
235
+ * (GAIA-211). `herdr worktree list` is machine-global, but run WITHOUT
236
+ * `--cwd` it resolves herdr's own ambient (previously selected / persisted /
237
+ * process / shell / daemon) repository context. When a preceding
238
+ * `gaia-cleanup-*` integration test selected then deleted a
239
+ * `/tmp/gaia-cleanup-*` repo, that stale context survives and the unanchored
240
+ * list exits (`cannot change to '/tmp/gaia-cleanup-…'`), aborting dispatch.
241
+ * Anchoring every list with `--cwd this.options.root` makes listing
242
+ * independent of that ambient context. This is the SINGLE choke point for
243
+ * all executor `worktree list` call paths — the sibling
244
+ * {@link HerdrWorkspace.ensure} anchors identically. The `--cwd` is only
245
+ * omitted when no root is configured (rootless test callers).
246
+ *
247
+ * On a herdr failure it throws an error naming the explicit configured root
248
+ * and the operation (AC-9) instead of surfacing herdr's raw stale-context
249
+ * message, so a future context failure is diagnosable.
250
+ */
251
+ async listWorktrees() {
252
+ const args = [
253
+ 'worktree',
254
+ 'list',
255
+ ...(this.options.root ? ['--cwd', this.options.root] : []),
256
+ '--json',
257
+ ];
258
+ let output;
259
+ try {
260
+ output = await this.execHerdr(args);
261
+ }
262
+ catch (err) {
263
+ throw new Error(`herdr worktree list failed for configured root ${this.options.root ?? '<unset>'}: ${String(err)}`);
264
+ }
265
+ return parseWorktreeList(output);
266
+ }
267
+ /**
268
+ * Find the worktree entry (path + open_workspace_id) for a branch via
269
+ * worktree list. Returns null if the branch has no worktree.
270
+ */
271
+ async findWorktreeByBranch(branch) {
272
+ const entries = await this.listWorktrees();
273
+ return entries.find((e) => e.branch === branch) ?? null;
274
+ }
275
+ /**
276
+ * Find the open_workspace_id for a branch via worktree list.
277
+ * Returns null if no workspace is open for that branch.
278
+ */
279
+ async findWorkspaceByBranch(branch) {
280
+ const entry = await this.findWorktreeByBranch(branch);
281
+ return entry?.open_workspace_id ?? null;
282
+ }
283
+ /**
284
+ * Find the open_workspace_id for a worktree by its stable `worktreePath`
285
+ * (falling back to `branch`; see {@link resolveWorktreeEntry}). Unlike
286
+ * {@link findWorkspaceByBranch}, this does not rely on the mutable branch —
287
+ * it is the startRun lookup the GAIA-114 fix hangs on. Returns null when the
288
+ * worktree is not listed as open.
289
+ */
290
+ async findOpenWorkspace(branch, worktreePath) {
291
+ const entries = await this.listWorktrees();
292
+ return (resolveWorktreeEntry(entries, branch, worktreePath)?.open_workspace_id ??
293
+ null);
294
+ }
295
+ /** Parent repo root for git worktree admin ops (falls back to `fallback`). */
296
+ gitRoot(fallback) {
297
+ return this.options.root ?? fallback;
298
+ }
299
+ /**
300
+ * Sweep orphaned `.git/worktrees/<name>` admin entries at the parent repo
301
+ * root — the ones whose on-disk dir has vanished (GAIA-141: they show up as
302
+ * `prunable` in `git worktree list`). Best-effort: a prune failure must never
303
+ * fail teardown.
304
+ */
305
+ async pruneWorktrees(root) {
306
+ try {
307
+ await this.runGit(['worktree', 'prune'], root);
308
+ }
309
+ catch {
310
+ // best-effort — pruning is a sweep, never the teardown's success gate
311
+ }
312
+ }
313
+ /**
314
+ * Remove an on-disk worktree GIT-AWARELY (GAIA-141 RC-1). The old fallback
315
+ * raw-`rm`'d the directory and left the parent repo's `.git/worktrees/<name>`
316
+ * admin entry behind → a `prunable` orphan. This:
317
+ * 1. restores owner-write (Drupal hardens web/sites/default 0555 +
318
+ * settings.php 0444 — chmod-restorable by the owning user, no sudo);
319
+ * 2. `git worktree remove --force <path>` from the PARENT repo root, which
320
+ * unlinks the directory AND drops the `.git/worktrees/<name>` entry;
321
+ * 3. guarantees the directory is gone (a raw force-remove) in case git
322
+ * refused (the path was never a registered worktree) — a no-op if git
323
+ * already removed it;
324
+ * 4. prunes the repo so any now-stale admin entry is swept.
325
+ */
326
+ async gitAwareRemove(path) {
327
+ restoreWritable(path);
328
+ const root = this.gitRoot(path);
329
+ try {
330
+ await this.runGit(['worktree', 'remove', '--force', path], root);
331
+ }
332
+ catch {
333
+ // Not a registered worktree (or git refused) — reclaim the dir directly.
334
+ }
335
+ if (existsSync(path)) {
336
+ forceRemoveDir(path);
337
+ }
338
+ await this.pruneWorktrees(root);
339
+ }
340
+ /**
341
+ * When herdr has lost track of a worktree and its `worktreePath` was never
342
+ * persisted, the PARENT repo's `git worktree list` still knows it (GAIA-141
343
+ * RC-2 — the ~28 never-touched leftovers). Resolve the lost worktree's on-disk
344
+ * path there, preferring an exact `worktreePath` match, then a branch match;
345
+ * never the main worktree (the repo root itself). Returns null when nothing
346
+ * matches. Best-effort: a git failure resolves to null.
347
+ */
348
+ async findGitWorktree(root, branch, worktreePath) {
349
+ let output;
350
+ try {
351
+ output = await this.runGit(['worktree', 'list', '--porcelain'], root);
352
+ }
353
+ catch {
354
+ return null;
355
+ }
356
+ const candidates = parseGitWorktreePorcelain(output).filter((e) => normPath(e.path) !== normPath(root));
357
+ if (worktreePath) {
358
+ const want = normPath(worktreePath);
359
+ const byPath = candidates.find((e) => normPath(e.path) === want);
360
+ if (byPath) {
361
+ return byPath.path;
362
+ }
363
+ }
364
+ return candidates.find((e) => e.branch === branch)?.path ?? null;
365
+ }
366
+ /**
367
+ * GAIA-166 focus guard. herdr's `worktree remove` deletes the ticket
368
+ * worktree's on-disk directory; if that workspace is the CURRENTLY FOCUSED
369
+ * one, the interactive client is left in a now-deleted `cwd` and herdr spawns
370
+ * a stray empty workspace for the dead path. So before the remove, if the
371
+ * target workspace is focused, switch focus to the main-checkout (non-linked)
372
+ * workspace of the SAME repo. A background reap of a non-focused worktree
373
+ * (or one herdr no longer lists) focuses nothing.
374
+ *
375
+ * Returns whether it is SAFE to proceed with the remove: `false` when a
376
+ * required focus switch cannot be resolved or verified — the caller then
377
+ * aborts (returns `false` → `cleaned_up=0`) so the reaper retries later,
378
+ * rather than deleting the focused path out from under the user.
379
+ */
380
+ async ensureFocusSafeToRemove(workspaceId) {
381
+ const workspaces = parseWorkspaceList(await this.execHerdr(['workspace', 'list']));
382
+ const target = workspaces.find((w) => w.workspaceId === workspaceId);
383
+ // Not focused (or not listed) → removing it strands no client.
384
+ if (!target?.focused) {
385
+ return true;
386
+ }
387
+ // Focused: resolve the main-checkout workspace for the SAME repo — the
388
+ // non-linked workspace whose repo_root matches the target's (falling back
389
+ // to the configured parent root when herdr reports no repo_root).
390
+ const anchor = target.repoRoot ?? this.options.root ?? null;
391
+ const parent = anchor === null
392
+ ? undefined
393
+ : workspaces.find((w) => w.workspaceId !== workspaceId &&
394
+ !w.isLinkedWorktree &&
395
+ w.repoRoot !== null &&
396
+ normPath(w.repoRoot) === normPath(anchor));
397
+ // No safe target → do NOT delete the focused path; retry later.
398
+ if (!parent) {
399
+ return false;
400
+ }
401
+ try {
402
+ await this.execHerdr(['workspace', 'focus', parent.workspaceId]);
403
+ }
404
+ catch {
405
+ return false;
406
+ }
407
+ // Verify the switch actually took before deleting the (was-focused) dir.
408
+ const after = parseWorkspaceList(await this.execHerdr(['workspace', 'list']));
409
+ const parentNow = after.find((w) => w.workspaceId === parent.workspaceId);
410
+ const targetNow = after.find((w) => w.workspaceId === workspaceId);
411
+ return parentNow?.focused === true && targetNow?.focused !== true;
412
+ }
413
+ /**
414
+ * Tear down a ticket's entire worktree (git worktree + hosted workspace).
415
+ * `herdr worktree remove` is keyed by an open workspace id, so resolve the
416
+ * worktree first — by its stable `worktreePath`, falling back to `branch`
417
+ * (see {@link resolveWorktreeEntry}): if its workspace is already open use
418
+ * that id; otherwise open the on-disk worktree to obtain one (mirrors
419
+ * startRun's open-if-needed).
420
+ *
421
+ * `herdr worktree list` is machine-global — it enumerates every repo's
422
+ * worktrees on the host, most of which are not ours. So a no-match does NOT
423
+ * mean "error"; it means herdr no longer tracks this ticket's worktree. When
424
+ * herdr has lost track, teardown is resolved off git itself (never a foreign
425
+ * worktree), git-awarely so no `.git/worktrees/<name>` metadata is orphaned
426
+ * (GAIA-141 RC-1):
427
+ * - `worktreePath` still on disk → reclaim it git-awarely;
428
+ * - `worktreePath` known but gone from disk → already torn down; sweep any
429
+ * orphaned metadata and report verified;
430
+ * - `worktreePath` unknown → resolve the lost worktree via the parent repo's
431
+ * `git worktree list` (GAIA-141 RC-2) and reclaim it git-awarely.
432
+ *
433
+ * Returns whether the teardown was VERIFIED on THIS host: `true` when the
434
+ * worktree is gone (removed by us, an on-disk orphan reclaimed, or confirmed
435
+ * already absent), `false` ONLY when nothing was resolvable and the teardown
436
+ * could not be verified — the reaper then leaves the ticket on its work list
437
+ * for a later retry instead of falsely flagging it cleaned (GAIA-141 RC-2).
438
+ */
439
+ async removeWorktree(branch, worktreePath) {
440
+ const entries = await this.listWorktrees();
441
+ const entry = resolveWorktreeEntry(entries, branch, worktreePath);
442
+ if (!entry) {
443
+ // herdr no longer tracks it. Resolve teardown off git, never a foreign
444
+ // worktree, and git-awarely so no `.git/worktrees/<name>` is orphaned.
445
+ if (worktreePath && existsSync(worktreePath)) {
446
+ await this.gitAwareRemove(worktreePath);
447
+ return true;
448
+ }
449
+ if (worktreePath) {
450
+ // Path known but already gone from disk → verified (idempotent) teardown;
451
+ // still sweep any orphaned metadata it left behind.
452
+ if (this.options.root) {
453
+ await this.pruneWorktrees(this.options.root);
454
+ }
455
+ return true;
456
+ }
457
+ // Path unknown (never persisted). The parent repo's git worktree list may
458
+ // still know the lost worktree — resolve + reclaim it (GAIA-141 RC-2).
459
+ if (this.options.root) {
460
+ const gitPath = await this.findGitWorktree(this.options.root, branch);
461
+ if (gitPath) {
462
+ await this.gitAwareRemove(gitPath);
463
+ return true;
464
+ }
465
+ // Nothing resolvable — sweep pre-existing orphans, then report the
466
+ // teardown UNVERIFIED so the reaper retries rather than falsely cleaning.
467
+ await this.pruneWorktrees(this.options.root);
468
+ }
469
+ return false;
470
+ }
471
+ let workspaceId = entry.open_workspace_id ?? null;
472
+ if (!workspaceId) {
473
+ // Reattach from the PARENT repo root, never the linked worktree itself:
474
+ // herdr rejects an open whose --cwd is a linked worktree with
475
+ // `linked_worktree_source` ("New and open actions start from the repo
476
+ // parent workspace"), which then yields no workspace id and the remove
477
+ // never runs. Fall back to the linked path only when no root is
478
+ // configured (keeps the old behaviour for rootless test callers).
479
+ await this.execHerdr([
480
+ 'worktree',
481
+ 'open',
482
+ '--cwd',
483
+ this.options.root ?? entry.path,
484
+ '--branch',
485
+ // entry.branch is null for a detached HEAD; fall back to the ticket
486
+ // branch so `open` still gets a branch arg.
487
+ entry.branch ?? branch,
488
+ '--no-focus',
489
+ '--json',
490
+ ]);
491
+ // Re-resolve by the same keys — open flips the entry's workspace id.
492
+ const reopened = resolveWorktreeEntry(await this.listWorktrees(), branch, worktreePath);
493
+ workspaceId = reopened?.open_workspace_id ?? null;
494
+ if (!workspaceId) {
495
+ throw new Error(`herdr worktree open did not yield a workspace id for path ${entry.path} (branch ${entry.branch})`);
496
+ }
497
+ }
498
+ // GAIA-166: before deleting the worktree's directory, if its workspace is
499
+ // the focused one, move focus to the main checkout — else the client is
500
+ // stranded in a deleted cwd. If a needed focus switch can't be made safely,
501
+ // abort UNVERIFIED so the reaper retries rather than stranding the user.
502
+ if (!(await this.ensureFocusSafeToRemove(workspaceId))) {
503
+ return false;
504
+ }
505
+ // Drupal hardens web/sites/default (0555) and settings.php/.htaccess (0444);
506
+ // herdr's remove performs the actual unlink and would hit EACCES on those
507
+ // read-only dirs. Restore owner-write on the on-disk worktree first.
508
+ restoreWritable(entry.path);
509
+ await this.execHerdr([
510
+ 'worktree',
511
+ 'remove',
512
+ '--workspace',
513
+ workspaceId,
514
+ '--force',
515
+ '--json',
516
+ ]);
517
+ // GAIA-166: verify the directory is actually gone on disk. If it survived,
518
+ // report the teardown UNVERIFIED (false) so the reaper retries instead of
519
+ // falsely flagging the ticket cleaned.
520
+ if (existsSync(entry.path)) {
521
+ return false;
522
+ }
523
+ // Sweep any orphaned `.git/worktrees/<name>` admin entries at the parent
524
+ // root (this teardown's and any pre-existing prunable ones — GAIA-141).
525
+ if (this.options.root) {
526
+ await this.pruneWorktrees(this.options.root);
527
+ }
528
+ return true;
529
+ }
530
+ async startRun(input) {
531
+ const branchName = input.ticket.branchName;
532
+ const workspacePath = input.workspacePath;
533
+ const cfg = resolveStateConfig(this.options, input.ticket.state);
534
+ // 1. Already-open workspace for THIS worktree, keyed by the stable
535
+ // identifier-derived PATH — never the branch (GAIA-114): the checked-out
536
+ // branch is mutable (a coding agent may switch/rename it) and herdr
537
+ // reports `null` for a detached HEAD, so a branch match is unreliable.
538
+ let workspaceId = await this.findOpenWorkspace(branchName, workspacePath);
539
+ // 2. Open workspace if not already open. Reattach from the PARENT repo
540
+ // root, never the linked worktree path: herdr rejects an open whose
541
+ // --cwd is a linked worktree with `linked_worktree_source` ("New and
542
+ // open actions start from the repo parent workspace"), so passing
543
+ // input.workspacePath (the linked path) makes every first dispatch of a
544
+ // closed-on-disk worktree fail — the run is released and re-claimed in a
545
+ // loop. Mirrors removeWorktree's reattach. Fall back to the linked path
546
+ // only when no root is configured (rootless test callers).
547
+ if (!workspaceId) {
548
+ const openOutput = await this.execHerdr([
549
+ 'worktree',
550
+ 'open',
551
+ '--cwd',
552
+ this.options.root ?? workspacePath,
553
+ '--branch',
554
+ branchName,
555
+ '--no-focus',
556
+ '--json',
557
+ ]);
558
+ // Take the id from `open`'s OWN output — the reliable success path, with
559
+ // no branch match. Only if it carries none do we re-derive from the list
560
+ // (matched by the stable path, not the branch).
561
+ workspaceId = parseWorkspaceOpen(openOutput);
562
+ if (!workspaceId) {
563
+ const entries = await this.listWorktrees();
564
+ workspaceId =
565
+ resolveWorktreeEntry(entries, branchName, workspacePath)
566
+ ?.open_workspace_id ?? null;
567
+ if (!workspaceId) {
568
+ // Surface the ACTUAL cause (AC #3): herdr's own open output and the
569
+ // worktree state it listed — not just the generic per-branch message.
570
+ throw new Error(`herdr worktree open did not yield a workspace id for path ${workspacePath} (branch ${branchName}). ` +
571
+ `open output: ${openOutput.trim()}; listed worktrees: ${JSON.stringify(entries)}`);
572
+ }
573
+ }
574
+ }
575
+ const vars = {
576
+ identifier: input.ticket.identifier,
577
+ title: input.ticket.title,
578
+ titleSlug: slugify(input.ticket.title),
579
+ runUuid: input.run.uuid,
580
+ workspacePath: input.workspacePath,
581
+ };
582
+ // 3. Create tab — NO --json flag; label includes the run-id token
583
+ const { paneId: rootPaneId, tabId } = parseTabCreate(await this.execHerdr([
584
+ 'tab',
585
+ 'create',
586
+ '--workspace',
587
+ workspaceId,
588
+ '--label',
589
+ `${input.ticket.identifier} · ${input.ticket.state} #${input.run.id}`,
590
+ '--no-focus',
591
+ ]));
592
+ try {
593
+ // 4+5. Run the agent command in the root pane, then split each configured
594
+ // pane — the shared pane-layout primitive (same code path as the workspace
595
+ // open pane). The root command embeds `KEY='value'` env prefixes whose
596
+ // values may be secret (GAIA-99); pass a redactor so a failed pane-run
597
+ // exec-log carries key names, never values (only when env is present).
598
+ const hasEnv = Object.keys(input.env ?? {}).length > 0;
599
+ await applyPaneLayout(this.execHerdr, rootPaneId, { command: this.commandFor(input), panes: cfg.panes }, input.workspacePath, vars, hasEnv
600
+ ? { redactCommand: () => this.redactedCommandFor(input) }
601
+ : undefined);
602
+ // 6. Return branch as sessionRef (for logging)
603
+ return { sessionRef: branchName };
604
+ }
605
+ catch (err) {
606
+ // 7. Rollback: close only the just-created tab
607
+ await this.rollback(tabId);
608
+ throw err;
609
+ }
610
+ }
611
+ /** Best-effort cleanup: close only the just-created tab. Swallows errors. */
612
+ async rollback(tabId) {
613
+ try {
614
+ await this.execHerdr(['tab', 'close', tabId]);
615
+ }
616
+ catch {
617
+ // ignore — best-effort
618
+ }
619
+ }
620
+ /**
621
+ * No-op: at finalise time the agent is idle and its transcript is already on
622
+ * disk; the {@link cleanupRun} tab-close kills the PTY, so the agent dies
623
+ * implicitly. Kept to satisfy the run lifecycle seam.
624
+ */
625
+ async stopRun(_branch) { }
626
+ /**
627
+ * Close ONLY the branch-workspace tab whose label carries the `#<runId>`
628
+ * token (`herdr tab close <tab_id>`), killing that PTY (GAIA-183). The run
629
+ * identity lives in the tab label, so teardown is run-scoped: a sibling run's
630
+ * tab and any non-run tab in the same workspace are left untouched, and a
631
+ * missing match is a quiet no-op. Best-effort: a failing close is swallowed.
632
+ * No `/exit`/C-c and no `(done)` rename — the matched tab simply goes away.
633
+ * The branch worktree is untouched (that is the reap/{@link removeWorktree}
634
+ * lifecycle).
635
+ */
636
+ async cleanupRun(branch, runId) {
637
+ const workspaceId = await this.findWorkspaceByBranch(branch);
638
+ if (!workspaceId) {
639
+ return;
640
+ }
641
+ const tabs = parseTabList(await this.execHerdr(['tab', 'list', '--workspace', workspaceId]));
642
+ const match = tabs.find((tab) => labelMatchesRun(tab.label, runId));
643
+ if (!match) {
644
+ return; // no tab for this run — quiet best-effort no-op (AC-4)
645
+ }
646
+ try {
647
+ await this.execHerdr(['tab', 'close', match.tabId]);
648
+ }
649
+ catch {
650
+ // ignore — best-effort: a failing tab-close must not abort finalisation
651
+ }
652
+ }
653
+ commandFor(input) {
654
+ return this.assembleCommand(input, false);
655
+ }
656
+ /**
657
+ * The pane-run command with every env VALUE masked (key names kept). Used
658
+ * only for the exec log so a failed `pane run` never carries a secret DSN —
659
+ * consistent with the conductor's key-name-only env logging (GAIA-99).
660
+ */
661
+ redactedCommandFor(input) {
662
+ return this.assembleCommand(input, true);
663
+ }
664
+ assembleCommand(input, redact) {
665
+ const env = Object.entries(input.env ?? {}).map(([key, value]) => {
666
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
667
+ throw new Error(`invalid environment variable name for herdr: ${key}`);
668
+ }
669
+ return `${key}=${redact ? '<redacted>' : shellQuote(value)}`;
670
+ });
671
+ // herdr is agent-agnostic: it runs the command the conductor (via the agent
672
+ // plugin) provides, falling back to the configured default. No prompt build
673
+ // and no agent-specific flags here.
674
+ const command = input.command ?? this.options.command ?? 'claude';
675
+ return [...env, command].join(' ');
676
+ }
677
+ }
678
+ export function herdrExecutor(options = {}) {
679
+ return {
680
+ kind: 'executor',
681
+ id: 'herdr',
682
+ requiredModules: [],
683
+ async createExecutor(config, deps) {
684
+ // Derive the parent repo root via the single shared anchor (GAIA-177) so
685
+ // removeWorktree / worktree open reattach from the main clone, not the
686
+ // linked worktree — identical derivation to the sibling herdrWorkspace.
687
+ const root = await deriveHerdrRoot(config.config_path, {
688
+ ...(options.root !== undefined ? { override: options.root } : {}),
689
+ ...(options.execGit ? { execGit: options.execGit } : {}),
690
+ });
691
+ // Lifecycle hooks are top-level config (GAIA-84); the executor runs them
692
+ // best-effort through its injected logger.
693
+ return new HerdrExecutor({
694
+ command: options.command ?? 'claude',
695
+ root,
696
+ ...(options.hooks ? { hooks: options.hooks } : {}),
697
+ ...(config.hooks ? { hooks: config.hooks } : {}),
698
+ ...(options.default ? { default: options.default } : {}),
699
+ ...(options.states ? { states: options.states } : {}),
700
+ }, defaultExec, deps.logger);
701
+ },
702
+ };
703
+ }
704
+ // Default export: this module also exports the `HerdrExecutor` class, so the
705
+ // config resolver's auto-pick (no `export:`) sees 2 function exports and
706
+ // would otherwise throw "specify export" — see conductor/src/config.ts
707
+ // `loadNamedPlugin`. The default export makes the factory win.
708
+ export default herdrExecutor;