gsd-pi 2.31.2-dev.91f95cf → 2.31.2-dev.c8d7e03

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/dist/cli.js CHANGED
@@ -359,16 +359,16 @@ if (cliFlags.messages[0] === 'worktree' || cliFlags.messages[0] === 'wt') {
359
359
  const sub = cliFlags.messages[1];
360
360
  const subArgs = cliFlags.messages.slice(2);
361
361
  if (!sub || sub === 'list') {
362
- handleList(process.cwd());
362
+ await handleList(process.cwd());
363
363
  }
364
364
  else if (sub === 'merge') {
365
365
  await handleMerge(process.cwd(), subArgs);
366
366
  }
367
367
  else if (sub === 'clean') {
368
- handleClean(process.cwd());
368
+ await handleClean(process.cwd());
369
369
  }
370
370
  else if (sub === 'remove' || sub === 'rm') {
371
- handleRemove(process.cwd(), subArgs);
371
+ await handleRemove(process.cwd(), subArgs);
372
372
  }
373
373
  else {
374
374
  process.stderr.write(`Unknown worktree command: ${sub}\n`);
@@ -381,7 +381,7 @@ if (cliFlags.messages[0] === 'worktree' || cliFlags.messages[0] === 'wt') {
381
381
  // ---------------------------------------------------------------------------
382
382
  if (cliFlags.worktree) {
383
383
  const { handleWorktreeFlag } = await import('./worktree-cli.js');
384
- handleWorktreeFlag(cliFlags.worktree);
384
+ await handleWorktreeFlag(cliFlags.worktree);
385
385
  }
386
386
  // ---------------------------------------------------------------------------
387
387
  // Active worktree banner — remind user of unmerged worktrees on normal launch
@@ -389,7 +389,7 @@ if (cliFlags.worktree) {
389
389
  if (!cliFlags.worktree && !isPrintMode) {
390
390
  try {
391
391
  const { handleStatusBanner } = await import('./worktree-cli.js');
392
- handleStatusBanner(process.cwd());
392
+ await handleStatusBanner(process.cwd());
393
393
  }
394
394
  catch { /* non-fatal */ }
395
395
  }
@@ -833,6 +833,9 @@ export async function handleAgentEnd(
833
833
  // permanently stalled with no unit running and no watchdog set.
834
834
  if (s.pendingAgentEndRetry) {
835
835
  s.pendingAgentEndRetry = false;
836
+ // Clear gap watchdog from the previous cycle to prevent concurrent
837
+ // dispatch when the deferred handleAgentEnd calls dispatchNextUnit (#1272).
838
+ clearDispatchGapWatchdog();
836
839
  setImmediate(() => {
837
840
  handleAgentEnd(ctx, pi).catch((err) => {
838
841
  const msg = err instanceof Error ? err.message : String(err);
@@ -975,8 +978,12 @@ async function dispatchNextUnit(
975
978
  return;
976
979
  }
977
980
 
978
- // Reentrancy guard
979
- if (s.dispatching && s.skipDepth === 0) {
981
+ // Reentrancy guard — unconditional to prevent concurrent dispatch from
982
+ // gap watchdog or pendingAgentEndRetry during skip chains (#1272).
983
+ // Previously the guard was bypassed when skipDepth > 0, but the recursive
984
+ // skip chain's inner finally block resets s.dispatching = false before the
985
+ // outer call's finally runs, opening a window for concurrent entry.
986
+ if (s.dispatching) {
980
987
  debugLog("dispatchNextUnit reentrancy guard — another dispatch in progress, bailing");
981
988
  return;
982
989
  }
@@ -1448,8 +1455,12 @@ async function dispatchNextUnit(
1448
1455
  }
1449
1456
 
1450
1457
  if (dispatchResult.action !== "dispatch") {
1451
- await new Promise(r => setImmediate(r));
1452
- await dispatchNextUnit(ctx, pi);
1458
+ // Defer re-dispatch to next microtask so s.dispatching is released first,
1459
+ // preventing reentrancy guard bypass during concurrent entry (#1272).
1460
+ setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => {
1461
+ ctx.ui.notify(`Deferred dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1462
+ pauseAuto(ctx, pi).catch(() => {});
1463
+ }));
1453
1464
  return;
1454
1465
  }
1455
1466
 
@@ -1467,8 +1478,10 @@ async function dispatchNextUnit(
1467
1478
  }
1468
1479
  if (preDispatchResult.action === "skip") {
1469
1480
  ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
1470
- await new Promise(r => setImmediate(r));
1471
- await dispatchNextUnit(ctx, pi);
1481
+ setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => {
1482
+ ctx.ui.notify(`Deferred dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1483
+ pauseAuto(ctx, pi).catch(() => {});
1484
+ }));
1472
1485
  return;
1473
1486
  }
1474
1487
  if (preDispatchResult.action === "replace") {
@@ -1499,9 +1512,16 @@ async function dispatchNextUnit(
1499
1512
  if (idempotencyResult.reason === "completed" || idempotencyResult.reason === "fallback-persisted" || idempotencyResult.reason === "phantom-loop-cleared" || idempotencyResult.reason === "evicted") {
1500
1513
  if (!s.active) return;
1501
1514
  s.skipDepth++;
1502
- await new Promise(r => setTimeout(r, idempotencyResult.reason === "phantom-loop-cleared" ? 50 : 150));
1503
- await dispatchNextUnit(ctx, pi);
1504
- s.skipDepth = Math.max(0, s.skipDepth - 1);
1515
+ const skipDelay = idempotencyResult.reason === "phantom-loop-cleared" ? 50 : 150;
1516
+ // Defer re-dispatch so s.dispatching is released first (#1272).
1517
+ setTimeout(() => {
1518
+ dispatchNextUnit(ctx, pi).catch(err => {
1519
+ ctx.ui.notify(`Deferred skip-dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1520
+ pauseAuto(ctx, pi).catch(() => {});
1521
+ }).finally(() => {
1522
+ s.skipDepth = Math.max(0, s.skipDepth - 1);
1523
+ });
1524
+ }, skipDelay);
1505
1525
  return;
1506
1526
  }
1507
1527
  } else if (idempotencyResult.action === "stop") {
@@ -1532,8 +1552,11 @@ async function dispatchNextUnit(
1532
1552
  return;
1533
1553
  }
1534
1554
  if (stuckResult.action === "recovered" && stuckResult.dispatchAgain) {
1535
- await new Promise(r => setImmediate(r));
1536
- await dispatchNextUnit(ctx, pi);
1555
+ // Defer re-dispatch so s.dispatching is released first (#1272).
1556
+ setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => {
1557
+ ctx.ui.notify(`Deferred recovery-dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1558
+ pauseAuto(ctx, pi).catch(() => {});
1559
+ }));
1537
1560
  return;
1538
1561
  }
1539
1562
 
@@ -1779,6 +1802,15 @@ export {
1779
1802
  export function _getUnitConsecutiveSkips(): Map<string, number> { return s.unitConsecutiveSkips; }
1780
1803
  export function _resetUnitConsecutiveSkips(): void { s.unitConsecutiveSkips.clear(); }
1781
1804
 
1805
+ /**
1806
+ * Test-only: expose dispatching / skipDepth state for reentrancy guard tests.
1807
+ * Not part of the public API.
1808
+ */
1809
+ export function _getDispatching(): boolean { return s.dispatching; }
1810
+ export function _setDispatching(v: boolean): void { s.dispatching = v; }
1811
+ export function _getSkipDepth(): number { return s.skipDepth; }
1812
+ export function _setSkipDepth(v: number): void { s.skipDepth = v; }
1813
+
1782
1814
  /**
1783
1815
  * Dispatch a hook unit directly, bypassing normal pre-dispatch hooks.
1784
1816
  * Used for manual hook triggers via /gsd run-hook.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * auto-reentrancy-guard.test.ts — Tests for the unconditional reentrancy guard.
3
+ *
4
+ * Regression for #1272: auto-mode stuck-loop where gap watchdog or
5
+ * pendingAgentEndRetry could enter dispatchNextUnit concurrently during
6
+ * recursive skip chains because the reentrancy guard was bypassed when
7
+ * skipDepth > 0.
8
+ *
9
+ * The fix makes the guard unconditional (`if (s.dispatching)` without
10
+ * `&& s.skipDepth === 0`), and defers recursive re-dispatch via
11
+ * setImmediate/setTimeout so s.dispatching is released first.
12
+ */
13
+
14
+ import {
15
+ _getDispatching,
16
+ _setDispatching,
17
+ _getSkipDepth,
18
+ _setSkipDepth,
19
+ } from "../auto.ts";
20
+ import { createTestContext } from "./test-helpers.ts";
21
+
22
+ const { assertEq, assertTrue, report } = createTestContext();
23
+
24
+ async function main(): Promise<void> {
25
+ // ─── Test-only accessors work ───────────────────────────────────────────
26
+ console.log("\n=== reentrancy guard: test accessors round-trip ===");
27
+ {
28
+ _setDispatching(false);
29
+ assertEq(_getDispatching(), false, "dispatching starts false");
30
+
31
+ _setDispatching(true);
32
+ assertEq(_getDispatching(), true, "dispatching set to true");
33
+
34
+ _setDispatching(false);
35
+ assertEq(_getDispatching(), false, "dispatching reset to false");
36
+ }
37
+
38
+ // ─── skipDepth accessors ────────────────────────────────────────────────
39
+ console.log("\n=== reentrancy guard: skipDepth accessors round-trip ===");
40
+ {
41
+ _setSkipDepth(0);
42
+ assertEq(_getSkipDepth(), 0, "skipDepth starts at 0");
43
+
44
+ _setSkipDepth(3);
45
+ assertEq(_getSkipDepth(), 3, "skipDepth set to 3");
46
+
47
+ _setSkipDepth(0);
48
+ assertEq(_getSkipDepth(), 0, "skipDepth reset to 0");
49
+ }
50
+
51
+ // ─── Guard blocks even when skipDepth > 0 (#1272 regression) ───────────
52
+ console.log("\n=== reentrancy guard: blocks when dispatching=true regardless of skipDepth ===");
53
+ {
54
+ // Simulate the scenario from #1272: dispatching=true + skipDepth>0
55
+ // The old guard (`if (s.dispatching && s.skipDepth === 0)`) would allow
56
+ // concurrent entry when skipDepth > 0. The fix makes the check
57
+ // unconditional on skipDepth.
58
+ _setDispatching(true);
59
+ _setSkipDepth(2);
60
+
61
+ // Verify dispatching is true — guard should block regardless of skipDepth
62
+ assertTrue(
63
+ _getDispatching() === true,
64
+ "dispatching flag is true during skip chain"
65
+ );
66
+
67
+ // The actual reentrancy guard in dispatchNextUnit checks:
68
+ // if (s.dispatching) { return; }
69
+ // We verify the state that would trigger the guard:
70
+ const wouldBlock = _getDispatching(); // unconditional check
71
+ const wouldBlockOld = _getDispatching() && _getSkipDepth() === 0; // old check
72
+
73
+ assertTrue(wouldBlock === true, "new guard blocks when dispatching=true, skipDepth=2");
74
+ assertTrue(wouldBlockOld === false, "old guard WOULD NOT block when dispatching=true, skipDepth=2 (the bug)");
75
+
76
+ // Clean up
77
+ _setDispatching(false);
78
+ _setSkipDepth(0);
79
+ }
80
+
81
+ // ─── Guard allows entry when dispatching=false ──────────────────────────
82
+ console.log("\n=== reentrancy guard: allows entry when dispatching=false ===");
83
+ {
84
+ _setDispatching(false);
85
+ _setSkipDepth(0);
86
+ assertTrue(!_getDispatching(), "guard allows entry when dispatching=false, skipDepth=0");
87
+
88
+ _setDispatching(false);
89
+ _setSkipDepth(3);
90
+ assertTrue(!_getDispatching(), "guard allows entry when dispatching=false, skipDepth=3");
91
+
92
+ _setSkipDepth(0);
93
+ }
94
+
95
+ // ─── skipDepth does not affect guard decision (the fix) ─────────────────
96
+ console.log("\n=== reentrancy guard: skipDepth is irrelevant to guard decision ===");
97
+ {
98
+ for (const depth of [0, 1, 2, 5]) {
99
+ _setDispatching(true);
100
+ _setSkipDepth(depth);
101
+ assertTrue(
102
+ _getDispatching() === true,
103
+ `guard blocks at skipDepth=${depth} when dispatching=true`
104
+ );
105
+ }
106
+
107
+ for (const depth of [0, 1, 2, 5]) {
108
+ _setDispatching(false);
109
+ _setSkipDepth(depth);
110
+ assertTrue(
111
+ _getDispatching() === false,
112
+ `guard allows at skipDepth=${depth} when dispatching=false`
113
+ );
114
+ }
115
+
116
+ // Clean up
117
+ _setDispatching(false);
118
+ _setSkipDepth(0);
119
+ }
120
+
121
+ report();
122
+ }
123
+
124
+ main().catch((err) => {
125
+ console.error(err);
126
+ process.exit(1);
127
+ });
@@ -12,7 +12,43 @@
12
12
  * On session exit (via session_shutdown event), auto-commits dirty work
13
13
  * so nothing is lost. The GSD extension reads GSD_CLI_WORKTREE to know
14
14
  * when a session was launched via -w.
15
+ *
16
+ * Note: Extension modules are .ts files loaded via jiti (not compiled to .js).
17
+ * We use createJiti() here because this module is compiled by tsc but imports
18
+ * from resources/extensions/gsd/ which are shipped as raw .ts (#1283).
15
19
  */
20
+ interface ExtensionModules {
21
+ createWorktree: (basePath: string, name: string) => {
22
+ path: string;
23
+ branch: string;
24
+ };
25
+ listWorktrees: (basePath: string) => Array<{
26
+ name: string;
27
+ path: string;
28
+ branch: string;
29
+ }>;
30
+ removeWorktree: (basePath: string, name: string, opts?: {
31
+ deleteBranch?: boolean;
32
+ }) => void;
33
+ mergeWorktreeToMain: (basePath: string, name: string, commitMessage: string) => void;
34
+ diffWorktreeAll: (basePath: string, name: string) => {
35
+ added: any[];
36
+ modified: any[];
37
+ removed: any[];
38
+ };
39
+ diffWorktreeNumstat: (basePath: string, name: string) => Array<{
40
+ added: number;
41
+ removed: number;
42
+ }>;
43
+ worktreeBranchName: (name: string) => string;
44
+ worktreePath: (basePath: string, name: string) => string;
45
+ runWorktreePostCreateHook: (basePath: string, wtPath: string) => string | null;
46
+ nativeHasChanges: (path: string) => boolean;
47
+ nativeDetectMainBranch: (basePath: string) => string;
48
+ nativeCommitCountBetween: (basePath: string, from: string, to: string) => number;
49
+ inferCommitType: (name: string) => string;
50
+ autoCommitCurrentBranch: (wtPath: string, reason: string, name: string) => void;
51
+ }
16
52
  interface WorktreeStatus {
17
53
  name: string;
18
54
  path: string;
@@ -24,11 +60,11 @@ interface WorktreeStatus {
24
60
  uncommitted: boolean;
25
61
  commits: number;
26
62
  }
27
- declare function getWorktreeStatus(basePath: string, name: string, wtPath: string): WorktreeStatus;
28
- declare function handleList(basePath: string): void;
63
+ declare function getWorktreeStatus(ext: ExtensionModules, basePath: string, name: string, wtPath: string): WorktreeStatus;
64
+ declare function handleList(basePath: string): Promise<void>;
29
65
  declare function handleMerge(basePath: string, args: string[]): Promise<void>;
30
- declare function handleClean(basePath: string): void;
31
- declare function handleRemove(basePath: string, args: string[]): void;
32
- declare function handleStatusBanner(basePath: string): void;
33
- declare function handleWorktreeFlag(worktreeFlag: boolean | string): void;
66
+ declare function handleClean(basePath: string): Promise<void>;
67
+ declare function handleRemove(basePath: string, args: string[]): Promise<void>;
68
+ declare function handleStatusBanner(basePath: string): Promise<void>;
69
+ declare function handleWorktreeFlag(worktreeFlag: boolean | string): Promise<void>;
34
70
  export { handleList, handleMerge, handleClean, handleRemove, handleStatusBanner, handleWorktreeFlag, getWorktreeStatus, };
@@ -12,18 +12,53 @@
12
12
  * On session exit (via session_shutdown event), auto-commits dirty work
13
13
  * so nothing is lost. The GSD extension reads GSD_CLI_WORKTREE to know
14
14
  * when a session was launched via -w.
15
+ *
16
+ * Note: Extension modules are .ts files loaded via jiti (not compiled to .js).
17
+ * We use createJiti() here because this module is compiled by tsc but imports
18
+ * from resources/extensions/gsd/ which are shipped as raw .ts (#1283).
15
19
  */
16
20
  import chalk from 'chalk';
17
- import { createWorktree, listWorktrees, removeWorktree, mergeWorktreeToMain, diffWorktreeAll, diffWorktreeNumstat, worktreeBranchName, } from './resources/extensions/gsd/worktree-manager.js';
18
- import { runWorktreePostCreateHook } from './resources/extensions/gsd/auto-worktree.js';
21
+ import { createJiti } from '@mariozechner/jiti';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { dirname, join } from 'node:path';
19
24
  import { generateWorktreeName } from './worktree-name-gen.js';
20
- import { nativeHasChanges, nativeDetectMainBranch, nativeCommitCountBetween, } from './resources/extensions/gsd/native-git-bridge.js';
21
- import { inferCommitType } from './resources/extensions/gsd/git-service.js';
22
25
  import { existsSync } from 'node:fs';
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const jiti = createJiti(fileURLToPath(import.meta.url), { interopDefault: true, debug: false });
28
+ // Lazily-loaded extension modules (loaded once on first use via jiti)
29
+ let _ext = null;
30
+ async function loadExtensionModules() {
31
+ if (_ext)
32
+ return _ext;
33
+ const [wtMgr, autoWt, gitBridge, gitSvc, wt] = await Promise.all([
34
+ jiti.import(join(__dirname, 'resources/extensions/gsd/worktree-manager.ts'), {}),
35
+ jiti.import(join(__dirname, 'resources/extensions/gsd/auto-worktree.ts'), {}),
36
+ jiti.import(join(__dirname, 'resources/extensions/gsd/native-git-bridge.ts'), {}),
37
+ jiti.import(join(__dirname, 'resources/extensions/gsd/git-service.ts'), {}),
38
+ jiti.import(join(__dirname, 'resources/extensions/gsd/worktree.ts'), {}),
39
+ ]);
40
+ _ext = {
41
+ createWorktree: wtMgr.createWorktree,
42
+ listWorktrees: wtMgr.listWorktrees,
43
+ removeWorktree: wtMgr.removeWorktree,
44
+ mergeWorktreeToMain: wtMgr.mergeWorktreeToMain,
45
+ diffWorktreeAll: wtMgr.diffWorktreeAll,
46
+ diffWorktreeNumstat: wtMgr.diffWorktreeNumstat,
47
+ worktreeBranchName: wtMgr.worktreeBranchName,
48
+ worktreePath: wtMgr.worktreePath,
49
+ runWorktreePostCreateHook: autoWt.runWorktreePostCreateHook,
50
+ nativeHasChanges: gitBridge.nativeHasChanges,
51
+ nativeDetectMainBranch: gitBridge.nativeDetectMainBranch,
52
+ nativeCommitCountBetween: gitBridge.nativeCommitCountBetween,
53
+ inferCommitType: gitSvc.inferCommitType,
54
+ autoCommitCurrentBranch: wt.autoCommitCurrentBranch,
55
+ };
56
+ return _ext;
57
+ }
23
58
  // ─── Status Helpers ─────────────────────────────────────────────────────────
24
- function getWorktreeStatus(basePath, name, wtPath) {
25
- const diff = diffWorktreeAll(basePath, name);
26
- const numstat = diffWorktreeNumstat(basePath, name);
59
+ function getWorktreeStatus(ext, basePath, name, wtPath) {
60
+ const diff = ext.diffWorktreeAll(basePath, name);
61
+ const numstat = ext.diffWorktreeNumstat(basePath, name);
27
62
  const filesChanged = diff.added.length + diff.modified.length + diff.removed.length;
28
63
  let linesAdded = 0;
29
64
  let linesRemoved = 0;
@@ -33,19 +68,19 @@ function getWorktreeStatus(basePath, name, wtPath) {
33
68
  }
34
69
  let uncommitted = false;
35
70
  try {
36
- uncommitted = existsSync(wtPath) && nativeHasChanges(wtPath);
71
+ uncommitted = existsSync(wtPath) && ext.nativeHasChanges(wtPath);
37
72
  }
38
73
  catch { /* */ }
39
74
  let commits = 0;
40
75
  try {
41
- const mainBranch = nativeDetectMainBranch(basePath);
42
- commits = nativeCommitCountBetween(basePath, mainBranch, worktreeBranchName(name));
76
+ const mainBranch = ext.nativeDetectMainBranch(basePath);
77
+ commits = ext.nativeCommitCountBetween(basePath, mainBranch, ext.worktreeBranchName(name));
43
78
  }
44
79
  catch { /* */ }
45
80
  return {
46
81
  name,
47
82
  path: wtPath,
48
- branch: worktreeBranchName(name),
83
+ branch: ext.worktreeBranchName(name),
49
84
  exists: existsSync(wtPath),
50
85
  filesChanged,
51
86
  linesAdded,
@@ -71,65 +106,66 @@ function formatStatus(s) {
71
106
  return lines.join('\n');
72
107
  }
73
108
  // ─── Subcommand: list ───────────────────────────────────────────────────────
74
- function handleList(basePath) {
75
- const worktrees = listWorktrees(basePath);
109
+ async function handleList(basePath) {
110
+ const ext = await loadExtensionModules();
111
+ const worktrees = ext.listWorktrees(basePath);
76
112
  if (worktrees.length === 0) {
77
113
  process.stderr.write(chalk.dim('No worktrees. Create one with: gsd -w <name>\n'));
78
114
  return;
79
115
  }
80
116
  process.stderr.write(chalk.bold('\nWorktrees\n\n'));
81
117
  for (const wt of worktrees) {
82
- const status = getWorktreeStatus(basePath, wt.name, wt.path);
118
+ const status = getWorktreeStatus(ext, basePath, wt.name, wt.path);
83
119
  process.stderr.write(formatStatus(status) + '\n\n');
84
120
  }
85
121
  }
86
122
  // ─── Subcommand: merge ──────────────────────────────────────────────────────
87
123
  async function handleMerge(basePath, args) {
124
+ const ext = await loadExtensionModules();
88
125
  const name = args[0];
89
126
  if (!name) {
90
127
  // If only one worktree exists, merge it
91
- const worktrees = listWorktrees(basePath);
128
+ const worktrees = ext.listWorktrees(basePath);
92
129
  if (worktrees.length === 1) {
93
- await doMerge(basePath, worktrees[0].name);
130
+ await doMerge(ext, basePath, worktrees[0].name);
94
131
  return;
95
132
  }
96
133
  process.stderr.write(chalk.red('Usage: gsd worktree merge <name>\n'));
97
134
  process.stderr.write(chalk.dim('Run gsd worktree list to see worktrees.\n'));
98
135
  process.exit(1);
99
136
  }
100
- await doMerge(basePath, name);
137
+ await doMerge(ext, basePath, name);
101
138
  }
102
- async function doMerge(basePath, name) {
103
- const worktrees = listWorktrees(basePath);
139
+ async function doMerge(ext, basePath, name) {
140
+ const worktrees = ext.listWorktrees(basePath);
104
141
  const wt = worktrees.find(w => w.name === name);
105
142
  if (!wt) {
106
143
  process.stderr.write(chalk.red(`Worktree "${name}" not found.\n`));
107
144
  process.exit(1);
108
145
  }
109
- const status = getWorktreeStatus(basePath, name, wt.path);
146
+ const status = getWorktreeStatus(ext, basePath, name, wt.path);
110
147
  if (status.filesChanged === 0 && !status.uncommitted) {
111
148
  process.stderr.write(chalk.dim(`Worktree "${name}" has no changes to merge.\n`));
112
149
  // Clean up empty worktree
113
- removeWorktree(basePath, name, { deleteBranch: true });
150
+ ext.removeWorktree(basePath, name, { deleteBranch: true });
114
151
  process.stderr.write(chalk.green(`Removed empty worktree ${chalk.bold(name)}.\n`));
115
152
  return;
116
153
  }
117
154
  // Auto-commit dirty work before merge
118
155
  if (status.uncommitted) {
119
156
  try {
120
- const { autoCommitCurrentBranch } = await import('./resources/extensions/gsd/worktree.js');
121
- autoCommitCurrentBranch(wt.path, 'worktree-merge', name);
157
+ ext.autoCommitCurrentBranch(wt.path, 'worktree-merge', name);
122
158
  process.stderr.write(chalk.dim(' Auto-committed dirty work before merge.\n'));
123
159
  }
124
160
  catch { /* best-effort */ }
125
161
  }
126
- const commitType = inferCommitType(name);
162
+ const commitType = ext.inferCommitType(name);
127
163
  const commitMessage = `${commitType}(${name}): merge worktree ${name}`;
128
- process.stderr.write(`\nMerging ${chalk.bold.cyan(name)} → ${chalk.magenta(nativeDetectMainBranch(basePath))}\n`);
164
+ process.stderr.write(`\nMerging ${chalk.bold.cyan(name)} → ${chalk.magenta(ext.nativeDetectMainBranch(basePath))}\n`);
129
165
  process.stderr.write(chalk.dim(` ${status.filesChanged} files, ${chalk.green(`+${status.linesAdded}`)} ${chalk.red(`-${status.linesRemoved}`)}\n\n`));
130
166
  try {
131
- mergeWorktreeToMain(basePath, name, commitMessage);
132
- removeWorktree(basePath, name, { deleteBranch: true });
167
+ ext.mergeWorktreeToMain(basePath, name, commitMessage);
168
+ ext.removeWorktree(basePath, name, { deleteBranch: true });
133
169
  process.stderr.write(chalk.green(`✓ Merged and cleaned up ${chalk.bold(name)}\n`));
134
170
  process.stderr.write(chalk.dim(` commit: ${commitMessage}\n`));
135
171
  }
@@ -141,18 +177,19 @@ async function doMerge(basePath, name) {
141
177
  }
142
178
  }
143
179
  // ─── Subcommand: clean ──────────────────────────────────────────────────────
144
- function handleClean(basePath) {
145
- const worktrees = listWorktrees(basePath);
180
+ async function handleClean(basePath) {
181
+ const ext = await loadExtensionModules();
182
+ const worktrees = ext.listWorktrees(basePath);
146
183
  if (worktrees.length === 0) {
147
184
  process.stderr.write(chalk.dim('No worktrees to clean.\n'));
148
185
  return;
149
186
  }
150
187
  let cleaned = 0;
151
188
  for (const wt of worktrees) {
152
- const status = getWorktreeStatus(basePath, wt.name, wt.path);
189
+ const status = getWorktreeStatus(ext, basePath, wt.name, wt.path);
153
190
  if (status.filesChanged === 0 && !status.uncommitted) {
154
191
  try {
155
- removeWorktree(basePath, wt.name, { deleteBranch: true });
192
+ ext.removeWorktree(basePath, wt.name, { deleteBranch: true });
156
193
  process.stderr.write(chalk.green(` ✓ Removed ${chalk.bold(wt.name)} (clean)\n`));
157
194
  cleaned++;
158
195
  }
@@ -167,19 +204,20 @@ function handleClean(basePath) {
167
204
  process.stderr.write(chalk.dim(`\nCleaned ${cleaned} worktree${cleaned === 1 ? '' : 's'}.\n`));
168
205
  }
169
206
  // ─── Subcommand: remove ─────────────────────────────────────────────────────
170
- function handleRemove(basePath, args) {
207
+ async function handleRemove(basePath, args) {
208
+ const ext = await loadExtensionModules();
171
209
  const name = args[0];
172
210
  if (!name) {
173
211
  process.stderr.write(chalk.red('Usage: gsd worktree remove <name>\n'));
174
212
  process.exit(1);
175
213
  }
176
- const worktrees = listWorktrees(basePath);
214
+ const worktrees = ext.listWorktrees(basePath);
177
215
  const wt = worktrees.find(w => w.name === name);
178
216
  if (!wt) {
179
217
  process.stderr.write(chalk.red(`Worktree "${name}" not found.\n`));
180
218
  process.exit(1);
181
219
  }
182
- const status = getWorktreeStatus(basePath, name, wt.path);
220
+ const status = getWorktreeStatus(ext, basePath, name, wt.path);
183
221
  if (status.filesChanged > 0 || status.uncommitted) {
184
222
  process.stderr.write(chalk.yellow(`⚠ Worktree "${name}" has unmerged changes (${status.filesChanged} files).\n`));
185
223
  process.stderr.write(chalk.yellow(' Use --force to remove anyway, or merge first: gsd worktree merge ' + name + '\n'));
@@ -187,17 +225,18 @@ function handleRemove(basePath, args) {
187
225
  process.exit(1);
188
226
  }
189
227
  }
190
- removeWorktree(basePath, name, { deleteBranch: true });
228
+ ext.removeWorktree(basePath, name, { deleteBranch: true });
191
229
  process.stderr.write(chalk.green(`✓ Removed worktree ${chalk.bold(name)}\n`));
192
230
  }
193
231
  // ─── Subcommand: status (default when no args) ─────────────────────────────
194
- function handleStatusBanner(basePath) {
195
- const worktrees = listWorktrees(basePath);
232
+ async function handleStatusBanner(basePath) {
233
+ const ext = await loadExtensionModules();
234
+ const worktrees = ext.listWorktrees(basePath);
196
235
  if (worktrees.length === 0)
197
236
  return;
198
237
  const withChanges = worktrees.filter(wt => {
199
238
  try {
200
- const diff = diffWorktreeAll(basePath, wt.name);
239
+ const diff = ext.diffWorktreeAll(basePath, wt.name);
201
240
  return diff.added.length + diff.modified.length + diff.removed.length > 0;
202
241
  }
203
242
  catch {
@@ -214,14 +253,15 @@ function handleStatusBanner(basePath) {
214
253
  chalk.dim('Resume: gsd -w <name> | Merge: gsd worktree merge <name> | List: gsd worktree list\n\n'));
215
254
  }
216
255
  // ─── -w flag: create/resume worktree for interactive session ────────────────
217
- function handleWorktreeFlag(worktreeFlag) {
256
+ async function handleWorktreeFlag(worktreeFlag) {
257
+ const ext = await loadExtensionModules();
218
258
  const basePath = process.cwd();
219
259
  // gsd -w (no name) — resume most recent worktree with changes, or create new
220
260
  if (worktreeFlag === true) {
221
- const existing = listWorktrees(basePath);
261
+ const existing = ext.listWorktrees(basePath);
222
262
  const withChanges = existing.filter(wt => {
223
263
  try {
224
- const diff = diffWorktreeAll(basePath, wt.name);
264
+ const diff = ext.diffWorktreeAll(basePath, wt.name);
225
265
  return diff.added.length + diff.modified.length + diff.removed.length > 0;
226
266
  }
227
267
  catch {
@@ -243,7 +283,7 @@ function handleWorktreeFlag(worktreeFlag) {
243
283
  // Multiple active worktrees — show them and ask user to pick
244
284
  process.stderr.write(chalk.yellow(`${withChanges.length} worktrees have unmerged changes:\n\n`));
245
285
  for (const wt of withChanges) {
246
- const status = getWorktreeStatus(basePath, wt.name, wt.path);
286
+ const status = getWorktreeStatus(ext, basePath, wt.name, wt.path);
247
287
  process.stderr.write(formatStatus(status) + '\n\n');
248
288
  }
249
289
  process.stderr.write(chalk.dim('Specify which one: gsd -w <name>\n'));
@@ -251,12 +291,12 @@ function handleWorktreeFlag(worktreeFlag) {
251
291
  }
252
292
  // No active worktrees — create a new one
253
293
  const name = generateWorktreeName();
254
- createAndEnter(basePath, name);
294
+ await createAndEnter(ext, basePath, name);
255
295
  return;
256
296
  }
257
297
  // gsd -w <name> — create or resume named worktree
258
298
  const name = worktreeFlag;
259
- const existing = listWorktrees(basePath);
299
+ const existing = ext.listWorktrees(basePath);
260
300
  const found = existing.find(wt => wt.name === name);
261
301
  if (found) {
262
302
  process.chdir(found.path);
@@ -267,13 +307,13 @@ function handleWorktreeFlag(worktreeFlag) {
267
307
  process.stderr.write(chalk.dim(` branch ${found.branch}\n\n`));
268
308
  }
269
309
  else {
270
- createAndEnter(basePath, name);
310
+ await createAndEnter(ext, basePath, name);
271
311
  }
272
312
  }
273
- function createAndEnter(basePath, name) {
313
+ async function createAndEnter(ext, basePath, name) {
274
314
  try {
275
- const info = createWorktree(basePath, name);
276
- const hookError = runWorktreePostCreateHook(basePath, info.path);
315
+ const info = ext.createWorktree(basePath, name);
316
+ const hookError = ext.runWorktreePostCreateHook(basePath, info.path);
277
317
  if (hookError) {
278
318
  process.stderr.write(chalk.yellow(`[gsd] ${hookError}\n`));
279
319
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.31.2-dev.91f95cf",
3
+ "version": "2.31.2-dev.c8d7e03",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -833,6 +833,9 @@ export async function handleAgentEnd(
833
833
  // permanently stalled with no unit running and no watchdog set.
834
834
  if (s.pendingAgentEndRetry) {
835
835
  s.pendingAgentEndRetry = false;
836
+ // Clear gap watchdog from the previous cycle to prevent concurrent
837
+ // dispatch when the deferred handleAgentEnd calls dispatchNextUnit (#1272).
838
+ clearDispatchGapWatchdog();
836
839
  setImmediate(() => {
837
840
  handleAgentEnd(ctx, pi).catch((err) => {
838
841
  const msg = err instanceof Error ? err.message : String(err);
@@ -975,8 +978,12 @@ async function dispatchNextUnit(
975
978
  return;
976
979
  }
977
980
 
978
- // Reentrancy guard
979
- if (s.dispatching && s.skipDepth === 0) {
981
+ // Reentrancy guard — unconditional to prevent concurrent dispatch from
982
+ // gap watchdog or pendingAgentEndRetry during skip chains (#1272).
983
+ // Previously the guard was bypassed when skipDepth > 0, but the recursive
984
+ // skip chain's inner finally block resets s.dispatching = false before the
985
+ // outer call's finally runs, opening a window for concurrent entry.
986
+ if (s.dispatching) {
980
987
  debugLog("dispatchNextUnit reentrancy guard — another dispatch in progress, bailing");
981
988
  return;
982
989
  }
@@ -1448,8 +1455,12 @@ async function dispatchNextUnit(
1448
1455
  }
1449
1456
 
1450
1457
  if (dispatchResult.action !== "dispatch") {
1451
- await new Promise(r => setImmediate(r));
1452
- await dispatchNextUnit(ctx, pi);
1458
+ // Defer re-dispatch to next microtask so s.dispatching is released first,
1459
+ // preventing reentrancy guard bypass during concurrent entry (#1272).
1460
+ setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => {
1461
+ ctx.ui.notify(`Deferred dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1462
+ pauseAuto(ctx, pi).catch(() => {});
1463
+ }));
1453
1464
  return;
1454
1465
  }
1455
1466
 
@@ -1467,8 +1478,10 @@ async function dispatchNextUnit(
1467
1478
  }
1468
1479
  if (preDispatchResult.action === "skip") {
1469
1480
  ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
1470
- await new Promise(r => setImmediate(r));
1471
- await dispatchNextUnit(ctx, pi);
1481
+ setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => {
1482
+ ctx.ui.notify(`Deferred dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1483
+ pauseAuto(ctx, pi).catch(() => {});
1484
+ }));
1472
1485
  return;
1473
1486
  }
1474
1487
  if (preDispatchResult.action === "replace") {
@@ -1499,9 +1512,16 @@ async function dispatchNextUnit(
1499
1512
  if (idempotencyResult.reason === "completed" || idempotencyResult.reason === "fallback-persisted" || idempotencyResult.reason === "phantom-loop-cleared" || idempotencyResult.reason === "evicted") {
1500
1513
  if (!s.active) return;
1501
1514
  s.skipDepth++;
1502
- await new Promise(r => setTimeout(r, idempotencyResult.reason === "phantom-loop-cleared" ? 50 : 150));
1503
- await dispatchNextUnit(ctx, pi);
1504
- s.skipDepth = Math.max(0, s.skipDepth - 1);
1515
+ const skipDelay = idempotencyResult.reason === "phantom-loop-cleared" ? 50 : 150;
1516
+ // Defer re-dispatch so s.dispatching is released first (#1272).
1517
+ setTimeout(() => {
1518
+ dispatchNextUnit(ctx, pi).catch(err => {
1519
+ ctx.ui.notify(`Deferred skip-dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1520
+ pauseAuto(ctx, pi).catch(() => {});
1521
+ }).finally(() => {
1522
+ s.skipDepth = Math.max(0, s.skipDepth - 1);
1523
+ });
1524
+ }, skipDelay);
1505
1525
  return;
1506
1526
  }
1507
1527
  } else if (idempotencyResult.action === "stop") {
@@ -1532,8 +1552,11 @@ async function dispatchNextUnit(
1532
1552
  return;
1533
1553
  }
1534
1554
  if (stuckResult.action === "recovered" && stuckResult.dispatchAgain) {
1535
- await new Promise(r => setImmediate(r));
1536
- await dispatchNextUnit(ctx, pi);
1555
+ // Defer re-dispatch so s.dispatching is released first (#1272).
1556
+ setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => {
1557
+ ctx.ui.notify(`Deferred recovery-dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1558
+ pauseAuto(ctx, pi).catch(() => {});
1559
+ }));
1537
1560
  return;
1538
1561
  }
1539
1562
 
@@ -1779,6 +1802,15 @@ export {
1779
1802
  export function _getUnitConsecutiveSkips(): Map<string, number> { return s.unitConsecutiveSkips; }
1780
1803
  export function _resetUnitConsecutiveSkips(): void { s.unitConsecutiveSkips.clear(); }
1781
1804
 
1805
+ /**
1806
+ * Test-only: expose dispatching / skipDepth state for reentrancy guard tests.
1807
+ * Not part of the public API.
1808
+ */
1809
+ export function _getDispatching(): boolean { return s.dispatching; }
1810
+ export function _setDispatching(v: boolean): void { s.dispatching = v; }
1811
+ export function _getSkipDepth(): number { return s.skipDepth; }
1812
+ export function _setSkipDepth(v: number): void { s.skipDepth = v; }
1813
+
1782
1814
  /**
1783
1815
  * Dispatch a hook unit directly, bypassing normal pre-dispatch hooks.
1784
1816
  * Used for manual hook triggers via /gsd run-hook.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * auto-reentrancy-guard.test.ts — Tests for the unconditional reentrancy guard.
3
+ *
4
+ * Regression for #1272: auto-mode stuck-loop where gap watchdog or
5
+ * pendingAgentEndRetry could enter dispatchNextUnit concurrently during
6
+ * recursive skip chains because the reentrancy guard was bypassed when
7
+ * skipDepth > 0.
8
+ *
9
+ * The fix makes the guard unconditional (`if (s.dispatching)` without
10
+ * `&& s.skipDepth === 0`), and defers recursive re-dispatch via
11
+ * setImmediate/setTimeout so s.dispatching is released first.
12
+ */
13
+
14
+ import {
15
+ _getDispatching,
16
+ _setDispatching,
17
+ _getSkipDepth,
18
+ _setSkipDepth,
19
+ } from "../auto.ts";
20
+ import { createTestContext } from "./test-helpers.ts";
21
+
22
+ const { assertEq, assertTrue, report } = createTestContext();
23
+
24
+ async function main(): Promise<void> {
25
+ // ─── Test-only accessors work ───────────────────────────────────────────
26
+ console.log("\n=== reentrancy guard: test accessors round-trip ===");
27
+ {
28
+ _setDispatching(false);
29
+ assertEq(_getDispatching(), false, "dispatching starts false");
30
+
31
+ _setDispatching(true);
32
+ assertEq(_getDispatching(), true, "dispatching set to true");
33
+
34
+ _setDispatching(false);
35
+ assertEq(_getDispatching(), false, "dispatching reset to false");
36
+ }
37
+
38
+ // ─── skipDepth accessors ────────────────────────────────────────────────
39
+ console.log("\n=== reentrancy guard: skipDepth accessors round-trip ===");
40
+ {
41
+ _setSkipDepth(0);
42
+ assertEq(_getSkipDepth(), 0, "skipDepth starts at 0");
43
+
44
+ _setSkipDepth(3);
45
+ assertEq(_getSkipDepth(), 3, "skipDepth set to 3");
46
+
47
+ _setSkipDepth(0);
48
+ assertEq(_getSkipDepth(), 0, "skipDepth reset to 0");
49
+ }
50
+
51
+ // ─── Guard blocks even when skipDepth > 0 (#1272 regression) ───────────
52
+ console.log("\n=== reentrancy guard: blocks when dispatching=true regardless of skipDepth ===");
53
+ {
54
+ // Simulate the scenario from #1272: dispatching=true + skipDepth>0
55
+ // The old guard (`if (s.dispatching && s.skipDepth === 0)`) would allow
56
+ // concurrent entry when skipDepth > 0. The fix makes the check
57
+ // unconditional on skipDepth.
58
+ _setDispatching(true);
59
+ _setSkipDepth(2);
60
+
61
+ // Verify dispatching is true — guard should block regardless of skipDepth
62
+ assertTrue(
63
+ _getDispatching() === true,
64
+ "dispatching flag is true during skip chain"
65
+ );
66
+
67
+ // The actual reentrancy guard in dispatchNextUnit checks:
68
+ // if (s.dispatching) { return; }
69
+ // We verify the state that would trigger the guard:
70
+ const wouldBlock = _getDispatching(); // unconditional check
71
+ const wouldBlockOld = _getDispatching() && _getSkipDepth() === 0; // old check
72
+
73
+ assertTrue(wouldBlock === true, "new guard blocks when dispatching=true, skipDepth=2");
74
+ assertTrue(wouldBlockOld === false, "old guard WOULD NOT block when dispatching=true, skipDepth=2 (the bug)");
75
+
76
+ // Clean up
77
+ _setDispatching(false);
78
+ _setSkipDepth(0);
79
+ }
80
+
81
+ // ─── Guard allows entry when dispatching=false ──────────────────────────
82
+ console.log("\n=== reentrancy guard: allows entry when dispatching=false ===");
83
+ {
84
+ _setDispatching(false);
85
+ _setSkipDepth(0);
86
+ assertTrue(!_getDispatching(), "guard allows entry when dispatching=false, skipDepth=0");
87
+
88
+ _setDispatching(false);
89
+ _setSkipDepth(3);
90
+ assertTrue(!_getDispatching(), "guard allows entry when dispatching=false, skipDepth=3");
91
+
92
+ _setSkipDepth(0);
93
+ }
94
+
95
+ // ─── skipDepth does not affect guard decision (the fix) ─────────────────
96
+ console.log("\n=== reentrancy guard: skipDepth is irrelevant to guard decision ===");
97
+ {
98
+ for (const depth of [0, 1, 2, 5]) {
99
+ _setDispatching(true);
100
+ _setSkipDepth(depth);
101
+ assertTrue(
102
+ _getDispatching() === true,
103
+ `guard blocks at skipDepth=${depth} when dispatching=true`
104
+ );
105
+ }
106
+
107
+ for (const depth of [0, 1, 2, 5]) {
108
+ _setDispatching(false);
109
+ _setSkipDepth(depth);
110
+ assertTrue(
111
+ _getDispatching() === false,
112
+ `guard allows at skipDepth=${depth} when dispatching=false`
113
+ );
114
+ }
115
+
116
+ // Clean up
117
+ _setDispatching(false);
118
+ _setSkipDepth(0);
119
+ }
120
+
121
+ report();
122
+ }
123
+
124
+ main().catch((err) => {
125
+ console.error(err);
126
+ process.exit(1);
127
+ });