@gaia-ai/addon-herdr 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { ExecutorCapabilities, ExecutorPlugin, GaiaExecutor, HookContext, HookName, HookResult, SpawnedSession, SpawnRunInput } from '@gaia-ai/conductor/contract';
1
+ import type { ExecutorCapabilities, ExecutorPlugin, GaiaExecutor, HookContext, HookName, HookResult, SpawnedSession, SpawnRunInput, WorktreeTeardown } from '@gaia-ai/conductor/contract';
2
2
  import type { ConductorLogger } from '@gaia-ai/core';
3
3
  import { type HerdrExecutorOptions } from './config.js';
4
4
  import { type HerdrExec } from './pane-layout.js';
@@ -87,11 +87,21 @@ export declare class HerdrExecutor implements GaiaExecutor {
87
87
  * 1. restores owner-write (Drupal hardens web/sites/default 0555 +
88
88
  * settings.php 0444 — chmod-restorable by the owning user, no sudo);
89
89
  * 2. `git worktree remove --force <path>` from the PARENT repo root, which
90
- * unlinks the directory AND drops the `.git/worktrees/<name>` entry;
90
+ * unlinks the directory AND drops the `.git/worktrees/<name>` entry
91
+ * but ONLY when the path really is a registered worktree (GAIA-293):
92
+ * against an unregistered path git exits 128 and the shared exec helper
93
+ * logs `ERROR: exec failed` for a condition this code expects and
94
+ * handles. The guard lives here rather than at the caller because this
95
+ * method has a second call site (the path resolved via
96
+ * {@link findGitWorktree});
91
97
  * 3. guarantees the directory is gone (a raw force-remove) in case git
92
- * refused (the path was never a registered worktree) — a no-op if git
93
- * already removed it;
98
+ * refused or was skipped — a no-op if git already removed it;
94
99
  * 4. prunes the repo so any now-stale admin entry is swept.
100
+ *
101
+ * Returns whether the directory is gone afterwards. A reclaim that cannot
102
+ * remove it REPORTS instead of throwing (GAIA-293, spec D5): the raw EACCES
103
+ * used to propagate all the way out of {@link removeWorktree} and reach the
104
+ * reaper as an opaque exception.
95
105
  */
96
106
  private gitAwareRemove;
97
107
  /**
@@ -99,10 +109,40 @@ export declare class HerdrExecutor implements GaiaExecutor {
99
109
  * persisted, the PARENT repo's `git worktree list` still knows it (GAIA-141
100
110
  * RC-2 — the ~28 never-touched leftovers). Resolve the lost worktree's on-disk
101
111
  * path there, preferring an exact `worktreePath` match, then a branch match;
102
- * never the main worktree (the repo root itself). Returns null when nothing
103
- * matches. Best-effort: a git failure resolves to null.
112
+ * never the main worktree. Returns null when nothing matches. Best-effort: a
113
+ * git failure resolves to null.
114
+ *
115
+ * `cwd` is only where git is RUN. The main worktree is excluded by git's own
116
+ * porcelain ordering (it is always the first record) rather than by comparing
117
+ * against `cwd`, because the two coincide only when a parent root is
118
+ * configured. Without one the probe falls back to running git inside the
119
+ * worktree itself, and a `path !== cwd` filter then dropped the very entry
120
+ * being asked about — so `registered: true` was unreachable for a rootless
121
+ * executor (GAIA-293 review, finding 1). The configured root is excluded too,
122
+ * belt and braces, so a caller that anchors elsewhere still never resolves the
123
+ * main checkout as a ticket worktree.
104
124
  */
105
125
  private findGitWorktree;
126
+ /**
127
+ * Ask — passively — whether this worktree still exists, in BOTH dimensions
128
+ * that can come apart (GAIA-293): git's registration, and the directory on
129
+ * disk. The reported failure was exactly a case where they disagreed — the
130
+ * directory was there, the registration was not — so a single-dimension
131
+ * check cannot decide what to do.
132
+ *
133
+ * Passive by construction: it writes nothing, calls no herdr, and NEVER
134
+ * throws. A git failure yields `registered: false`, degrading the verdict to
135
+ * the on-disk answer rather than failing the teardown.
136
+ *
137
+ * The persisted `worktreePath` is the stable key and wins when given;
138
+ * `registered` then describes THAT path, never a branch match pointing
139
+ * somewhere else (the checked-out branch is mutable — GAIA-114).
140
+ *
141
+ * git is run at the configured parent root when there is one, else inside the
142
+ * worktree itself — `git worktree list` enumerates the whole repo from any of
143
+ * its worktrees, so the rootless answer is the same list.
144
+ */
145
+ private probeWorktree;
106
146
  /**
107
147
  * GAIA-166 focus guard. herdr's `worktree remove` deletes the ticket
108
148
  * worktree's on-disk directory; if that workspace is the CURRENTLY FOCUSED
@@ -131,20 +171,26 @@ export declare class HerdrExecutor implements GaiaExecutor {
131
171
  * mean "error"; it means herdr no longer tracks this ticket's worktree. When
132
172
  * herdr has lost track, teardown is resolved off git itself (never a foreign
133
173
  * worktree), git-awarely so no `.git/worktrees/<name>` metadata is orphaned
134
- * (GAIA-141 RC-1):
135
- * - `worktreePath` still on disk reclaim it git-awarely;
136
- * - `worktreePath` known but gone from disk → already torn down; sweep any
137
- * orphaned metadata and report verified;
138
- * - `worktreePath` unknown resolve the lost worktree via the parent repo's
139
- * `git worktree list` (GAIA-141 RC-2) and reclaim it git-awarely.
174
+ * (GAIA-141 RC-1). Since GAIA-293 that resolution starts by ASKING — a passive
175
+ * {@link probeWorktree} reads both dimensions of existence and the four cases
176
+ * branch explicitly:
177
+ * - neither registered nor on disk → nothing to do at all;
178
+ * - registered but gone from disk sweep the prunable admin entry;
179
+ * - on disk but not registered reclaim the directory, WITHOUT asking git
180
+ * to remove a worktree it does not know (the bogus `ERROR: exec failed`);
181
+ * - both → reclaim it git-awarely, `git worktree remove` included.
182
+ * A path that resolves in NEITHER dimension is not "absent" — it is
183
+ * unresolved, and still reported `unverified` (GAIA-141 RC-2).
140
184
  *
141
- * Returns whether the teardown was VERIFIED on THIS host: `true` when the
142
- * worktree is gone (removed by us, an on-disk orphan reclaimed, or confirmed
143
- * already absent), `false` ONLY when nothing was resolvable and the teardown
144
- * could not be verified the reaper then leaves the ticket on its work list
145
- * for a later retry instead of falsely flagging it cleaned (GAIA-141 RC-2).
185
+ * Reports what the teardown DID (GAIA-293): `removed` when the worktree is
186
+ * gone because this host removed it (or reclaimed an on-disk orphan, or swept
187
+ * the prunable admin entry it left behind), `already_absent` when a RESOLVED
188
+ * worktree existed in neither dimension, and `unverified` ONLY when nothing
189
+ * was resolvable or the directory could not be reclaimed the reaper then
190
+ * leaves the ticket on its work list for a later retry instead of falsely
191
+ * flagging it cleaned (GAIA-141 RC-2).
146
192
  */
147
- removeWorktree(branch: string, worktreePath?: string): Promise<boolean>;
193
+ removeWorktree(branch: string, worktreePath?: string): Promise<WorktreeTeardown>;
148
194
  startRun(input: SpawnRunInput): Promise<SpawnedSession>;
149
195
  /** Best-effort cleanup: close only the just-created tab. Swallows errors. */
150
196
  private rollback;
package/dist/src/index.js CHANGED
@@ -326,43 +326,78 @@ export class HerdrExecutor {
326
326
  * 1. restores owner-write (Drupal hardens web/sites/default 0555 +
327
327
  * settings.php 0444 — chmod-restorable by the owning user, no sudo);
328
328
  * 2. `git worktree remove --force <path>` from the PARENT repo root, which
329
- * unlinks the directory AND drops the `.git/worktrees/<name>` entry;
329
+ * unlinks the directory AND drops the `.git/worktrees/<name>` entry
330
+ * but ONLY when the path really is a registered worktree (GAIA-293):
331
+ * against an unregistered path git exits 128 and the shared exec helper
332
+ * logs `ERROR: exec failed` for a condition this code expects and
333
+ * handles. The guard lives here rather than at the caller because this
334
+ * method has a second call site (the path resolved via
335
+ * {@link findGitWorktree});
330
336
  * 3. guarantees the directory is gone (a raw force-remove) in case git
331
- * refused (the path was never a registered worktree) — a no-op if git
332
- * already removed it;
337
+ * refused or was skipped — a no-op if git already removed it;
333
338
  * 4. prunes the repo so any now-stale admin entry is swept.
339
+ *
340
+ * Returns whether the directory is gone afterwards. A reclaim that cannot
341
+ * remove it REPORTS instead of throwing (GAIA-293, spec D5): the raw EACCES
342
+ * used to propagate all the way out of {@link removeWorktree} and reach the
343
+ * reaper as an opaque exception.
334
344
  */
335
- async gitAwareRemove(path) {
345
+ async gitAwareRemove(path, registered) {
336
346
  restoreWritable(path);
337
347
  const root = this.gitRoot(path);
338
- try {
339
- await this.runGit(['worktree', 'remove', '--force', path], root);
340
- }
341
- catch {
342
- // Not a registered worktree (or git refused) — reclaim the dir directly.
348
+ if (registered) {
349
+ try {
350
+ await this.runGit(['worktree', 'remove', '--force', path], root);
351
+ }
352
+ catch (err) {
353
+ // Registered yet refused — genuinely unexpected. Say so, then reclaim
354
+ // the directory directly below.
355
+ this.logger.warn({ path, err: String(err) }, 'git worktree remove failed on a registered worktree');
356
+ }
343
357
  }
344
358
  if (existsSync(path)) {
345
- forceRemoveDir(path);
359
+ try {
360
+ forceRemoveDir(path);
361
+ }
362
+ catch (err) {
363
+ // GAIA-293 (spec D5): a leftover we cannot unlink — most likely
364
+ // foreign-owned (a container wrote it under another uid), which chmod
365
+ // cannot repair. Name the failing step; the reaper retries.
366
+ this.logger.warn({ path, err: String(err) }, 'worktree directory reclaim failed');
367
+ return false;
368
+ }
346
369
  }
347
370
  await this.pruneWorktrees(root);
371
+ return true;
348
372
  }
349
373
  /**
350
374
  * When herdr has lost track of a worktree and its `worktreePath` was never
351
375
  * persisted, the PARENT repo's `git worktree list` still knows it (GAIA-141
352
376
  * RC-2 — the ~28 never-touched leftovers). Resolve the lost worktree's on-disk
353
377
  * path there, preferring an exact `worktreePath` match, then a branch match;
354
- * never the main worktree (the repo root itself). Returns null when nothing
355
- * matches. Best-effort: a git failure resolves to null.
378
+ * never the main worktree. Returns null when nothing matches. Best-effort: a
379
+ * git failure resolves to null.
380
+ *
381
+ * `cwd` is only where git is RUN. The main worktree is excluded by git's own
382
+ * porcelain ordering (it is always the first record) rather than by comparing
383
+ * against `cwd`, because the two coincide only when a parent root is
384
+ * configured. Without one the probe falls back to running git inside the
385
+ * worktree itself, and a `path !== cwd` filter then dropped the very entry
386
+ * being asked about — so `registered: true` was unreachable for a rootless
387
+ * executor (GAIA-293 review, finding 1). The configured root is excluded too,
388
+ * belt and braces, so a caller that anchors elsewhere still never resolves the
389
+ * main checkout as a ticket worktree.
356
390
  */
357
- async findGitWorktree(root, branch, worktreePath) {
391
+ async findGitWorktree(cwd, branch, worktreePath) {
358
392
  let output;
359
393
  try {
360
- output = await this.runGit(['worktree', 'list', '--porcelain'], root);
394
+ output = await this.runGit(['worktree', 'list', '--porcelain'], cwd);
361
395
  }
362
396
  catch {
363
397
  return null;
364
398
  }
365
- const candidates = parseGitWorktreePorcelain(output).filter((e) => normPath(e.path) !== normPath(root));
399
+ const mainRoot = this.options.root;
400
+ const candidates = parseGitWorktreePorcelain(output).filter((e, i) => i > 0 && (!mainRoot || normPath(e.path) !== normPath(mainRoot)));
366
401
  if (worktreePath) {
367
402
  const want = normPath(worktreePath);
368
403
  const byPath = candidates.find((e) => normPath(e.path) === want);
@@ -372,6 +407,34 @@ export class HerdrExecutor {
372
407
  }
373
408
  return candidates.find((e) => e.branch === branch)?.path ?? null;
374
409
  }
410
+ /**
411
+ * Ask — passively — whether this worktree still exists, in BOTH dimensions
412
+ * that can come apart (GAIA-293): git's registration, and the directory on
413
+ * disk. The reported failure was exactly a case where they disagreed — the
414
+ * directory was there, the registration was not — so a single-dimension
415
+ * check cannot decide what to do.
416
+ *
417
+ * Passive by construction: it writes nothing, calls no herdr, and NEVER
418
+ * throws. A git failure yields `registered: false`, degrading the verdict to
419
+ * the on-disk answer rather than failing the teardown.
420
+ *
421
+ * The persisted `worktreePath` is the stable key and wins when given;
422
+ * `registered` then describes THAT path, never a branch match pointing
423
+ * somewhere else (the checked-out branch is mutable — GAIA-114).
424
+ *
425
+ * git is run at the configured parent root when there is one, else inside the
426
+ * worktree itself — `git worktree list` enumerates the whole repo from any of
427
+ * its worktrees, so the rootless answer is the same list.
428
+ */
429
+ async probeWorktree(branch, worktreePath) {
430
+ const cwd = this.options.root ?? worktreePath ?? null;
431
+ const gitPath = cwd
432
+ ? await this.findGitWorktree(cwd, branch, worktreePath)
433
+ : null;
434
+ const path = worktreePath ?? gitPath;
435
+ const registered = gitPath !== null && path !== null && normPath(gitPath) === normPath(path);
436
+ return { registered, onDisk: path !== null && existsSync(path), path };
437
+ }
375
438
  /**
376
439
  * GAIA-166 focus guard. herdr's `worktree remove` deletes the ticket
377
440
  * worktree's on-disk directory; if that workspace is the CURRENTLY FOCUSED
@@ -432,50 +495,62 @@ export class HerdrExecutor {
432
495
  * mean "error"; it means herdr no longer tracks this ticket's worktree. When
433
496
  * herdr has lost track, teardown is resolved off git itself (never a foreign
434
497
  * worktree), git-awarely so no `.git/worktrees/<name>` metadata is orphaned
435
- * (GAIA-141 RC-1):
436
- * - `worktreePath` still on disk reclaim it git-awarely;
437
- * - `worktreePath` known but gone from disk → already torn down; sweep any
438
- * orphaned metadata and report verified;
439
- * - `worktreePath` unknown resolve the lost worktree via the parent repo's
440
- * `git worktree list` (GAIA-141 RC-2) and reclaim it git-awarely.
498
+ * (GAIA-141 RC-1). Since GAIA-293 that resolution starts by ASKING — a passive
499
+ * {@link probeWorktree} reads both dimensions of existence and the four cases
500
+ * branch explicitly:
501
+ * - neither registered nor on disk → nothing to do at all;
502
+ * - registered but gone from disk sweep the prunable admin entry;
503
+ * - on disk but not registered reclaim the directory, WITHOUT asking git
504
+ * to remove a worktree it does not know (the bogus `ERROR: exec failed`);
505
+ * - both → reclaim it git-awarely, `git worktree remove` included.
506
+ * A path that resolves in NEITHER dimension is not "absent" — it is
507
+ * unresolved, and still reported `unverified` (GAIA-141 RC-2).
441
508
  *
442
- * Returns whether the teardown was VERIFIED on THIS host: `true` when the
443
- * worktree is gone (removed by us, an on-disk orphan reclaimed, or confirmed
444
- * already absent), `false` ONLY when nothing was resolvable and the teardown
445
- * could not be verified the reaper then leaves the ticket on its work list
446
- * for a later retry instead of falsely flagging it cleaned (GAIA-141 RC-2).
509
+ * Reports what the teardown DID (GAIA-293): `removed` when the worktree is
510
+ * gone because this host removed it (or reclaimed an on-disk orphan, or swept
511
+ * the prunable admin entry it left behind), `already_absent` when a RESOLVED
512
+ * worktree existed in neither dimension, and `unverified` ONLY when nothing
513
+ * was resolvable or the directory could not be reclaimed the reaper then
514
+ * leaves the ticket on its work list for a later retry instead of falsely
515
+ * flagging it cleaned (GAIA-141 RC-2).
447
516
  */
448
517
  async removeWorktree(branch, worktreePath) {
449
518
  const entries = await this.listWorktrees();
450
519
  const entry = resolveWorktreeEntry(entries, branch, worktreePath);
451
520
  if (!entry) {
452
- // herdr no longer tracks it. Resolve teardown off git, never a foreign
453
- // worktree, and git-awarely so no `.git/worktrees/<name>` is orphaned.
454
- if (worktreePath && existsSync(worktreePath)) {
455
- await this.gitAwareRemove(worktreePath);
456
- return true;
457
- }
458
- if (worktreePath) {
459
- // Path known but already gone from disk → verified (idempotent) teardown;
460
- // still sweep any orphaned metadata it left behind.
461
- if (this.options.root) {
462
- await this.pruneWorktrees(this.options.root);
521
+ // herdr no longer tracks it. GAIA-293: ask before acting. An
522
+ // already-absent worktree is a FINISHED teardown, not a failure to retry
523
+ // and running `git worktree remove` against an unregistered path is
524
+ // what produced the bogus `ERROR: exec failed` (git exits 128 on an
525
+ // expected, handled condition). herdr still tracking the worktree is
526
+ // itself evidence that something is there to release, which is why the
527
+ // probe governs only this lost-track path and the tracked path below
528
+ // keeps its behaviour unchanged.
529
+ const probe = await this.probeWorktree(branch, worktreePath);
530
+ if (probe.path) {
531
+ if (!probe.onDisk) {
532
+ if (!probe.registered) {
533
+ this.logger.info({ branch, worktreePath: probe.path }, 'worktree already absent — nothing to tear down');
534
+ return 'already_absent';
535
+ }
536
+ // A prunable admin entry whose directory has vanished: the metadata
537
+ // sweep IS the teardown here (GAIA-141).
538
+ await this.pruneWorktrees(this.gitRoot(probe.path));
539
+ return 'removed';
463
540
  }
464
- return true;
541
+ return (await this.gitAwareRemove(probe.path, probe.registered))
542
+ ? 'removed'
543
+ : 'unverified';
465
544
  }
466
- // Path unknown (never persisted). The parent repo's git worktree list may
467
- // still know the lost worktree resolve + reclaim it (GAIA-141 RC-2).
545
+ // Nothing resolvable in either dimension the path was never persisted
546
+ // and git does not know the branch either. Sweep pre-existing orphans,
547
+ // then report the teardown UNVERIFIED so the reaper retries rather than
548
+ // falsely cleaning (GAIA-141 RC-2). NOT `already_absent`: absence can
549
+ // only be claimed about a worktree that was actually located.
468
550
  if (this.options.root) {
469
- const gitPath = await this.findGitWorktree(this.options.root, branch);
470
- if (gitPath) {
471
- await this.gitAwareRemove(gitPath);
472
- return true;
473
- }
474
- // Nothing resolvable — sweep pre-existing orphans, then report the
475
- // teardown UNVERIFIED so the reaper retries rather than falsely cleaning.
476
551
  await this.pruneWorktrees(this.options.root);
477
552
  }
478
- return false;
553
+ return 'unverified';
479
554
  }
480
555
  let workspaceId = entry.open_workspace_id ?? null;
481
556
  if (!workspaceId) {
@@ -509,7 +584,7 @@ export class HerdrExecutor {
509
584
  // stranded in a deleted cwd. If a needed focus switch can't be made safely,
510
585
  // abort UNVERIFIED so the reaper retries rather than stranding the user.
511
586
  if (!(await this.ensureFocusSafeToRemove(workspaceId))) {
512
- return false;
587
+ return 'unverified';
513
588
  }
514
589
  // Drupal hardens web/sites/default (0555) and settings.php/.htaccess (0444);
515
590
  // herdr's remove performs the actual unlink and would hit EACCES on those
@@ -527,14 +602,14 @@ export class HerdrExecutor {
527
602
  // report the teardown UNVERIFIED (false) so the reaper retries instead of
528
603
  // falsely flagging the ticket cleaned.
529
604
  if (existsSync(entry.path)) {
530
- return false;
605
+ return 'unverified';
531
606
  }
532
607
  // Sweep any orphaned `.git/worktrees/<name>` admin entries at the parent
533
608
  // root (this teardown's and any pre-existing prunable ones — GAIA-141).
534
609
  if (this.options.root) {
535
610
  await this.pruneWorktrees(this.options.root);
536
611
  }
537
- return true;
612
+ return 'removed';
538
613
  }
539
614
  async startRun(input) {
540
615
  const branchName = input.ticket.branchName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/addon-herdr",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "GAIA herdr integration: spawn executor + per-ticket worktree workspace.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,10 +20,10 @@
20
20
  "directory": "gaia-cli/addons/herdr"
21
21
  },
22
22
  "dependencies": {
23
- "@gaia-ai/addon-workspace-git": "^0.7.0"
23
+ "@gaia-ai/addon-workspace-git": "^0.9.0"
24
24
  },
25
25
  "peerDependencies": {
26
- "@gaia-ai/conductor": "^0.7.0",
27
- "@gaia-ai/core": "^0.7.0"
26
+ "@gaia-ai/conductor": "^0.9.0",
27
+ "@gaia-ai/core": "^0.9.0"
28
28
  }
29
29
  }