gsd-pi 2.65.0-dev.16e10d7 → 2.65.0-dev.6cc5110
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/session.js +4 -0
- package/dist/resources/extensions/gsd/auto-dispatch.js +5 -1
- package/dist/resources/extensions/gsd/auto-recovery.js +28 -14
- package/dist/resources/extensions/gsd/auto-start.js +7 -10
- package/dist/resources/extensions/gsd/auto.js +19 -13
- package/dist/resources/extensions/gsd/db-writer.js +13 -3
- package/dist/resources/extensions/gsd/json-persistence.js +5 -2
- package/dist/resources/extensions/gsd/state.js +12 -10
- package/dist/resources/extensions/gsd/tools/complete-milestone.js +15 -3
- package/dist/resources/extensions/gsd/tools/complete-slice.js +15 -3
- package/dist/resources/extensions/gsd/tools/complete-task.js +15 -3
- package/dist/resources/extensions/gsd/triage-resolution.js +8 -7
- package/dist/resources/extensions/gsd/undo.js +3 -2
- package/dist/resources/extensions/gsd/workflow-logger.js +1 -1
- package/dist/resources/extensions/gsd/workflow-reconcile.js +99 -6
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +20 -20
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +20 -20
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto/session.ts +4 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +5 -1
- package/src/resources/extensions/gsd/auto-recovery.ts +19 -15
- package/src/resources/extensions/gsd/auto-start.ts +7 -10
- package/src/resources/extensions/gsd/auto.ts +17 -7
- package/src/resources/extensions/gsd/db-writer.ts +11 -3
- package/src/resources/extensions/gsd/json-persistence.ts +6 -3
- package/src/resources/extensions/gsd/state.ts +11 -9
- package/src/resources/extensions/gsd/tests/integration/auto-recovery.test.ts +6 -6
- package/src/resources/extensions/gsd/tests/wave1-critical-regressions.test.ts +49 -0
- package/src/resources/extensions/gsd/tests/wave2-events-regressions.test.ts +48 -0
- package/src/resources/extensions/gsd/tests/wave3-session-regressions.test.ts +47 -0
- package/src/resources/extensions/gsd/tests/wave4-write-safety-regressions.test.ts +70 -0
- package/src/resources/extensions/gsd/tests/workflow-logger-audit.test.ts +6 -3
- package/src/resources/extensions/gsd/tools/complete-milestone.ts +13 -3
- package/src/resources/extensions/gsd/tools/complete-slice.ts +13 -3
- package/src/resources/extensions/gsd/tools/complete-task.ts +13 -3
- package/src/resources/extensions/gsd/triage-resolution.ts +8 -7
- package/src/resources/extensions/gsd/undo.ts +3 -2
- package/src/resources/extensions/gsd/workflow-events.ts +1 -1
- package/src/resources/extensions/gsd/workflow-logger.ts +1 -1
- package/src/resources/extensions/gsd/workflow-reconcile.ts +107 -5
- /package/dist/web/standalone/.next/static/{Z3TgDP0c7kG9j8CVQVGcl → iueakR5x5bQbax2sGz8Yr}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{Z3TgDP0c7kG9j8CVQVGcl → iueakR5x5bQbax2sGz8Yr}/_ssgManifest.js +0 -0
|
@@ -77,6 +77,9 @@ export class AutoSession {
|
|
|
77
77
|
milestoneMergedInPhases = false;
|
|
78
78
|
// ── Dispatch circuit breakers ──────────────────────────────────────
|
|
79
79
|
rewriteAttemptCount = 0;
|
|
80
|
+
/** Tracks consecutive bootstrap attempts that found phase === "complete".
|
|
81
|
+
* Moved from module-level to per-session so s.reset() clears it (#1348). */
|
|
82
|
+
consecutiveCompleteBootstraps = 0;
|
|
80
83
|
// ── Metrics ──────────────────────────────────────────────────────────────
|
|
81
84
|
autoStartTime = 0;
|
|
82
85
|
lastPromptCharCount;
|
|
@@ -159,6 +162,7 @@ export class AutoSession {
|
|
|
159
162
|
this.pendingQuickTasks = [];
|
|
160
163
|
this.sidecarQueue = [];
|
|
161
164
|
this.rewriteAttemptCount = 0;
|
|
165
|
+
this.consecutiveCompleteBootstraps = 0;
|
|
162
166
|
this.lastToolInvocationError = null;
|
|
163
167
|
this.isolationDegraded = false;
|
|
164
168
|
this.milestoneMergedInPhases = false;
|
|
@@ -611,13 +611,17 @@ export const DISPATCH_RULES = [
|
|
|
611
611
|
// Safety guard (#1703): verify the milestone produced implementation
|
|
612
612
|
// artifacts (non-.gsd/ files). A milestone with only plan files and
|
|
613
613
|
// zero implementation code should not be marked complete.
|
|
614
|
-
|
|
614
|
+
const artifactCheck = hasImplementationArtifacts(basePath);
|
|
615
|
+
if (artifactCheck === "absent") {
|
|
615
616
|
return {
|
|
616
617
|
action: "stop",
|
|
617
618
|
reason: `Cannot complete milestone ${mid}: no implementation files found outside .gsd/. The milestone has only plan files — actual code changes are required.`,
|
|
618
619
|
level: "error",
|
|
619
620
|
};
|
|
620
621
|
}
|
|
622
|
+
if (artifactCheck === "unknown") {
|
|
623
|
+
logWarning("dispatch", `Implementation artifact check inconclusive for ${mid} — proceeding (git context unavailable)`);
|
|
624
|
+
}
|
|
621
625
|
// Verification class compliance: if operational verification was planned,
|
|
622
626
|
// ensure the validation output documents it before allowing completion.
|
|
623
627
|
try {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* globals or AutoContext dependency.
|
|
8
8
|
*/
|
|
9
9
|
import { parseUnitId } from "./unit-id.js";
|
|
10
|
+
import { appendEvent } from "./workflow-events.js";
|
|
10
11
|
import { clearParseCache } from "./files.js";
|
|
11
12
|
import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js";
|
|
12
13
|
import { isDbAvailable, getTask, getSlice, getSliceTasks, updateTaskStatus, updateSliceStatus } from "./gsd-db.js";
|
|
@@ -27,13 +28,12 @@ export { resolveExpectedArtifactPath, diagnoseExpectedArtifact };
|
|
|
27
28
|
* in the git history. Uses `git log --name-only` to inspect all commits on the
|
|
28
29
|
* current branch that touch files outside `.gsd/`.
|
|
29
30
|
*
|
|
30
|
-
* Returns
|
|
31
|
-
*
|
|
32
|
-
* running outside a git repo (e.g., tests).
|
|
31
|
+
* Returns "present" if implementation files found, "absent" if only .gsd/ files,
|
|
32
|
+
* "unknown" if git is unavailable or check failed (callers decide how to handle).
|
|
33
33
|
*/
|
|
34
34
|
export function hasImplementationArtifacts(basePath) {
|
|
35
35
|
try {
|
|
36
|
-
// Verify we're in a git repo
|
|
36
|
+
// Verify we're in a git repo
|
|
37
37
|
try {
|
|
38
38
|
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
39
39
|
cwd: basePath,
|
|
@@ -43,7 +43,7 @@ export function hasImplementationArtifacts(basePath) {
|
|
|
43
43
|
}
|
|
44
44
|
catch (e) {
|
|
45
45
|
logWarning("recovery", `git rev-parse check failed: ${e.message}`);
|
|
46
|
-
return
|
|
46
|
+
return "unknown";
|
|
47
47
|
}
|
|
48
48
|
// Strategy: check `git diff --name-only` against the merge-base with the
|
|
49
49
|
// main branch. This captures ALL files changed during the milestone's
|
|
@@ -51,20 +51,20 @@ export function hasImplementationArtifacts(basePath) {
|
|
|
51
51
|
// back to checking the last N commits.
|
|
52
52
|
const mainBranch = detectMainBranch(basePath);
|
|
53
53
|
const changedFiles = getChangedFilesSinceBranch(basePath, mainBranch);
|
|
54
|
-
// No files changed at all —
|
|
54
|
+
// No files changed at all — unknown (could be detached HEAD, single-
|
|
55
55
|
// commit repo, or other edge case where git diff returns nothing).
|
|
56
56
|
if (changedFiles.length === 0)
|
|
57
|
-
return
|
|
57
|
+
return "unknown";
|
|
58
58
|
// Filter out .gsd/ files — only implementation files count.
|
|
59
59
|
// If every changed file is under .gsd/, the milestone produced no
|
|
60
60
|
// implementation code (#1703).
|
|
61
61
|
const implFiles = changedFiles.filter(f => !f.startsWith(".gsd/") && !f.startsWith(".gsd\\"));
|
|
62
|
-
return implFiles.length > 0;
|
|
62
|
+
return implFiles.length > 0 ? "present" : "absent";
|
|
63
63
|
}
|
|
64
64
|
catch (e) {
|
|
65
|
-
// Non-fatal — if git operations fail,
|
|
65
|
+
// Non-fatal — if git operations fail, return unknown so callers can decide
|
|
66
66
|
logWarning("recovery", `implementation artifact check failed: ${e.message}`);
|
|
67
|
-
return
|
|
67
|
+
return "unknown";
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
/**
|
|
@@ -354,7 +354,7 @@ export function verifyExpectedArtifact(unitType, unitId, base) {
|
|
|
354
354
|
// A milestone with only .gsd/ plan files and zero implementation code is
|
|
355
355
|
// not genuinely complete — the LLM wrote plan files but skipped actual work.
|
|
356
356
|
if (unitType === "complete-milestone") {
|
|
357
|
-
if (
|
|
357
|
+
if (hasImplementationArtifacts(base) === "absent")
|
|
358
358
|
return false;
|
|
359
359
|
}
|
|
360
360
|
return true;
|
|
@@ -386,21 +386,35 @@ export function writeBlockerPlaceholder(unitType, unitId, base, reason) {
|
|
|
386
386
|
// re-derives the same unit indefinitely (#2531, #2653).
|
|
387
387
|
if (isDbAvailable()) {
|
|
388
388
|
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
|
|
389
|
+
const ts = new Date().toISOString();
|
|
389
390
|
if (unitType === "execute-task" && mid && sid && tid) {
|
|
390
391
|
try {
|
|
391
|
-
updateTaskStatus(mid, sid, tid, "complete",
|
|
392
|
+
updateTaskStatus(mid, sid, tid, "complete", ts);
|
|
392
393
|
}
|
|
393
394
|
catch (e) {
|
|
394
395
|
logWarning("recovery", `updateTaskStatus failed during context exhaustion: ${e instanceof Error ? e.message : String(e)}`);
|
|
395
396
|
}
|
|
397
|
+
// Append event so worktree reconciliation can replay this recovery completion
|
|
398
|
+
try {
|
|
399
|
+
appendEvent(base, { cmd: "complete-task", params: { milestoneId: mid, sliceId: sid, taskId: tid }, ts, actor: "system", trigger_reason: "blocker-placeholder-recovery" });
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
logWarning("recovery", `appendEvent failed for task recovery: ${e instanceof Error ? e.message : String(e)}`);
|
|
403
|
+
}
|
|
396
404
|
}
|
|
397
405
|
if (unitType === "complete-slice" && mid && sid) {
|
|
398
406
|
try {
|
|
399
|
-
updateSliceStatus(mid, sid, "complete",
|
|
407
|
+
updateSliceStatus(mid, sid, "complete", ts);
|
|
400
408
|
}
|
|
401
409
|
catch (e) {
|
|
402
410
|
logWarning("recovery", `updateSliceStatus failed during context exhaustion: ${e instanceof Error ? e.message : String(e)}`);
|
|
403
411
|
}
|
|
412
|
+
try {
|
|
413
|
+
appendEvent(base, { cmd: "complete-slice", params: { milestoneId: mid, sliceId: sid }, ts, actor: "system", trigger_reason: "blocker-placeholder-recovery" });
|
|
414
|
+
}
|
|
415
|
+
catch (e) {
|
|
416
|
+
logWarning("recovery", `appendEvent failed for slice recovery: ${e instanceof Error ? e.message : String(e)}`);
|
|
417
|
+
}
|
|
404
418
|
}
|
|
405
419
|
}
|
|
406
420
|
return diagnoseExpectedArtifact(unitType, unitId, base);
|
|
@@ -455,7 +469,7 @@ export function reconcileMergeState(basePath, ctx) {
|
|
|
455
469
|
if (conflictedFiles.length === 0) {
|
|
456
470
|
// All conflicts resolved — finalize the merge/squash commit
|
|
457
471
|
try {
|
|
458
|
-
const commitSha = nativeCommit(basePath, "
|
|
472
|
+
const commitSha = nativeCommit(basePath, "chore(gsd): reconcile merge state");
|
|
459
473
|
if (commitSha) {
|
|
460
474
|
const mode = hasMergeHead ? "merge" : "squash commit";
|
|
461
475
|
ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info");
|
|
@@ -48,11 +48,8 @@ import { resolveDefaultSessionModel } from "./preferences-models.js";
|
|
|
48
48
|
* Returns false if the bootstrap aborted (e.g., guided flow returned,
|
|
49
49
|
* concurrent session detected). Returns true when ready to dispatch.
|
|
50
50
|
*/
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
* bootstrapAutoSession → showSmartEntry → checkAutoStartAfterDiscuss → startAuto
|
|
54
|
-
* cycles indefinitely when the discuss workflow doesn't produce a milestone. */
|
|
55
|
-
let _consecutiveCompleteBootstraps = 0;
|
|
51
|
+
// Guard constant for consecutive bootstrap attempts that found phase === "complete".
|
|
52
|
+
// Counter moved to AutoSession.consecutiveCompleteBootstraps so s.reset() clears it.
|
|
56
53
|
const MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS = 2;
|
|
57
54
|
export async function openProjectDbIfPresent(basePath) {
|
|
58
55
|
const gsdDbPath = resolveProjectRootDbPath(basePath);
|
|
@@ -263,9 +260,9 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
|
|
|
263
260
|
// Guard against recursive dialog loop (#1348):
|
|
264
261
|
// If we've entered this branch multiple times in quick succession,
|
|
265
262
|
// the discuss workflow isn't producing a milestone. Break the cycle.
|
|
266
|
-
|
|
267
|
-
if (
|
|
268
|
-
|
|
263
|
+
s.consecutiveCompleteBootstraps++;
|
|
264
|
+
if (s.consecutiveCompleteBootstraps > MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS) {
|
|
265
|
+
s.consecutiveCompleteBootstraps = 0;
|
|
269
266
|
ctx.ui.notify("All milestones are complete and the discussion didn't produce a new one. " +
|
|
270
267
|
"Run /gsd to start a new milestone manually.", "warning");
|
|
271
268
|
return releaseLockAndReturn();
|
|
@@ -277,7 +274,7 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
|
|
|
277
274
|
if (postState.activeMilestone &&
|
|
278
275
|
postState.phase !== "complete" &&
|
|
279
276
|
postState.phase !== "pre-planning") {
|
|
280
|
-
|
|
277
|
+
s.consecutiveCompleteBootstraps = 0; // Successfully advanced past "complete"
|
|
281
278
|
state = postState;
|
|
282
279
|
}
|
|
283
280
|
else if (postState.activeMilestone &&
|
|
@@ -338,7 +335,7 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
|
|
|
338
335
|
return releaseLockAndReturn();
|
|
339
336
|
}
|
|
340
337
|
// Successfully resolved an active milestone — reset the re-entry guard
|
|
341
|
-
|
|
338
|
+
s.consecutiveCompleteBootstraps = 0;
|
|
342
339
|
// ── Initialize session state ──
|
|
343
340
|
s.active = true;
|
|
344
341
|
s.stepMode = requestedStepMode;
|
|
@@ -845,12 +845,9 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
|
|
|
845
845
|
s.stepMode = meta.stepMode ?? requestedStepMode;
|
|
846
846
|
s.autoStartTime = meta.autoStartTime || Date.now();
|
|
847
847
|
s.paused = true;
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
catch (err) { /* non-fatal */
|
|
852
|
-
logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
|
|
853
|
-
}
|
|
848
|
+
// Don't delete pause file yet — defer until lock is acquired.
|
|
849
|
+
// If lock fails, the file must survive for retry.
|
|
850
|
+
s.pausedSessionFile = pausedPath;
|
|
854
851
|
ctx.ui.notify(`Resuming paused custom workflow${meta.activeRunDir ? ` (${meta.activeRunDir})` : ""}.`, "info");
|
|
855
852
|
}
|
|
856
853
|
else if (meta.milestoneId) {
|
|
@@ -873,13 +870,9 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
|
|
|
873
870
|
s.stepMode = meta.stepMode ?? requestedStepMode;
|
|
874
871
|
s.autoStartTime = meta.autoStartTime || Date.now();
|
|
875
872
|
s.paused = true;
|
|
876
|
-
//
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
}
|
|
880
|
-
catch (err) { /* non-fatal */
|
|
881
|
-
logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
|
|
882
|
-
}
|
|
873
|
+
// Don't delete pause file yet — defer until lock is acquired.
|
|
874
|
+
// If lock fails, the file must survive for retry.
|
|
875
|
+
s.pausedSessionFile = pausedPath;
|
|
883
876
|
ctx.ui.notify(`Resuming paused session for ${meta.milestoneId}${meta.worktreePath ? ` (worktree)` : ""}.`, "info");
|
|
884
877
|
}
|
|
885
878
|
}
|
|
@@ -893,9 +886,22 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
|
|
|
893
886
|
if (s.paused) {
|
|
894
887
|
const resumeLock = acquireSessionLock(base);
|
|
895
888
|
if (!resumeLock.acquired) {
|
|
889
|
+
// Reset paused state so isAutoPaused() doesn't stick true after lock failure.
|
|
890
|
+
// Pause file is preserved on disk for retry — not deleted.
|
|
891
|
+
s.paused = false;
|
|
896
892
|
ctx.ui.notify(`Cannot resume: ${resumeLock.reason}`, "error");
|
|
897
893
|
return;
|
|
898
894
|
}
|
|
895
|
+
// Lock acquired — now safe to delete the pause file
|
|
896
|
+
if (s.pausedSessionFile) {
|
|
897
|
+
try {
|
|
898
|
+
unlinkSync(s.pausedSessionFile);
|
|
899
|
+
}
|
|
900
|
+
catch (err) {
|
|
901
|
+
logWarning("session", `pause file cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
|
|
902
|
+
}
|
|
903
|
+
s.pausedSessionFile = null;
|
|
904
|
+
}
|
|
899
905
|
s.paused = false;
|
|
900
906
|
s.active = true;
|
|
901
907
|
s.verbose = verboseMode;
|
|
@@ -300,8 +300,13 @@ export async function saveRequirementToDb(fields, basePath) {
|
|
|
300
300
|
}
|
|
301
301
|
catch (diskErr) {
|
|
302
302
|
logError('manifest', 'disk write failed, rolling back DB row', { fn: 'saveRequirementToDb', error: String(diskErr.message) });
|
|
303
|
-
|
|
304
|
-
|
|
303
|
+
try {
|
|
304
|
+
const rollbackAdapter = db._getAdapter();
|
|
305
|
+
rollbackAdapter?.prepare('DELETE FROM requirements WHERE id = :id').run({ ':id': id });
|
|
306
|
+
}
|
|
307
|
+
catch (rollbackErr) {
|
|
308
|
+
logError('manifest', 'SPLIT BRAIN: disk write failed AND DB rollback failed — DB has orphaned row', { fn: 'saveRequirementToDb', id, error: String(rollbackErr.message) });
|
|
309
|
+
}
|
|
305
310
|
throw diskErr;
|
|
306
311
|
}
|
|
307
312
|
invalidateStateCache();
|
|
@@ -399,7 +404,12 @@ export async function saveDecisionToDb(fields, basePath) {
|
|
|
399
404
|
}
|
|
400
405
|
catch (diskErr) {
|
|
401
406
|
logError('manifest', 'disk write failed, rolling back DB row', { fn: 'saveDecisionToDb', error: String(diskErr.message) });
|
|
402
|
-
|
|
407
|
+
try {
|
|
408
|
+
adapter?.prepare('DELETE FROM decisions WHERE id = :id').run({ ':id': id });
|
|
409
|
+
}
|
|
410
|
+
catch (rollbackErr) {
|
|
411
|
+
logError('manifest', 'SPLIT BRAIN: disk write failed AND DB rollback failed — DB has orphaned row', { fn: 'saveDecisionToDb', id, error: String(rollbackErr.message) });
|
|
412
|
+
}
|
|
403
413
|
throw diskErr;
|
|
404
414
|
}
|
|
405
415
|
// #2661: When a decision defers a slice, update the slice status in the DB
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
3
4
|
/**
|
|
4
5
|
* Load a JSON file with validation, returning a default on failure.
|
|
5
6
|
* Handles missing files, corrupt JSON, and schema mismatches uniformly.
|
|
@@ -45,9 +46,11 @@ export function loadJsonFileOrNull(filePath, validate) {
|
|
|
45
46
|
export function saveJsonFile(filePath, data) {
|
|
46
47
|
try {
|
|
47
48
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
48
|
-
|
|
49
|
+
// Use randomized tmp suffix to prevent concurrent-write data loss
|
|
50
|
+
const tmp = `${filePath}.tmp.${randomBytes(4).toString("hex")}`;
|
|
49
51
|
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
50
52
|
renameSync(tmp, filePath);
|
|
53
|
+
// No cleanup needed — renameSync atomically removes tmp on success
|
|
51
54
|
}
|
|
52
55
|
catch {
|
|
53
56
|
// Non-fatal — don't let persistence failures break operation
|
|
@@ -60,7 +63,7 @@ export function saveJsonFile(filePath, data) {
|
|
|
60
63
|
export function writeJsonFileAtomic(filePath, data) {
|
|
61
64
|
try {
|
|
62
65
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
63
|
-
const tmp = filePath
|
|
66
|
+
const tmp = `${filePath}.tmp.${randomBytes(4).toString("hex")}`;
|
|
64
67
|
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
65
68
|
renameSync(tmp, filePath);
|
|
66
69
|
}
|
|
@@ -6,7 +6,7 @@ import { parseSummary, loadFile, parseRequirementCounts, parseContextDependsOn,
|
|
|
6
6
|
import { resolveMilestoneFile, resolveSlicePath, resolveSliceFile, resolveTaskFile, resolveTasksDir, resolveGsdRootFile, gsdRoot, } from './paths.js';
|
|
7
7
|
import { findMilestoneIds } from './milestone-ids.js';
|
|
8
8
|
import { loadQueueOrder, sortByQueueOrder } from './queue-order.js';
|
|
9
|
-
import { isDeferredStatus } from './status-guards.js';
|
|
9
|
+
import { isClosedStatus, isDeferredStatus } from './status-guards.js';
|
|
10
10
|
import { nativeBatchParseGsdFiles } from './native-parser-bridge.js';
|
|
11
11
|
import { join, resolve } from 'path';
|
|
12
12
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
@@ -119,7 +119,7 @@ export async function getActiveMilestoneId(basePath) {
|
|
|
119
119
|
const byId = new Map(allMilestones.map(m => [m.id, m]));
|
|
120
120
|
for (const id of sortedIds) {
|
|
121
121
|
const m = byId.get(id);
|
|
122
|
-
if (m.status
|
|
122
|
+
if (isClosedStatus(m.status) || m.status === "parked")
|
|
123
123
|
continue;
|
|
124
124
|
return m.id;
|
|
125
125
|
}
|
|
@@ -362,13 +362,10 @@ export async function deriveStateFromDb(basePath) {
|
|
|
362
362
|
completeMilestoneIds.add(m.id);
|
|
363
363
|
continue;
|
|
364
364
|
}
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
// if a summary file exists
|
|
370
|
-
// Note: without summary file, the milestone is in validating/completing state, not complete
|
|
371
|
-
}
|
|
365
|
+
// Milestones with all slices done but no SUMMARY file are in
|
|
366
|
+
// validating/completing state — intentionally NOT added to
|
|
367
|
+
// completeMilestoneIds. The SUMMARY file (checked above) is the
|
|
368
|
+
// terminal artifact that proves completion per #864.
|
|
372
369
|
}
|
|
373
370
|
// Phase 2: Build registry and find active milestone
|
|
374
371
|
const registry = [];
|
|
@@ -840,7 +837,12 @@ export async function deriveStateFromDb(basePath) {
|
|
|
840
837
|
// ── REPLAN-TRIGGER detection ─────────────────────────────────────────
|
|
841
838
|
if (!blockerTaskId) {
|
|
842
839
|
const sliceRow = getSlice(activeMilestone.id, activeSlice.id);
|
|
843
|
-
|
|
840
|
+
// Check DB column first, fall back to disk trigger file when DB write
|
|
841
|
+
// was best-effort and failed (triage-resolution.ts dual-write gap).
|
|
842
|
+
const dbTriggered = !!sliceRow?.replan_triggered_at;
|
|
843
|
+
const diskTriggered = !dbTriggered &&
|
|
844
|
+
!!resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN-TRIGGER");
|
|
845
|
+
if (dbTriggered || diskTriggered) {
|
|
844
846
|
// Loop protection: if replan_history has entries, replan was already done
|
|
845
847
|
const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id);
|
|
846
848
|
if (replanHistory.length === 0) {
|
|
@@ -15,7 +15,7 @@ import { invalidateStateCache } from "../state.js";
|
|
|
15
15
|
import { renderAllProjections, stripIdPrefix } from "../workflow-projections.js";
|
|
16
16
|
import { writeManifest } from "../workflow-manifest.js";
|
|
17
17
|
import { appendEvent } from "../workflow-events.js";
|
|
18
|
-
import { logWarning } from "../workflow-logger.js";
|
|
18
|
+
import { logWarning, logError } from "../workflow-logger.js";
|
|
19
19
|
function renderMilestoneSummaryMarkdown(params) {
|
|
20
20
|
const now = new Date().toISOString();
|
|
21
21
|
const displayTitle = stripIdPrefix(params.title, params.milestoneId);
|
|
@@ -156,9 +156,21 @@ export async function handleCompleteMilestone(params, basePath) {
|
|
|
156
156
|
clearPathCache();
|
|
157
157
|
clearParseCache();
|
|
158
158
|
// ── Post-mutation hook: projections, manifest, event log ───────────────
|
|
159
|
+
// Separate try/catch per step so a projection failure doesn't prevent
|
|
160
|
+
// the event log entry (critical for worktree reconciliation).
|
|
159
161
|
try {
|
|
160
162
|
await renderAllProjections(basePath, params.milestoneId);
|
|
163
|
+
}
|
|
164
|
+
catch (projErr) {
|
|
165
|
+
logWarning("tool", `complete-milestone projection warning: ${projErr.message}`);
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
161
168
|
writeManifest(basePath);
|
|
169
|
+
}
|
|
170
|
+
catch (mfErr) {
|
|
171
|
+
logWarning("tool", `complete-milestone manifest warning: ${mfErr.message}`);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
162
174
|
appendEvent(basePath, {
|
|
163
175
|
cmd: "complete-milestone",
|
|
164
176
|
params: { milestoneId: params.milestoneId },
|
|
@@ -168,8 +180,8 @@ export async function handleCompleteMilestone(params, basePath) {
|
|
|
168
180
|
trigger_reason: params.triggerReason,
|
|
169
181
|
});
|
|
170
182
|
}
|
|
171
|
-
catch (
|
|
172
|
-
|
|
183
|
+
catch (eventErr) {
|
|
184
|
+
logError("tool", `complete-milestone event log FAILED — completion invisible to reconciliation`, { error: eventErr.message });
|
|
173
185
|
}
|
|
174
186
|
return {
|
|
175
187
|
milestoneId: params.milestoneId,
|
|
@@ -18,7 +18,7 @@ import { renderRoadmapCheckboxes } from "../markdown-renderer.js";
|
|
|
18
18
|
import { renderAllProjections } from "../workflow-projections.js";
|
|
19
19
|
import { writeManifest } from "../workflow-manifest.js";
|
|
20
20
|
import { appendEvent } from "../workflow-events.js";
|
|
21
|
-
import { logWarning } from "../workflow-logger.js";
|
|
21
|
+
import { logWarning, logError } from "../workflow-logger.js";
|
|
22
22
|
/**
|
|
23
23
|
* Render slice summary markdown matching the template format.
|
|
24
24
|
* YAML frontmatter uses snake_case keys for parseSummary() compatibility.
|
|
@@ -276,9 +276,21 @@ export async function handleCompleteSlice(params, basePath) {
|
|
|
276
276
|
clearPathCache();
|
|
277
277
|
clearParseCache();
|
|
278
278
|
// ── Post-mutation hook: projections, manifest, event log ───────────────
|
|
279
|
+
// Separate try/catch per step so a projection failure doesn't prevent
|
|
280
|
+
// the event log entry (critical for worktree reconciliation).
|
|
279
281
|
try {
|
|
280
282
|
await renderAllProjections(basePath, params.milestoneId);
|
|
283
|
+
}
|
|
284
|
+
catch (projErr) {
|
|
285
|
+
logWarning("tool", `complete-slice projection warning for ${params.milestoneId}/${params.sliceId}: ${projErr.message}`);
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
281
288
|
writeManifest(basePath);
|
|
289
|
+
}
|
|
290
|
+
catch (mfErr) {
|
|
291
|
+
logWarning("tool", `complete-slice manifest warning: ${mfErr.message}`);
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
282
294
|
appendEvent(basePath, {
|
|
283
295
|
cmd: "complete-slice",
|
|
284
296
|
params: { milestoneId: params.milestoneId, sliceId: params.sliceId },
|
|
@@ -288,8 +300,8 @@ export async function handleCompleteSlice(params, basePath) {
|
|
|
288
300
|
trigger_reason: params.triggerReason,
|
|
289
301
|
});
|
|
290
302
|
}
|
|
291
|
-
catch (
|
|
292
|
-
|
|
303
|
+
catch (eventErr) {
|
|
304
|
+
logError("tool", `complete-slice event log FAILED — completion invisible to reconciliation`, { error: eventErr.message });
|
|
293
305
|
}
|
|
294
306
|
return {
|
|
295
307
|
sliceId: params.sliceId,
|
|
@@ -18,7 +18,7 @@ import { renderPlanCheckboxes } from "../markdown-renderer.js";
|
|
|
18
18
|
import { renderAllProjections, renderSummaryContent } from "../workflow-projections.js";
|
|
19
19
|
import { writeManifest } from "../workflow-manifest.js";
|
|
20
20
|
import { appendEvent } from "../workflow-events.js";
|
|
21
|
-
import { logWarning } from "../workflow-logger.js";
|
|
21
|
+
import { logWarning, logError } from "../workflow-logger.js";
|
|
22
22
|
/**
|
|
23
23
|
* Normalize a list parameter that may arrive as a string (newline-delimited
|
|
24
24
|
* bullet list from the LLM) into a string array (#3361).
|
|
@@ -194,9 +194,21 @@ export async function handleCompleteTask(params, basePath) {
|
|
|
194
194
|
clearPathCache();
|
|
195
195
|
clearParseCache();
|
|
196
196
|
// ── Post-mutation hook: projections, manifest, event log ───────────────
|
|
197
|
+
// Separate try/catch per step so a projection failure doesn't prevent
|
|
198
|
+
// the event log entry (critical for worktree reconciliation).
|
|
197
199
|
try {
|
|
198
200
|
await renderAllProjections(basePath, params.milestoneId);
|
|
201
|
+
}
|
|
202
|
+
catch (projErr) {
|
|
203
|
+
logWarning("tool", `complete-task projection warning: ${projErr.message}`);
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
199
206
|
writeManifest(basePath);
|
|
207
|
+
}
|
|
208
|
+
catch (mfErr) {
|
|
209
|
+
logWarning("tool", `complete-task manifest warning: ${mfErr.message}`);
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
200
212
|
appendEvent(basePath, {
|
|
201
213
|
cmd: "complete-task",
|
|
202
214
|
params: { milestoneId: params.milestoneId, sliceId: params.sliceId, taskId: params.taskId },
|
|
@@ -206,8 +218,8 @@ export async function handleCompleteTask(params, basePath) {
|
|
|
206
218
|
trigger_reason: params.triggerReason,
|
|
207
219
|
});
|
|
208
220
|
}
|
|
209
|
-
catch (
|
|
210
|
-
|
|
221
|
+
catch (eventErr) {
|
|
222
|
+
logError("tool", `complete-task event log FAILED — completion invisible to reconciliation`, { error: eventErr.message });
|
|
211
223
|
}
|
|
212
224
|
return {
|
|
213
225
|
taskId: params.taskId,
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Also provides detectFileOverlap() for surfacing downstream impact on quick tasks.
|
|
11
11
|
*/
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
13
|
+
import { atomicWriteSync } from "./atomic-write.js";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
import { createRequire } from "node:module";
|
|
15
16
|
import { gsdRoot, milestonesDir } from "./paths.js";
|
|
@@ -46,11 +47,11 @@ export function executeInject(basePath, mid, sid, capture) {
|
|
|
46
47
|
const filesSection = content.indexOf("## Files Likely Touched");
|
|
47
48
|
if (filesSection !== -1) {
|
|
48
49
|
const updated = content.slice(0, filesSection) + newTask + "\n\n" + content.slice(filesSection);
|
|
49
|
-
|
|
50
|
+
atomicWriteSync(planPath, updated, "utf-8");
|
|
50
51
|
}
|
|
51
52
|
else {
|
|
52
53
|
// No Files section — append at end
|
|
53
|
-
|
|
54
|
+
atomicWriteSync(planPath, content.trimEnd() + "\n\n" + newTask + "\n", "utf-8");
|
|
54
55
|
}
|
|
55
56
|
return newId;
|
|
56
57
|
}
|
|
@@ -78,7 +79,7 @@ export function executeReplan(basePath, mid, sid, capture) {
|
|
|
78
79
|
`This file was created by the triage pipeline. The next dispatch cycle`,
|
|
79
80
|
`will detect it and enter the replanning-slice phase.`,
|
|
80
81
|
].join("\n");
|
|
81
|
-
|
|
82
|
+
atomicWriteSync(triggerPath, content, "utf-8");
|
|
82
83
|
// Also write replan_triggered_at column for DB-backed detection
|
|
83
84
|
try {
|
|
84
85
|
const req = createRequire(import.meta.url);
|
|
@@ -146,7 +147,7 @@ export function executeBacktrack(basePath, currentMilestoneId, capture) {
|
|
|
146
147
|
`2. Identify missing features/requirements from the target milestone`,
|
|
147
148
|
`3. Resume auto-mode — the state machine will re-enter discussion for the target`,
|
|
148
149
|
].join("\n");
|
|
149
|
-
|
|
150
|
+
atomicWriteSync(triggerPath, content, "utf-8");
|
|
150
151
|
// If we have a valid target, also reset that milestone's completion status
|
|
151
152
|
// so deriveState() will re-enter it as the active milestone.
|
|
152
153
|
if (targetMilestoneId) {
|
|
@@ -156,7 +157,7 @@ export function executeBacktrack(basePath, currentMilestoneId, capture) {
|
|
|
156
157
|
// Write a regression marker so the state machine knows this milestone
|
|
157
158
|
// needs re-discussion, not just re-execution
|
|
158
159
|
const regressionPath = join(targetDir, `${targetMilestoneId}-REGRESSION.md`);
|
|
159
|
-
|
|
160
|
+
atomicWriteSync(regressionPath, [
|
|
160
161
|
`# Milestone Regression`,
|
|
161
162
|
``,
|
|
162
163
|
`**From:** ${currentMilestoneId}`,
|
|
@@ -292,7 +293,7 @@ export function ensureDeferMilestoneDir(basePath, targetMilestone, captures) {
|
|
|
292
293
|
captureList || `(no captures yet)`,
|
|
293
294
|
``,
|
|
294
295
|
].join("\n");
|
|
295
|
-
|
|
296
|
+
atomicWriteSync(join(msDir, `${targetMilestone}-CONTEXT-DRAFT.md`), draftContent, "utf-8");
|
|
296
297
|
return true;
|
|
297
298
|
}
|
|
298
299
|
catch {
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// handleUndo: Rollback the most recent completed unit (revert git, remove state, uncheck plans).
|
|
3
3
|
// handleUndoTask: Reset a single task's DB status to "pending" and re-render markdown.
|
|
4
4
|
// handleResetSlice: Reset a slice and all its tasks, re-rendering plan + roadmap.
|
|
5
|
-
import { existsSync, readFileSync,
|
|
5
|
+
import { existsSync, readFileSync, unlinkSync, readdirSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { nativeRevertCommit, nativeRevertAbort } from "./native-git-bridge.js";
|
|
8
|
+
import { atomicWriteSync } from "./atomic-write.js";
|
|
8
9
|
import { parseUnitId } from "./unit-id.js";
|
|
9
10
|
import { deriveState } from "./state.js";
|
|
10
11
|
import { invalidateAllCaches } from "./cache.js";
|
|
@@ -331,7 +332,7 @@ export function uncheckTaskInPlan(basePath, mid, sid, tid) {
|
|
|
331
332
|
const regex = new RegExp(`^(\\s*-\\s*)\\[x\\](\\s*\\**${tid}\\**[:\\s])`, "mi");
|
|
332
333
|
if (regex.test(content)) {
|
|
333
334
|
content = content.replace(regex, "$1[ ]$2");
|
|
334
|
-
|
|
335
|
+
atomicWriteSync(planFile, content);
|
|
335
336
|
return true;
|
|
336
337
|
}
|
|
337
338
|
return false;
|
|
@@ -223,7 +223,7 @@ function _sanitizeForAudit(entry) {
|
|
|
223
223
|
};
|
|
224
224
|
if (entry.context) {
|
|
225
225
|
// Allowlist: only persist known-safe structured keys
|
|
226
|
-
const SAFE_KEYS = new Set(["fn", "tool", "mid", "sid", "tid", "worktree"]);
|
|
226
|
+
const SAFE_KEYS = new Set(["fn", "tool", "mid", "sid", "tid", "worktree", "id", "error", "count"]);
|
|
227
227
|
const filtered = {};
|
|
228
228
|
for (const [k, v] of Object.entries(entry.context)) {
|
|
229
229
|
if (SAFE_KEYS.has(k)) {
|