@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.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/src/agent-host.d.ts +45 -0
- package/dist/src/agent-host.js +9 -0
- package/dist/src/agents.d.ts +3 -0
- package/dist/src/agents.js +132 -0
- package/dist/src/config.d.ts +77 -0
- package/dist/src/config.js +29 -0
- package/dist/src/frozen-marker.d.ts +8 -0
- package/dist/src/frozen-marker.js +26 -0
- package/dist/src/fs.d.ts +22 -0
- package/dist/src/fs.js +58 -0
- package/dist/src/index.d.ts +219 -0
- package/dist/src/index.js +809 -0
- package/dist/src/launch-script.d.ts +34 -0
- package/dist/src/launch-script.js +98 -0
- package/dist/src/pane-layout.d.ts +45 -0
- package/dist/src/pane-layout.js +78 -0
- package/dist/src/panes.d.ts +31 -0
- package/dist/src/panes.js +87 -0
- package/dist/src/preset.d.ts +6 -0
- package/dist/src/preset.js +18 -0
- package/dist/src/process-host.d.ts +6 -0
- package/dist/src/process-host.js +46 -0
- package/dist/src/root-anchor.d.ts +13 -0
- package/dist/src/root-anchor.js +12 -0
- package/dist/src/workspace-list.d.ts +19 -0
- package/dist/src/workspace-list.js +29 -0
- package/dist/src/workspace.d.ts +185 -0
- package/dist/src/workspace.js +415 -0
- package/package.json +27 -4
|
@@ -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;
|