@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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- 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 +111 -0
- package/dist/src/config.d.ts +55 -0
- package/dist/src/config.js +27 -0
- package/dist/src/fs.d.ts +22 -0
- package/dist/src/fs.js +58 -0
- package/dist/src/index.d.ts +172 -0
- package/dist/src/index.js +708 -0
- package/dist/src/pane-layout.d.ts +37 -0
- package/dist/src/pane-layout.js +69 -0
- package/dist/src/panes.d.ts +19 -0
- package/dist/src/panes.js +73 -0
- package/dist/src/preset.d.ts +3 -0
- package/dist/src/preset.js +9 -0
- package/dist/src/root-anchor.d.ts +26 -0
- package/dist/src/root-anchor.js +40 -0
- package/dist/src/workspace.d.ts +85 -0
- package/dist/src/workspace.js +261 -0
- package/package.json +29 -0
|
@@ -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;
|