gsd-pi 2.32.0-dev.f3d5d53 → 2.33.0-dev.69bff0f
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/README.md +13 -18
- package/dist/resources/extensions/gsd/auto-dashboard.ts +3 -1
- package/dist/resources/extensions/gsd/auto-dispatch.ts +40 -12
- package/dist/resources/extensions/gsd/auto-idempotency.ts +3 -2
- package/dist/resources/extensions/gsd/auto-observability.ts +2 -4
- package/dist/resources/extensions/gsd/auto-post-unit.ts +5 -5
- package/dist/resources/extensions/gsd/auto-recovery.ts +8 -22
- package/dist/resources/extensions/gsd/auto-start.ts +2 -1
- package/dist/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
- package/dist/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
- package/dist/resources/extensions/gsd/auto-verification.ts +4 -5
- package/dist/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/dist/resources/extensions/gsd/auto.ts +89 -164
- package/dist/resources/extensions/gsd/commands.ts +14 -2
- package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
- package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
- package/dist/resources/extensions/gsd/metrics.ts +3 -3
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
- package/dist/resources/extensions/gsd/session-lock.ts +80 -16
- package/dist/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
- package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
- package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/dist/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/dist/resources/extensions/gsd/undo.ts +5 -7
- package/dist/resources/extensions/gsd/unit-id.ts +14 -0
- package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
- package/package.json +3 -2
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-dashboard.ts +3 -1
- package/src/resources/extensions/gsd/auto-dispatch.ts +40 -12
- package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
- package/src/resources/extensions/gsd/auto-observability.ts +2 -4
- package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
- package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
- package/src/resources/extensions/gsd/auto-start.ts +2 -1
- package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
- package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
- package/src/resources/extensions/gsd/auto-verification.ts +4 -5
- package/src/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/src/resources/extensions/gsd/auto.ts +89 -164
- package/src/resources/extensions/gsd/commands.ts +14 -2
- package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
- package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
- package/src/resources/extensions/gsd/metrics.ts +3 -3
- package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
- package/src/resources/extensions/gsd/session-lock.ts +80 -16
- package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
- package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/src/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
- package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/src/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/src/resources/extensions/gsd/undo.ts +5 -7
- package/src/resources/extensions/gsd/unit-id.ts +14 -0
- package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
- package/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
- package/src/resources/extensions/mcporter/extension-manifest.json +0 -12
|
@@ -105,6 +105,7 @@ import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.j
|
|
|
105
105
|
import { GSDError, GSD_ARTIFACT_MISSING } from "./errors.js";
|
|
106
106
|
import { join } from "node:path";
|
|
107
107
|
import { sep as pathSep } from "node:path";
|
|
108
|
+
import { parseUnitId } from "./unit-id.js";
|
|
108
109
|
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs";
|
|
109
110
|
import { atomicWriteSync } from "./atomic-write.js";
|
|
110
111
|
import { nativeIsRepo, nativeInit, nativeAddAll, nativeCommit } from "./native-git-bridge.js";
|
|
@@ -214,52 +215,7 @@ export function shouldUseWorktreeIsolation(): boolean {
|
|
|
214
215
|
return true; // default: worktree
|
|
215
216
|
}
|
|
216
217
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
/** Pending verification retry — set when gate fails with retries remaining, consumed by dispatchNextUnit */
|
|
220
|
-
|
|
221
|
-
/** Verification retry count per unitId — separate from s.unitDispatchCount which tracks artifact-missing retries */
|
|
222
|
-
|
|
223
|
-
/** Session file path captured at pause — used to synthesize recovery briefing on resume */
|
|
224
|
-
|
|
225
|
-
/** Dashboard tracking */
|
|
226
|
-
|
|
227
|
-
/** Track dynamic routing decision for the current unit (for metrics) */
|
|
228
|
-
|
|
229
|
-
/** Queue of quick-task captures awaiting dispatch after triage resolution */
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Model captured at auto-mode start. Used to prevent model bleed between
|
|
233
|
-
* concurrent GSD instances sharing the same global settings.json (#650).
|
|
234
|
-
* When preferences don't specify a model for a unit type, this ensures
|
|
235
|
-
* the session's original model is re-applied instead of reading from
|
|
236
|
-
* the shared global settings (which another instance may have overwritten).
|
|
237
|
-
*/
|
|
238
|
-
|
|
239
|
-
/** Track current milestone to detect transitions */
|
|
240
|
-
|
|
241
|
-
/** Model the user had selected before auto-mode started */
|
|
242
|
-
|
|
243
|
-
/** Progress-aware timeout supervision */
|
|
244
|
-
|
|
245
|
-
/** Context-pressure continue-here monitor — fires once when context usage >= 70% */
|
|
246
|
-
|
|
247
|
-
/** Dispatch gap watchdog — detects when the state machine stalls between units.
|
|
248
|
-
* After handleAgentEnd completes, if auto-mode is still active but no new unit
|
|
249
|
-
* has been dispatched (sendMessage not called), this timer fires to force a
|
|
250
|
-
* re-evaluation. Covers the case where dispatchNextUnit silently fails or
|
|
251
|
-
* an unhandled error kills the dispatch chain. */
|
|
252
|
-
|
|
253
|
-
/** Prompt character measurement for token savings analysis (R051). */
|
|
254
|
-
|
|
255
|
-
/** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Tool calls currently being executed — prevents false idle detection during long-running tools.
|
|
259
|
-
* Maps toolCallId → start timestamp (ms) so the idle watchdog can detect tools that have been
|
|
260
|
-
* running suspiciously long (e.g., a Bash command hung because `&` kept stdout open).
|
|
261
|
-
*/
|
|
262
|
-
|
|
218
|
+
// All mutable state lives in AutoSession (auto/session.ts) — see encapsulation invariant above.
|
|
263
219
|
/** Wrapper: register SIGTERM handler and store reference. */
|
|
264
220
|
function registerSigtermHandler(currentBasePath: string): void {
|
|
265
221
|
s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler);
|
|
@@ -405,6 +361,79 @@ function buildSnapshotOpts(unitType: string, unitId: string): { continueHereFire
|
|
|
405
361
|
};
|
|
406
362
|
}
|
|
407
363
|
|
|
364
|
+
// ─── Extracted Merge Helper ───────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Attempt to merge the current milestone branch to main.
|
|
368
|
+
* Handles both worktree and branch isolation modes with a single code path.
|
|
369
|
+
* Returns true if merge succeeded, false on error (non-fatal, logged).
|
|
370
|
+
*
|
|
371
|
+
* Extracted from 4 duplicate merge blocks in dispatchNextUnit to eliminate
|
|
372
|
+
* the bug factory where fixing one copy didn't fix the others (#1308).
|
|
373
|
+
*/
|
|
374
|
+
function tryMergeMilestone(ctx: ExtensionContext, milestoneId: string, mode: "transition" | "complete"): boolean {
|
|
375
|
+
const isolationMode = getIsolationMode();
|
|
376
|
+
|
|
377
|
+
// Worktree merge path
|
|
378
|
+
if (isInAutoWorktree(s.basePath) && s.originalBasePath) {
|
|
379
|
+
try {
|
|
380
|
+
const roadmapPath = resolveMilestoneFile(s.originalBasePath, milestoneId, "ROADMAP");
|
|
381
|
+
if (!roadmapPath) {
|
|
382
|
+
teardownAutoWorktree(s.originalBasePath, milestoneId);
|
|
383
|
+
ctx.ui.notify(`Exited worktree for ${milestoneId} (no roadmap for merge).`, "info");
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
387
|
+
const mergeResult = mergeMilestoneToMain(s.originalBasePath, milestoneId, roadmapContent);
|
|
388
|
+
s.basePath = s.originalBasePath;
|
|
389
|
+
s.gitService = createGitService(s.basePath);
|
|
390
|
+
ctx.ui.notify(
|
|
391
|
+
`Milestone ${milestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
392
|
+
"info",
|
|
393
|
+
);
|
|
394
|
+
return true;
|
|
395
|
+
} catch (err) {
|
|
396
|
+
ctx.ui.notify(
|
|
397
|
+
`Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
398
|
+
"warning",
|
|
399
|
+
);
|
|
400
|
+
if (s.originalBasePath) {
|
|
401
|
+
s.basePath = s.originalBasePath;
|
|
402
|
+
try { process.chdir(s.basePath); } catch { /* best-effort */ }
|
|
403
|
+
}
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Branch-mode merge path
|
|
409
|
+
if (isolationMode === "branch") {
|
|
410
|
+
try {
|
|
411
|
+
const currentBranch = getCurrentBranch(s.basePath);
|
|
412
|
+
const milestoneBranch = autoWorktreeBranch(milestoneId);
|
|
413
|
+
if (currentBranch === milestoneBranch) {
|
|
414
|
+
const roadmapPath = resolveMilestoneFile(s.basePath, milestoneId, "ROADMAP");
|
|
415
|
+
if (roadmapPath) {
|
|
416
|
+
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
417
|
+
const mergeResult = mergeMilestoneToMain(s.basePath, milestoneId, roadmapContent);
|
|
418
|
+
s.gitService = createGitService(s.basePath);
|
|
419
|
+
ctx.ui.notify(
|
|
420
|
+
`Milestone ${milestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
421
|
+
"info",
|
|
422
|
+
);
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} catch (err) {
|
|
427
|
+
ctx.ui.notify(
|
|
428
|
+
`Milestone merge failed (branch mode): ${err instanceof Error ? err.message : String(err)}`,
|
|
429
|
+
"warning",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
|
|
408
437
|
/**
|
|
409
438
|
* Start a watchdog that fires if no new unit is dispatched within DISPATCH_GAP_TIMEOUT_MS
|
|
410
439
|
* after handleAgentEnd completes. This catches the case where the dispatch chain silently
|
|
@@ -1106,32 +1135,14 @@ async function dispatchNextUnit(
|
|
|
1106
1135
|
} catch (e) { debugLog("completed-keys-reset-failed", { error: getErrorMessage(e) }); }
|
|
1107
1136
|
|
|
1108
1137
|
// ── Worktree lifecycle on milestone transition (#616) ──
|
|
1109
|
-
if (isInAutoWorktree(s.basePath)
|
|
1110
|
-
|
|
1111
|
-
const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
|
|
1112
|
-
if (roadmapPath) {
|
|
1113
|
-
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
1114
|
-
const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
|
|
1115
|
-
ctx.ui.notify(
|
|
1116
|
-
`Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
1117
|
-
"info",
|
|
1118
|
-
);
|
|
1119
|
-
} else {
|
|
1120
|
-
teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId);
|
|
1121
|
-
ctx.ui.notify(`Exited worktree for ${ s.currentMilestoneId } (no roadmap for merge).`, "info");
|
|
1122
|
-
}
|
|
1123
|
-
} catch (err) {
|
|
1124
|
-
ctx.ui.notify(
|
|
1125
|
-
`Milestone merge failed during transition: ${getErrorMessage(err)}`,
|
|
1126
|
-
"warning",
|
|
1127
|
-
);
|
|
1128
|
-
if (s.originalBasePath) {
|
|
1129
|
-
try { process.chdir(s.originalBasePath); } catch { /* best-effort */ }
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1138
|
+
if ((isInAutoWorktree(s.basePath) || getIsolationMode() === "branch") && shouldUseWorktreeIsolation()) {
|
|
1139
|
+
tryMergeMilestone(ctx, s.currentMilestoneId, "transition");
|
|
1132
1140
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1141
|
+
// Reset to project root and re-derive state for the new milestone
|
|
1142
|
+
if (s.originalBasePath) {
|
|
1143
|
+
s.basePath = s.originalBasePath;
|
|
1144
|
+
s.gitService = createGitService(s.basePath);
|
|
1145
|
+
}
|
|
1135
1146
|
invalidateAllCaches();
|
|
1136
1147
|
|
|
1137
1148
|
state = await deriveState(s.basePath);
|
|
@@ -1176,51 +1187,8 @@ async function dispatchNextUnit(
|
|
|
1176
1187
|
const incomplete = (state.registry ?? []).filter(m => m.status !== "complete" && m.status !== "parked");
|
|
1177
1188
|
if (incomplete.length === 0) {
|
|
1178
1189
|
// Genuinely all complete (parked milestones excluded) — merge milestone branch to main before stopping (#962)
|
|
1179
|
-
if (s.currentMilestoneId
|
|
1180
|
-
|
|
1181
|
-
const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
|
|
1182
|
-
if (roadmapPath) {
|
|
1183
|
-
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
1184
|
-
const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
|
|
1185
|
-
s.basePath = s.originalBasePath;
|
|
1186
|
-
s.gitService = createGitService(s.basePath);
|
|
1187
|
-
ctx.ui.notify(
|
|
1188
|
-
`Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
1189
|
-
"info",
|
|
1190
|
-
);
|
|
1191
|
-
}
|
|
1192
|
-
} catch (err) {
|
|
1193
|
-
ctx.ui.notify(
|
|
1194
|
-
`Milestone merge failed: ${getErrorMessage(err)}`,
|
|
1195
|
-
"warning",
|
|
1196
|
-
);
|
|
1197
|
-
if (s.originalBasePath) {
|
|
1198
|
-
s.basePath = s.originalBasePath;
|
|
1199
|
-
try { process.chdir(s.basePath); } catch { /* best-effort */ }
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
} else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() === "branch") {
|
|
1203
|
-
try {
|
|
1204
|
-
const currentBranch = getCurrentBranch(s.basePath);
|
|
1205
|
-
const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
|
|
1206
|
-
if (currentBranch === milestoneBranch) {
|
|
1207
|
-
const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP");
|
|
1208
|
-
if (roadmapPath) {
|
|
1209
|
-
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
1210
|
-
const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
|
|
1211
|
-
s.gitService = createGitService(s.basePath);
|
|
1212
|
-
ctx.ui.notify(
|
|
1213
|
-
`Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
1214
|
-
"info",
|
|
1215
|
-
);
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
} catch (err) {
|
|
1219
|
-
ctx.ui.notify(
|
|
1220
|
-
`Milestone merge failed (branch mode): ${getErrorMessage(err)}`,
|
|
1221
|
-
"warning",
|
|
1222
|
-
);
|
|
1223
|
-
}
|
|
1190
|
+
if (s.currentMilestoneId) {
|
|
1191
|
+
tryMergeMilestone(ctx, s.currentMilestoneId, "complete");
|
|
1224
1192
|
}
|
|
1225
1193
|
sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
|
|
1226
1194
|
await stopAuto(ctx, pi, "All milestones complete");
|
|
@@ -1279,50 +1247,8 @@ async function dispatchNextUnit(
|
|
|
1279
1247
|
s.completedKeySet.clear();
|
|
1280
1248
|
} catch (e) { debugLog("completed-keys-reset-failed", { error: getErrorMessage(e) }); }
|
|
1281
1249
|
// ── Milestone merge ──
|
|
1282
|
-
if (s.currentMilestoneId
|
|
1283
|
-
|
|
1284
|
-
const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
|
|
1285
|
-
if (!roadmapPath) throw new GSDError(GSD_ARTIFACT_MISSING, `Cannot resolve ROADMAP file for milestone ${ s.currentMilestoneId }`);
|
|
1286
|
-
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
1287
|
-
const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
|
|
1288
|
-
s.basePath = s.originalBasePath;
|
|
1289
|
-
s.gitService = createGitService(s.basePath);
|
|
1290
|
-
ctx.ui.notify(
|
|
1291
|
-
`Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
1292
|
-
"info",
|
|
1293
|
-
);
|
|
1294
|
-
} catch (err) {
|
|
1295
|
-
ctx.ui.notify(
|
|
1296
|
-
`Milestone merge failed: ${getErrorMessage(err)}`,
|
|
1297
|
-
"warning",
|
|
1298
|
-
);
|
|
1299
|
-
if (s.originalBasePath) {
|
|
1300
|
-
s.basePath = s.originalBasePath;
|
|
1301
|
-
try { process.chdir(s.basePath); } catch { /* best-effort */ }
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
} else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() === "branch") {
|
|
1305
|
-
try {
|
|
1306
|
-
const currentBranch = getCurrentBranch(s.basePath);
|
|
1307
|
-
const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
|
|
1308
|
-
if (currentBranch === milestoneBranch) {
|
|
1309
|
-
const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP");
|
|
1310
|
-
if (roadmapPath) {
|
|
1311
|
-
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
1312
|
-
const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
|
|
1313
|
-
s.gitService = createGitService(s.basePath);
|
|
1314
|
-
ctx.ui.notify(
|
|
1315
|
-
`Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
1316
|
-
"info",
|
|
1317
|
-
);
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
} catch (err) {
|
|
1321
|
-
ctx.ui.notify(
|
|
1322
|
-
`Milestone merge failed (branch mode): ${getErrorMessage(err)}`,
|
|
1323
|
-
"warning",
|
|
1324
|
-
);
|
|
1325
|
-
}
|
|
1250
|
+
if (s.currentMilestoneId) {
|
|
1251
|
+
tryMergeMilestone(ctx, s.currentMilestoneId, "complete");
|
|
1326
1252
|
}
|
|
1327
1253
|
sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
|
|
1328
1254
|
await stopAuto(ctx, pi, `Milestone ${mid} complete`);
|
|
@@ -1748,8 +1674,7 @@ async function dispatchNextUnit(
|
|
|
1748
1674
|
function ensurePreconditions(
|
|
1749
1675
|
unitType: string, unitId: string, base: string, state: GSDState,
|
|
1750
1676
|
): void {
|
|
1751
|
-
const
|
|
1752
|
-
const mid = parts[0]!;
|
|
1677
|
+
const { milestone: mid } = parseUnitId(unitId);
|
|
1753
1678
|
|
|
1754
1679
|
const mDir = resolveMilestonePath(base, mid);
|
|
1755
1680
|
if (!mDir) {
|
|
@@ -1757,8 +1682,8 @@ function ensurePreconditions(
|
|
|
1757
1682
|
mkdirSync(join(newDir, "slices"), { recursive: true });
|
|
1758
1683
|
}
|
|
1759
1684
|
|
|
1760
|
-
|
|
1761
|
-
|
|
1685
|
+
const sid = parseUnitId(unitId).slice;
|
|
1686
|
+
if (sid) {
|
|
1762
1687
|
|
|
1763
1688
|
const mDirResolved = resolveMilestonePath(base, mid);
|
|
1764
1689
|
if (mDirResolved) {
|
|
@@ -52,8 +52,20 @@ import { handleStart, handleTemplates, getTemplateCompletions } from "./commands
|
|
|
52
52
|
|
|
53
53
|
/** Resolve the effective project root, accounting for worktree paths. */
|
|
54
54
|
export function projectRoot(): string {
|
|
55
|
-
const
|
|
56
|
-
|
|
55
|
+
const cwd = process.cwd();
|
|
56
|
+
const root = resolveProjectRoot(cwd);
|
|
57
|
+
|
|
58
|
+
// When running inside a GSD worktree, the resolved root may be a "dangerous"
|
|
59
|
+
// directory (e.g., $HOME used as a git repo root — #1317). The safety check
|
|
60
|
+
// should validate the actual working directory, not the upstream root,
|
|
61
|
+
// because the worktree itself is a safe project subdirectory.
|
|
62
|
+
// Only skip the root check when we can confirm we're in a valid worktree.
|
|
63
|
+
if (root !== cwd) {
|
|
64
|
+
// We're in a worktree — validate the worktree path instead of the root
|
|
65
|
+
assertSafeDirectory(cwd);
|
|
66
|
+
} else {
|
|
67
|
+
assertSafeDirectory(root);
|
|
68
|
+
}
|
|
57
69
|
return root;
|
|
58
70
|
}
|
|
59
71
|
|
|
@@ -6,6 +6,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { gsdRoot } from "./paths.js";
|
|
8
8
|
import { getAdaptiveTierAdjustment } from "./routing-history.js";
|
|
9
|
+
import { parseUnitId } from "./unit-id.js";
|
|
9
10
|
|
|
10
11
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
11
12
|
|
|
@@ -180,15 +181,14 @@ function analyzePlanComplexity(
|
|
|
180
181
|
basePath: string,
|
|
181
182
|
): TaskAnalysis | null {
|
|
182
183
|
// Check if this is a milestone-level plan (more complex) vs single slice
|
|
183
|
-
const
|
|
184
|
-
if (
|
|
184
|
+
const { milestone: mid, slice: sid } = parseUnitId(unitId);
|
|
185
|
+
if (!sid) {
|
|
185
186
|
// Milestone-level planning is always at least standard
|
|
186
187
|
return { tier: "standard", reason: "milestone-level planning" };
|
|
187
188
|
}
|
|
188
189
|
|
|
189
190
|
// For slice planning, try to read the context/research to gauge complexity
|
|
190
191
|
// If research exists and is large, bump to heavy
|
|
191
|
-
const [mid, sid] = parts;
|
|
192
192
|
const researchPath = join(gsdRoot(basePath), mid, "slices", sid, "RESEARCH.md");
|
|
193
193
|
try {
|
|
194
194
|
if (existsSync(researchPath)) {
|
|
@@ -210,10 +210,8 @@ function analyzePlanComplexity(
|
|
|
210
210
|
*/
|
|
211
211
|
function extractTaskMetadata(unitId: string, basePath: string): TaskMetadata {
|
|
212
212
|
const meta: TaskMetadata = {};
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
const [mid, sid, tid] = parts;
|
|
213
|
+
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
|
|
214
|
+
if (!mid || !sid || !tid) return meta;
|
|
217
215
|
const taskPlanPath = join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-PLAN.md`);
|
|
218
216
|
|
|
219
217
|
try {
|
|
@@ -5,6 +5,7 @@ import { readdirSync } from "node:fs";
|
|
|
5
5
|
import { resolveMilestoneFile, milestonesDir } from "./paths.js";
|
|
6
6
|
import { parseRoadmapSlices } from "./roadmap-slices.js";
|
|
7
7
|
import { findMilestoneIds } from "./guided-flow.js";
|
|
8
|
+
import { parseUnitId } from "./unit-id.js";
|
|
8
9
|
|
|
9
10
|
const SLICE_DISPATCH_TYPES = new Set([
|
|
10
11
|
"research-slice",
|
|
@@ -39,7 +40,7 @@ function readRoadmapFromDisk(base: string, milestoneId: string): string | null {
|
|
|
39
40
|
export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string, unitType: string, unitId: string): string | null {
|
|
40
41
|
if (!SLICE_DISPATCH_TYPES.has(unitType)) return null;
|
|
41
42
|
|
|
42
|
-
const
|
|
43
|
+
const { milestone: targetMid, slice: targetSid } = parseUnitId(unitId);
|
|
43
44
|
if (!targetMid || !targetSid) return null;
|
|
44
45
|
|
|
45
46
|
// Use findMilestoneIds to respect custom queue order.
|
|
@@ -18,6 +18,7 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent";
|
|
|
18
18
|
import { gsdRoot } from "./paths.js";
|
|
19
19
|
import { getAndClearSkills } from "./skill-telemetry.js";
|
|
20
20
|
import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
|
|
21
|
+
import { parseUnitId } from "./unit-id.js";
|
|
21
22
|
|
|
22
23
|
// Re-export from shared — canonical implementation lives in format-utils.
|
|
23
24
|
export { formatTokenCount } from "../shared/mod.js";
|
|
@@ -290,9 +291,8 @@ export function aggregateByPhase(units: UnitMetrics[]): PhaseAggregate[] {
|
|
|
290
291
|
export function aggregateBySlice(units: UnitMetrics[]): SliceAggregate[] {
|
|
291
292
|
const map = new Map<string, SliceAggregate>();
|
|
292
293
|
for (const u of units) {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
const sliceId = parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
294
|
+
const { milestone, slice } = parseUnitId(u.id);
|
|
295
|
+
const sliceId = slice ? `${milestone}/${slice}` : milestone;
|
|
296
296
|
let agg = map.get(sliceId);
|
|
297
297
|
if (!agg) {
|
|
298
298
|
agg = { sliceId, units: 0, tokens: emptyTokens(), cost: 0, duration: 0 };
|
|
@@ -15,6 +15,7 @@ import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js"
|
|
|
15
15
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { gsdRoot } from "./paths.js";
|
|
18
|
+
import { parseUnitId } from "./unit-id.js";
|
|
18
19
|
|
|
19
20
|
// ─── Hook Queue State ──────────────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -149,7 +150,7 @@ function dequeueNextHook(basePath: string): HookDispatchResult | null {
|
|
|
149
150
|
};
|
|
150
151
|
|
|
151
152
|
// Build the prompt with variable substitution
|
|
152
|
-
const
|
|
153
|
+
const { milestone: mid, slice: sid, task: tid } = parseUnitId(triggerUnitId);
|
|
153
154
|
const prompt = config.prompt
|
|
154
155
|
.replace(/\{milestoneId\}/g, mid ?? "")
|
|
155
156
|
.replace(/\{sliceId\}/g, sid ?? "")
|
|
@@ -208,16 +209,14 @@ function handleHookCompletion(basePath: string): HookDispatchResult | null {
|
|
|
208
209
|
* - Milestone-level (M001): .gsd/M001/{artifact}
|
|
209
210
|
*/
|
|
210
211
|
export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string {
|
|
211
|
-
const
|
|
212
|
-
if (
|
|
213
|
-
const [mid, sid, tid] = parts;
|
|
212
|
+
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
|
|
213
|
+
if (mid && sid && tid) {
|
|
214
214
|
return join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
|
|
215
215
|
}
|
|
216
|
-
if (
|
|
217
|
-
const [mid, sid] = parts;
|
|
216
|
+
if (mid && sid) {
|
|
218
217
|
return join(gsdRoot(basePath), mid, "slices", sid, artifactName);
|
|
219
218
|
}
|
|
220
|
-
return join(gsdRoot(basePath),
|
|
219
|
+
return join(gsdRoot(basePath), mid, artifactName);
|
|
221
220
|
}
|
|
222
221
|
|
|
223
222
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -253,7 +252,7 @@ export function runPreDispatchHooks(
|
|
|
253
252
|
return { action: "proceed", prompt, firedHooks: [] };
|
|
254
253
|
}
|
|
255
254
|
|
|
256
|
-
const
|
|
255
|
+
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
|
|
257
256
|
const substitute = (text: string): string =>
|
|
258
257
|
text
|
|
259
258
|
.replace(/\{milestoneId\}/g, mid ?? "")
|
|
@@ -466,7 +465,7 @@ export function triggerHookManually(
|
|
|
466
465
|
activeHook.cycle = currentCycle;
|
|
467
466
|
|
|
468
467
|
// Build the prompt with variable substitution
|
|
469
|
-
const
|
|
468
|
+
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
|
|
470
469
|
const prompt = hook.prompt
|
|
471
470
|
.replace(/\{milestoneId\}/g, mid ?? "")
|
|
472
471
|
.replace(/\{sliceId\}/g, sid ?? "")
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
|
-
import { existsSync, readFileSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
|
|
21
21
|
import { join, dirname } from "node:path";
|
|
22
22
|
import { gsdRoot } from "./paths.js";
|
|
23
23
|
import { atomicWriteSync } from "./atomic-write.js";
|
|
@@ -54,12 +54,81 @@ let _lockPid: number = 0;
|
|
|
54
54
|
/** Set to true when proper-lockfile fires onCompromised (mtime drift, sleep, etc.). */
|
|
55
55
|
let _lockCompromised: boolean = false;
|
|
56
56
|
|
|
57
|
+
/** Whether we've already registered a process.on('exit') handler. */
|
|
58
|
+
let _exitHandlerRegistered: boolean = false;
|
|
59
|
+
|
|
57
60
|
const LOCK_FILE = "auto.lock";
|
|
58
61
|
|
|
59
62
|
function lockPath(basePath: string): string {
|
|
60
63
|
return join(gsdRoot(basePath), LOCK_FILE);
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
// ─── Stray Lock Cleanup ─────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Remove numbered lock file variants (e.g. "auto 2.lock", "auto 3.lock")
|
|
70
|
+
* that accumulate from macOS file conflict resolution (iCloud/Dropbox/OneDrive)
|
|
71
|
+
* or other filesystem-level copy-on-conflict behavior (#1315).
|
|
72
|
+
*
|
|
73
|
+
* Also removes stray proper-lockfile directories beyond the canonical `.gsd.lock/`.
|
|
74
|
+
*/
|
|
75
|
+
export function cleanupStrayLockFiles(basePath: string): void {
|
|
76
|
+
const gsdDir = gsdRoot(basePath);
|
|
77
|
+
|
|
78
|
+
// Clean numbered auto lock files inside .gsd/
|
|
79
|
+
try {
|
|
80
|
+
if (existsSync(gsdDir)) {
|
|
81
|
+
for (const entry of readdirSync(gsdDir)) {
|
|
82
|
+
// Match "auto <N>.lock" or "auto (<N>).lock" variants but NOT the canonical "auto.lock"
|
|
83
|
+
if (entry !== LOCK_FILE && /^auto\s.+\.lock$/i.test(entry)) {
|
|
84
|
+
try { unlinkSync(join(gsdDir, entry)); } catch { /* best-effort */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch { /* non-fatal: directory read failure */ }
|
|
89
|
+
|
|
90
|
+
// Clean stray proper-lockfile directories (e.g. ".gsd 2.lock/")
|
|
91
|
+
// The canonical one is ".gsd.lock/" — anything else is stray.
|
|
92
|
+
try {
|
|
93
|
+
const parentDir = dirname(gsdDir);
|
|
94
|
+
const gsdDirName = gsdDir.split("/").pop() || ".gsd";
|
|
95
|
+
if (existsSync(parentDir)) {
|
|
96
|
+
for (const entry of readdirSync(parentDir)) {
|
|
97
|
+
// Match ".gsd <N>.lock" or ".gsd (<N>).lock" directories but NOT ".gsd.lock"
|
|
98
|
+
if (entry !== `${gsdDirName}.lock` && entry.startsWith(gsdDirName) && entry.endsWith(".lock")) {
|
|
99
|
+
const fullPath = join(parentDir, entry);
|
|
100
|
+
try {
|
|
101
|
+
const stat = statSync(fullPath);
|
|
102
|
+
if (stat.isDirectory()) {
|
|
103
|
+
rmSync(fullPath, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
} catch { /* best-effort */ }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch { /* non-fatal */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Register a single process exit handler that cleans up lock state.
|
|
114
|
+
* Uses module-level references so it always operates on current state.
|
|
115
|
+
* Only registers once — subsequent calls are no-ops.
|
|
116
|
+
*/
|
|
117
|
+
function ensureExitHandler(gsdDir: string): void {
|
|
118
|
+
if (_exitHandlerRegistered) return;
|
|
119
|
+
_exitHandlerRegistered = true;
|
|
120
|
+
|
|
121
|
+
process.once("exit", () => {
|
|
122
|
+
try {
|
|
123
|
+
if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; }
|
|
124
|
+
} catch { /* best-effort */ }
|
|
125
|
+
try {
|
|
126
|
+
const lockDir = join(gsdDir + ".lock");
|
|
127
|
+
if (existsSync(lockDir)) rmSync(lockDir, { recursive: true, force: true });
|
|
128
|
+
} catch { /* best-effort */ }
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
63
132
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
64
133
|
|
|
65
134
|
/**
|
|
@@ -77,6 +146,9 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
77
146
|
// Ensure the directory exists
|
|
78
147
|
mkdirSync(dirname(lp), { recursive: true });
|
|
79
148
|
|
|
149
|
+
// Clean up numbered lock file variants from cloud sync conflicts (#1315)
|
|
150
|
+
cleanupStrayLockFiles(basePath);
|
|
151
|
+
|
|
80
152
|
// Write our lock data first (the content is informational; the OS lock is the real guard)
|
|
81
153
|
const lockData: SessionLockData = {
|
|
82
154
|
pid: process.pid,
|
|
@@ -124,15 +196,7 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
124
196
|
|
|
125
197
|
// Safety net: clean up lock dir on process exit if _releaseFunction
|
|
126
198
|
// wasn't called (e.g., normal exit after clean completion) (#1245).
|
|
127
|
-
|
|
128
|
-
process.once("exit", () => {
|
|
129
|
-
try {
|
|
130
|
-
if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; }
|
|
131
|
-
} catch { /* best-effort */ }
|
|
132
|
-
try {
|
|
133
|
-
if (existsSync(lockDirForCleanup)) rmSync(lockDirForCleanup, { recursive: true, force: true });
|
|
134
|
-
} catch { /* best-effort */ }
|
|
135
|
-
});
|
|
199
|
+
ensureExitHandler(gsdDir);
|
|
136
200
|
|
|
137
201
|
// Write the informational lock data
|
|
138
202
|
atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
|
|
@@ -158,18 +222,15 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
|
|
|
158
222
|
update: 10_000,
|
|
159
223
|
onCompromised: () => {
|
|
160
224
|
_lockCompromised = true;
|
|
225
|
+
_releaseFunction = null;
|
|
161
226
|
},
|
|
162
227
|
});
|
|
163
228
|
_releaseFunction = release;
|
|
164
229
|
_lockedPath = basePath;
|
|
165
230
|
_lockPid = process.pid;
|
|
166
231
|
|
|
167
|
-
// Safety net
|
|
168
|
-
|
|
169
|
-
process.once("exit", () => {
|
|
170
|
-
try { if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; } } catch {}
|
|
171
|
-
try { if (existsSync(retryLockDir)) rmSync(retryLockDir, { recursive: true, force: true }); } catch {}
|
|
172
|
-
});
|
|
232
|
+
// Safety net — uses centralized handler to avoid double-registration
|
|
233
|
+
ensureExitHandler(gsdDir);
|
|
173
234
|
|
|
174
235
|
atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
|
|
175
236
|
return { acquired: true };
|
|
@@ -310,6 +371,9 @@ export function releaseSessionLock(basePath: string): void {
|
|
|
310
371
|
// Non-fatal
|
|
311
372
|
}
|
|
312
373
|
|
|
374
|
+
// Clean up numbered lock file variants from cloud sync conflicts (#1315)
|
|
375
|
+
cleanupStrayLockFiles(basePath);
|
|
376
|
+
|
|
313
377
|
_lockedPath = null;
|
|
314
378
|
_lockPid = 0;
|
|
315
379
|
_lockCompromised = false;
|
|
@@ -70,31 +70,34 @@ test("auto.ts 'all milestones complete' path merges before stopping (#962)", ()
|
|
|
70
70
|
const incompleteIdx = autoSrc.indexOf("incomplete.length === 0");
|
|
71
71
|
assert.ok(incompleteIdx > -1, "auto.ts should have 'incomplete.length === 0' check");
|
|
72
72
|
|
|
73
|
-
// The merge call must appear BETWEEN the incomplete check and the stopAuto call
|
|
74
|
-
//
|
|
73
|
+
// The merge call must appear BETWEEN the incomplete check and the stopAuto call.
|
|
74
|
+
// After the #1308 refactor, the merge is delegated to tryMergeMilestone.
|
|
75
75
|
const blockAfterIncomplete = autoSrc.slice(incompleteIdx, incompleteIdx + 3000);
|
|
76
76
|
|
|
77
77
|
assert.ok(
|
|
78
|
-
blockAfterIncomplete.includes("
|
|
79
|
-
"auto.ts should call
|
|
78
|
+
blockAfterIncomplete.includes("tryMergeMilestone"),
|
|
79
|
+
"auto.ts should call tryMergeMilestone in the 'all milestones complete' path",
|
|
80
80
|
);
|
|
81
81
|
|
|
82
82
|
// The merge should come before stopAuto in this block
|
|
83
|
-
const mergePos = blockAfterIncomplete.indexOf("
|
|
83
|
+
const mergePos = blockAfterIncomplete.indexOf("tryMergeMilestone");
|
|
84
84
|
const stopPos = blockAfterIncomplete.indexOf("stopAuto");
|
|
85
85
|
assert.ok(
|
|
86
86
|
mergePos < stopPos,
|
|
87
|
-
"
|
|
87
|
+
"tryMergeMilestone should be called before stopAuto in the 'all complete' path",
|
|
88
88
|
);
|
|
89
89
|
|
|
90
|
-
//
|
|
90
|
+
// Verify tryMergeMilestone handles both worktree and branch isolation
|
|
91
|
+
const helperIdx = autoSrc.indexOf("function tryMergeMilestone");
|
|
92
|
+
assert.ok(helperIdx > -1, "tryMergeMilestone helper should exist");
|
|
93
|
+
const helperBlock = autoSrc.slice(helperIdx, helperIdx + 2000);
|
|
91
94
|
assert.ok(
|
|
92
|
-
|
|
93
|
-
"should check isInAutoWorktree for worktree mode",
|
|
95
|
+
helperBlock.includes("isInAutoWorktree"),
|
|
96
|
+
"tryMergeMilestone should check isInAutoWorktree for worktree mode",
|
|
94
97
|
);
|
|
95
98
|
assert.ok(
|
|
96
|
-
|
|
97
|
-
"should check
|
|
99
|
+
helperBlock.includes("getIsolationMode") || helperBlock.includes("isolationMode"),
|
|
100
|
+
"tryMergeMilestone should check isolation mode for branch mode",
|
|
98
101
|
);
|
|
99
102
|
});
|
|
100
103
|
|