gsd-pi 2.31.1-dev.ffe48ad → 2.31.2-dev.2453512
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/resources/extensions/gsd/auto-constants.ts +6 -0
- package/dist/resources/extensions/gsd/auto-dashboard.ts +20 -26
- package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +1 -6
- package/dist/resources/extensions/gsd/auto-dispatch.ts +18 -22
- package/dist/resources/extensions/gsd/auto-post-unit.ts +27 -32
- package/dist/resources/extensions/gsd/auto-prompts.ts +43 -46
- package/dist/resources/extensions/gsd/auto-start.ts +4 -4
- package/dist/resources/extensions/gsd/auto.ts +54 -33
- package/dist/resources/extensions/gsd/commands-workflow-templates.ts +3 -5
- package/dist/resources/extensions/gsd/git-service.ts +9 -0
- package/dist/resources/extensions/gsd/guided-flow-queue.ts +1 -8
- package/dist/resources/extensions/gsd/preferences-types.ts +8 -0
- package/dist/resources/extensions/gsd/preferences-validation.ts +3 -10
- package/dist/resources/extensions/gsd/prompts/run-uat.md +1 -42
- package/dist/resources/extensions/gsd/quick.ts +3 -5
- package/dist/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +127 -0
- package/dist/resources/extensions/gsd/tests/run-uat.test.ts +101 -2
- package/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-constants.ts +6 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +20 -26
- package/src/resources/extensions/gsd/auto-direct-dispatch.ts +1 -6
- package/src/resources/extensions/gsd/auto-dispatch.ts +18 -22
- package/src/resources/extensions/gsd/auto-post-unit.ts +27 -32
- package/src/resources/extensions/gsd/auto-prompts.ts +43 -46
- package/src/resources/extensions/gsd/auto-start.ts +4 -4
- package/src/resources/extensions/gsd/auto.ts +54 -33
- package/src/resources/extensions/gsd/commands-workflow-templates.ts +3 -5
- package/src/resources/extensions/gsd/git-service.ts +9 -0
- package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -8
- package/src/resources/extensions/gsd/preferences-types.ts +8 -0
- package/src/resources/extensions/gsd/preferences-validation.ts +3 -10
- package/src/resources/extensions/gsd/prompts/run-uat.md +1 -42
- package/src/resources/extensions/gsd/quick.ts +3 -5
- package/src/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +127 -0
- package/src/resources/extensions/gsd/tests/run-uat.test.ts +101 -2
|
@@ -118,7 +118,7 @@ import {
|
|
|
118
118
|
parseSliceBranch,
|
|
119
119
|
setActiveMilestoneId,
|
|
120
120
|
} from "./worktree.js";
|
|
121
|
-
import {
|
|
121
|
+
import { createGitService, type TaskCommitContext } from "./git-service.js";
|
|
122
122
|
import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
|
|
123
123
|
import { formatGitError } from "./git-self-heal.js";
|
|
124
124
|
import {
|
|
@@ -204,8 +204,7 @@ import type { CompletedUnit, CurrentUnit, UnitRouting, StartModel, PendingVerifi
|
|
|
204
204
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
205
205
|
const s = new AutoSession();
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
const STATE_REBUILD_MIN_INTERVAL_MS = 30_000;
|
|
207
|
+
import { STATE_REBUILD_MIN_INTERVAL_MS } from "./auto-constants.js";
|
|
209
208
|
|
|
210
209
|
export function shouldUseWorktreeIsolation(): boolean {
|
|
211
210
|
const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
|
|
@@ -462,7 +461,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
|
|
|
462
461
|
try { autoCommitCurrentBranch(s.basePath, "stop", s.currentMilestoneId); } catch (e) { debugLog("stop-auto-commit-failed", { error: e instanceof Error ? e.message : String(e) }); }
|
|
463
462
|
teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId, { preserveBranch: true });
|
|
464
463
|
s.basePath = s.originalBasePath;
|
|
465
|
-
s.gitService =
|
|
464
|
+
s.gitService = createGitService(s.basePath);
|
|
466
465
|
ctx?.ui.notify("Exited auto-worktree (branch preserved for resume).", "info");
|
|
467
466
|
} catch (err) {
|
|
468
467
|
ctx?.ui.notify(
|
|
@@ -626,12 +625,12 @@ export async function startAuto(
|
|
|
626
625
|
if (existingWtPath) {
|
|
627
626
|
const wtPath = enterAutoWorktree(s.originalBasePath, s.currentMilestoneId);
|
|
628
627
|
s.basePath = wtPath;
|
|
629
|
-
s.gitService =
|
|
628
|
+
s.gitService = createGitService(s.basePath);
|
|
630
629
|
ctx.ui.notify(`Re-entered auto-worktree at ${wtPath}`, "info");
|
|
631
630
|
} else {
|
|
632
631
|
const wtPath = createAutoWorktree(s.originalBasePath, s.currentMilestoneId);
|
|
633
632
|
s.basePath = wtPath;
|
|
634
|
-
s.gitService =
|
|
633
|
+
s.gitService = createGitService(s.basePath);
|
|
635
634
|
ctx.ui.notify(`Recreated auto-worktree at ${wtPath}`, "info");
|
|
636
635
|
}
|
|
637
636
|
} catch (err) {
|
|
@@ -834,6 +833,9 @@ export async function handleAgentEnd(
|
|
|
834
833
|
// permanently stalled with no unit running and no watchdog set.
|
|
835
834
|
if (s.pendingAgentEndRetry) {
|
|
836
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();
|
|
837
839
|
setImmediate(() => {
|
|
838
840
|
handleAgentEnd(ctx, pi).catch((err) => {
|
|
839
841
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -976,8 +978,12 @@ async function dispatchNextUnit(
|
|
|
976
978
|
return;
|
|
977
979
|
}
|
|
978
980
|
|
|
979
|
-
// Reentrancy guard
|
|
980
|
-
|
|
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) {
|
|
981
987
|
debugLog("dispatchNextUnit reentrancy guard — another dispatch in progress, bailing");
|
|
982
988
|
return;
|
|
983
989
|
}
|
|
@@ -1124,7 +1130,7 @@ async function dispatchNextUnit(
|
|
|
1124
1130
|
}
|
|
1125
1131
|
|
|
1126
1132
|
s.basePath = s.originalBasePath;
|
|
1127
|
-
s.gitService =
|
|
1133
|
+
s.gitService = createGitService(s.basePath);
|
|
1128
1134
|
invalidateAllCaches();
|
|
1129
1135
|
|
|
1130
1136
|
state = await deriveState(s.basePath);
|
|
@@ -1136,7 +1142,7 @@ async function dispatchNextUnit(
|
|
|
1136
1142
|
try {
|
|
1137
1143
|
const wtPath = createAutoWorktree(s.basePath, mid);
|
|
1138
1144
|
s.basePath = wtPath;
|
|
1139
|
-
s.gitService =
|
|
1145
|
+
s.gitService = createGitService(s.basePath);
|
|
1140
1146
|
ctx.ui.notify(`Created auto-worktree for ${mid} at ${wtPath}`, "info");
|
|
1141
1147
|
} catch (err) {
|
|
1142
1148
|
ctx.ui.notify(
|
|
@@ -1176,7 +1182,7 @@ async function dispatchNextUnit(
|
|
|
1176
1182
|
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
1177
1183
|
const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
|
|
1178
1184
|
s.basePath = s.originalBasePath;
|
|
1179
|
-
s.gitService =
|
|
1185
|
+
s.gitService = createGitService(s.basePath);
|
|
1180
1186
|
ctx.ui.notify(
|
|
1181
1187
|
`Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
1182
1188
|
"info",
|
|
@@ -1201,7 +1207,7 @@ async function dispatchNextUnit(
|
|
|
1201
1207
|
if (roadmapPath) {
|
|
1202
1208
|
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
1203
1209
|
const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
|
|
1204
|
-
s.gitService =
|
|
1210
|
+
s.gitService = createGitService(s.basePath);
|
|
1205
1211
|
ctx.ui.notify(
|
|
1206
1212
|
`Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
1207
1213
|
"info",
|
|
@@ -1279,7 +1285,7 @@ async function dispatchNextUnit(
|
|
|
1279
1285
|
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
1280
1286
|
const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
|
|
1281
1287
|
s.basePath = s.originalBasePath;
|
|
1282
|
-
s.gitService =
|
|
1288
|
+
s.gitService = createGitService(s.basePath);
|
|
1283
1289
|
ctx.ui.notify(
|
|
1284
1290
|
`Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
1285
1291
|
"info",
|
|
@@ -1303,7 +1309,7 @@ async function dispatchNextUnit(
|
|
|
1303
1309
|
if (roadmapPath) {
|
|
1304
1310
|
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
1305
1311
|
const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
|
|
1306
|
-
s.gitService =
|
|
1312
|
+
s.gitService = createGitService(s.basePath);
|
|
1307
1313
|
ctx.ui.notify(
|
|
1308
1314
|
`Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
1309
1315
|
"info",
|
|
@@ -1449,15 +1455,18 @@ async function dispatchNextUnit(
|
|
|
1449
1455
|
}
|
|
1450
1456
|
|
|
1451
1457
|
if (dispatchResult.action !== "dispatch") {
|
|
1452
|
-
|
|
1453
|
-
|
|
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
|
+
}));
|
|
1454
1464
|
return;
|
|
1455
1465
|
}
|
|
1456
1466
|
|
|
1457
1467
|
unitType = dispatchResult.unitType;
|
|
1458
1468
|
unitId = dispatchResult.unitId;
|
|
1459
1469
|
prompt = dispatchResult.prompt;
|
|
1460
|
-
let pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
|
|
1461
1470
|
|
|
1462
1471
|
// ── Pre-dispatch hooks ──
|
|
1463
1472
|
const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, s.basePath);
|
|
@@ -1469,8 +1478,10 @@ async function dispatchNextUnit(
|
|
|
1469
1478
|
}
|
|
1470
1479
|
if (preDispatchResult.action === "skip") {
|
|
1471
1480
|
ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
|
|
1472
|
-
|
|
1473
|
-
|
|
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
|
+
}));
|
|
1474
1485
|
return;
|
|
1475
1486
|
}
|
|
1476
1487
|
if (preDispatchResult.action === "replace") {
|
|
@@ -1501,9 +1512,16 @@ async function dispatchNextUnit(
|
|
|
1501
1512
|
if (idempotencyResult.reason === "completed" || idempotencyResult.reason === "fallback-persisted" || idempotencyResult.reason === "phantom-loop-cleared" || idempotencyResult.reason === "evicted") {
|
|
1502
1513
|
if (!s.active) return;
|
|
1503
1514
|
s.skipDepth++;
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
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);
|
|
1507
1525
|
return;
|
|
1508
1526
|
}
|
|
1509
1527
|
} else if (idempotencyResult.action === "stop") {
|
|
@@ -1534,8 +1552,11 @@ async function dispatchNextUnit(
|
|
|
1534
1552
|
return;
|
|
1535
1553
|
}
|
|
1536
1554
|
if (stuckResult.action === "recovered" && stuckResult.dispatchAgain) {
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
+
}));
|
|
1539
1560
|
return;
|
|
1540
1561
|
}
|
|
1541
1562
|
|
|
@@ -1712,13 +1733,6 @@ async function dispatchNextUnit(
|
|
|
1712
1733
|
{ triggerTurn: true },
|
|
1713
1734
|
);
|
|
1714
1735
|
|
|
1715
|
-
if (pauseAfterUatDispatch) {
|
|
1716
|
-
ctx.ui.notify(
|
|
1717
|
-
"UAT requires human execution. Auto-mode will pause after this unit writes the result file.",
|
|
1718
|
-
"info",
|
|
1719
|
-
);
|
|
1720
|
-
await pauseAuto(ctx, pi);
|
|
1721
|
-
}
|
|
1722
1736
|
} finally {
|
|
1723
1737
|
s.dispatching = false;
|
|
1724
1738
|
}
|
|
@@ -1788,6 +1802,15 @@ export {
|
|
|
1788
1802
|
export function _getUnitConsecutiveSkips(): Map<string, number> { return s.unitConsecutiveSkips; }
|
|
1789
1803
|
export function _resetUnitConsecutiveSkips(): void { s.unitConsecutiveSkips.clear(); }
|
|
1790
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
|
+
|
|
1791
1814
|
/**
|
|
1792
1815
|
* Dispatch a hook unit directly, bypassing normal pre-dispatch hooks.
|
|
1793
1816
|
* Used for manual hook triggers via /gsd run-hook.
|
|
@@ -1874,8 +1897,6 @@ export async function dispatchHookUnit(
|
|
|
1874
1897
|
ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
|
|
1875
1898
|
ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info");
|
|
1876
1899
|
|
|
1877
|
-
console.log(`[dispatchHookUnit] Sending prompt of length ${hookPrompt.length}`);
|
|
1878
|
-
console.log(`[dispatchHookUnit] Prompt preview: ${hookPrompt.substring(0, 200)}...`);
|
|
1879
1900
|
pi.sendMessage(
|
|
1880
1901
|
{ customType: "gsd-auto", content: hookPrompt, display: true },
|
|
1881
1902
|
{ triggerTurn: true },
|
|
@@ -19,8 +19,7 @@ import {
|
|
|
19
19
|
} from "./workflow-templates.js";
|
|
20
20
|
import { loadPrompt } from "./prompt-loader.js";
|
|
21
21
|
import { gsdRoot } from "./paths.js";
|
|
22
|
-
import {
|
|
23
|
-
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
22
|
+
import { createGitService, runGit } from "./git-service.js";
|
|
24
23
|
import { isAutoActive, isAutoPaused } from "./auto.js";
|
|
25
24
|
|
|
26
25
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -423,9 +422,8 @@ export async function handleStart(
|
|
|
423
422
|
|
|
424
423
|
// ─── Create git branch (unless isolation: none) ─────────────────────────
|
|
425
424
|
|
|
426
|
-
const
|
|
427
|
-
const
|
|
428
|
-
const skipBranch = gitPrefs.isolation === "none";
|
|
425
|
+
const git = createGitService(basePath);
|
|
426
|
+
const skipBranch = git.prefs.isolation === "none";
|
|
429
427
|
const slug = slugify(description || templateId);
|
|
430
428
|
const branchName = `gsd/${templateId}/${slug}`;
|
|
431
429
|
let branchCreated = false;
|
|
@@ -13,6 +13,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { gsdRoot } from "./paths.js";
|
|
15
15
|
import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
|
|
16
|
+
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
16
17
|
|
|
17
18
|
import {
|
|
18
19
|
detectWorktreeName,
|
|
@@ -541,6 +542,14 @@ export class GitServiceImpl {
|
|
|
541
542
|
|
|
542
543
|
}
|
|
543
544
|
|
|
545
|
+
// ─── Factory ───────────────────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
/** Create a GitServiceImpl with the current effective git preferences. */
|
|
548
|
+
export function createGitService(basePath: string): GitServiceImpl {
|
|
549
|
+
const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
|
|
550
|
+
return new GitServiceImpl(basePath, gitPrefs);
|
|
551
|
+
}
|
|
552
|
+
|
|
544
553
|
// ─── Commit Type Inference ─────────────────────────────────────────────────
|
|
545
554
|
|
|
546
555
|
/**
|
|
@@ -23,13 +23,6 @@ import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
|
23
23
|
import { loadQueueOrder, sortByQueueOrder, saveQueueOrder } from "./queue-order.js";
|
|
24
24
|
import { findMilestoneIds, nextMilestoneId } from "./milestone-ids.js";
|
|
25
25
|
|
|
26
|
-
// ─── Commit Instruction Helper (local copy — avoids circular dep) ───────────
|
|
27
|
-
|
|
28
|
-
/** Build commit instruction for queue prompts. .gsd/ is managed externally and always gitignored. */
|
|
29
|
-
function buildDocsCommitInstruction(_message: string): string {
|
|
30
|
-
return "Do not commit planning artifacts — .gsd/ is managed externally.";
|
|
31
|
-
}
|
|
32
|
-
|
|
33
26
|
// ─── Queue Entry Point ──────────────────────────────────────────────────────
|
|
34
27
|
|
|
35
28
|
/**
|
|
@@ -207,7 +200,7 @@ export async function showQueueAdd(
|
|
|
207
200
|
preamble,
|
|
208
201
|
existingMilestonesContext: existingContext,
|
|
209
202
|
inlinedTemplates: queueInlinedTemplates,
|
|
210
|
-
commitInstruction:
|
|
203
|
+
commitInstruction: "Do not commit planning artifacts — .gsd/ is managed externally.",
|
|
211
204
|
});
|
|
212
205
|
|
|
213
206
|
pi.sendMessage(
|
|
@@ -86,6 +86,14 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|
|
86
86
|
"context_selection",
|
|
87
87
|
]);
|
|
88
88
|
|
|
89
|
+
/** Canonical list of all dispatch unit types. */
|
|
90
|
+
export const KNOWN_UNIT_TYPES = [
|
|
91
|
+
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
|
|
92
|
+
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
|
|
93
|
+
"run-uat", "complete-milestone",
|
|
94
|
+
] as const;
|
|
95
|
+
export type UnitType = (typeof KNOWN_UNIT_TYPES)[number];
|
|
96
|
+
|
|
89
97
|
export const SKILL_ACTIONS = new Set(["use", "prefer", "avoid"]);
|
|
90
98
|
|
|
91
99
|
export interface GSDSkillRule {
|
|
@@ -14,6 +14,7 @@ import { normalizeStringArray } from "../shared/mod.js";
|
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
KNOWN_PREFERENCE_KEYS,
|
|
17
|
+
KNOWN_UNIT_TYPES,
|
|
17
18
|
SKILL_ACTIONS,
|
|
18
19
|
type WorkflowMode,
|
|
19
20
|
type GSDPreferences,
|
|
@@ -239,11 +240,7 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|
|
239
240
|
if (preferences.post_unit_hooks && Array.isArray(preferences.post_unit_hooks)) {
|
|
240
241
|
const validHooks: PostUnitHookConfig[] = [];
|
|
241
242
|
const seenNames = new Set<string>();
|
|
242
|
-
const knownUnitTypes = new Set(
|
|
243
|
-
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
|
|
244
|
-
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
|
|
245
|
-
"run-uat", "complete-milestone",
|
|
246
|
-
]);
|
|
243
|
+
const knownUnitTypes = new Set<string>(KNOWN_UNIT_TYPES);
|
|
247
244
|
for (const hook of preferences.post_unit_hooks) {
|
|
248
245
|
if (!hook || typeof hook !== "object") {
|
|
249
246
|
errors.push("post_unit_hooks entry must be an object");
|
|
@@ -305,11 +302,7 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|
|
305
302
|
if (preferences.pre_dispatch_hooks && Array.isArray(preferences.pre_dispatch_hooks)) {
|
|
306
303
|
const validPreHooks: PreDispatchHookConfig[] = [];
|
|
307
304
|
const seenPreNames = new Set<string>();
|
|
308
|
-
const knownUnitTypes = new Set(
|
|
309
|
-
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
|
|
310
|
-
"execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
|
|
311
|
-
"run-uat", "complete-milestone",
|
|
312
|
-
]);
|
|
305
|
+
const knownUnitTypes = new Set<string>(KNOWN_UNIT_TYPES);
|
|
313
306
|
const validActions = new Set(["modify", "skip", "replace"]);
|
|
314
307
|
for (const hook of preferences.pre_dispatch_hooks) {
|
|
315
308
|
if (!hook || typeof hook !== "object") {
|
|
@@ -17,11 +17,8 @@ If a `GSD Skill Preferences` block is present in system context, use it to decid
|
|
|
17
17
|
## UAT Instructions
|
|
18
18
|
|
|
19
19
|
**UAT file:** `{{uatPath}}`
|
|
20
|
-
**UAT type:** `{{uatType}}`
|
|
21
20
|
**Result file to write:** `{{uatResultPath}}`
|
|
22
21
|
|
|
23
|
-
### If UAT type is `artifact-driven`
|
|
24
|
-
|
|
25
22
|
You are the test runner. Execute every check defined in `{{uatPath}}` directly:
|
|
26
23
|
|
|
27
24
|
- Run shell commands with `bash`
|
|
@@ -46,7 +43,7 @@ Write `{{uatResultPath}}` with:
|
|
|
46
43
|
```markdown
|
|
47
44
|
---
|
|
48
45
|
sliceId: {{sliceId}}
|
|
49
|
-
uatType:
|
|
46
|
+
uatType: artifact-driven
|
|
50
47
|
verdict: PASS | FAIL | PARTIAL
|
|
51
48
|
date: <ISO 8601 timestamp>
|
|
52
49
|
---
|
|
@@ -68,44 +65,6 @@ date: <ISO 8601 timestamp>
|
|
|
68
65
|
<any additional context, errors encountered, or follow-up items>
|
|
69
66
|
```
|
|
70
67
|
|
|
71
|
-
### If UAT type is NOT `artifact-driven` (type is `{{uatType}}`)
|
|
72
|
-
|
|
73
|
-
This UAT type requires human execution or live-runtime observation that you cannot perform mechanically. Your role is to surface it clearly for review.
|
|
74
|
-
|
|
75
|
-
Write `{{uatResultPath}}` with:
|
|
76
|
-
|
|
77
|
-
```markdown
|
|
78
|
-
---
|
|
79
|
-
sliceId: {{sliceId}}
|
|
80
|
-
uatType: {{uatType}}
|
|
81
|
-
verdict: surfaced-for-human-review
|
|
82
|
-
date: <ISO 8601 timestamp>
|
|
83
|
-
---
|
|
84
|
-
|
|
85
|
-
# UAT Result — {{sliceId}}
|
|
86
|
-
|
|
87
|
-
## UAT Type
|
|
88
|
-
|
|
89
|
-
`{{uatType}}` — requires human execution or live-runtime verification.
|
|
90
|
-
|
|
91
|
-
## Status
|
|
92
|
-
|
|
93
|
-
Surfaced for human review. Auto-mode will pause after this unit so the UAT can be performed manually.
|
|
94
|
-
|
|
95
|
-
## UAT File
|
|
96
|
-
|
|
97
|
-
See `{{uatPath}}` for the full UAT specification and acceptance criteria.
|
|
98
|
-
|
|
99
|
-
## Instructions for Human Reviewer
|
|
100
|
-
|
|
101
|
-
Review `{{uatPath}}`, perform the described UAT steps, then update this file with:
|
|
102
|
-
- The actual verdict (PASS / FAIL / PARTIAL)
|
|
103
|
-
- Results for each check
|
|
104
|
-
- Date completed
|
|
105
|
-
|
|
106
|
-
Once updated, run `/gsd auto` to resume auto-mode.
|
|
107
|
-
```
|
|
108
|
-
|
|
109
68
|
---
|
|
110
69
|
|
|
111
70
|
**You MUST write `{{uatResultPath}}` before finishing.**
|
|
@@ -14,8 +14,7 @@ import { existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { loadPrompt } from "./prompt-loader.js";
|
|
16
16
|
import { gsdRoot } from "./paths.js";
|
|
17
|
-
import {
|
|
18
|
-
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
17
|
+
import { createGitService, runGit } from "./git-service.js";
|
|
19
18
|
|
|
20
19
|
// ─── Quick Task Helpers ───────────────────────────────────────────────────────
|
|
21
20
|
|
|
@@ -103,10 +102,9 @@ export async function handleQuick(
|
|
|
103
102
|
const date = new Date().toISOString().split("T")[0];
|
|
104
103
|
|
|
105
104
|
// Create git branch for the quick task (unless isolation: none)
|
|
106
|
-
const
|
|
107
|
-
const git = new GitServiceImpl(basePath, gitPrefs);
|
|
105
|
+
const git = createGitService(basePath);
|
|
108
106
|
const branchName = `gsd/quick/${taskNum}-${slug}`;
|
|
109
|
-
const skipBranch =
|
|
107
|
+
const skipBranch = git.prefs.isolation === "none";
|
|
110
108
|
|
|
111
109
|
let branchCreated = false;
|
|
112
110
|
if (!skipBranch) {
|
|
@@ -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
|
+
});
|