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 +5 -5
- package/dist/resources/extensions/gsd/auto.ts +43 -11
- package/dist/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +127 -0
- package/dist/worktree-cli.d.ts +42 -6
- package/dist/worktree-cli.js +88 -48
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto.ts +43 -11
- package/src/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +127 -0
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
|
-
|
|
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
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
-
|
|
1536
|
-
|
|
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
|
+
});
|
package/dist/worktree-cli.d.ts
CHANGED
|
@@ -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, };
|
package/dist/worktree-cli.js
CHANGED
|
@@ -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 {
|
|
18
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
-
|
|
1536
|
-
|
|
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
|
+
});
|