gsd-pi 3.0.0-dev.2e8b124f7 → 3.0.0-dev.6c9a50fd0
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/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/loop.js +2 -3
- package/dist/resources/extensions/gsd/auto/orchestrator.js +2 -2
- package/dist/resources/extensions/gsd/auto/phases.js +12 -4
- package/dist/resources/extensions/gsd/auto-dispatch.js +34 -4
- package/dist/resources/extensions/gsd/auto-recovery.js +1 -0
- package/dist/resources/extensions/gsd/auto.js +27 -11
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +35 -4
- package/dist/resources/extensions/gsd/crash-recovery.js +4 -1
- package/dist/resources/extensions/gsd/db/auto-workers.js +21 -0
- package/dist/resources/extensions/gsd/preferences.js +4 -0
- package/dist/resources/extensions/gsd/repo-identity.js +39 -22
- package/dist/resources/extensions/gsd/session-lock.js +15 -2
- package/dist/resources/extensions/gsd/tools/complete-milestone.js +9 -1
- package/dist/resources/extensions/gsd/tools/complete-slice.js +50 -2
- package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +66 -40
- package/dist/resources/extensions/gsd/worktree-safety.js +10 -3
- package/dist/resources/extensions/shared/next-action-ui.js +13 -5
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
- 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 +1 -1
- 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 +6 -6
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto/contracts.ts +2 -0
- package/src/resources/extensions/gsd/auto/loop.ts +2 -2
- package/src/resources/extensions/gsd/auto/orchestrator.ts +2 -2
- package/src/resources/extensions/gsd/auto/phases.ts +14 -4
- package/src/resources/extensions/gsd/auto-dispatch.ts +52 -3
- package/src/resources/extensions/gsd/auto-recovery.ts +1 -0
- package/src/resources/extensions/gsd/auto.ts +63 -18
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +25 -4
- package/src/resources/extensions/gsd/crash-recovery.ts +3 -0
- package/src/resources/extensions/gsd/db/auto-workers.ts +25 -0
- package/src/resources/extensions/gsd/preferences.ts +4 -0
- package/src/resources/extensions/gsd/repo-identity.ts +45 -25
- package/src/resources/extensions/gsd/session-lock.ts +15 -2
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +135 -0
- package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +64 -35
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +17 -15
- package/src/resources/extensions/gsd/tests/auto-workers.test.ts +13 -0
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +51 -1
- package/src/resources/extensions/gsd/tests/complete-slice.test.ts +55 -0
- package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +111 -1
- package/src/resources/extensions/gsd/tests/integration/state-machine-live-validation.test.ts +15 -0
- package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +28 -1
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +35 -0
- package/src/resources/extensions/gsd/tests/session-switch-abort-misclassification.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/worktree-safety.test.ts +44 -0
- package/src/resources/extensions/gsd/tools/complete-milestone.ts +10 -0
- package/src/resources/extensions/gsd/tools/complete-slice.ts +51 -2
- package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +31 -17
- package/src/resources/extensions/gsd/worktree-safety.ts +12 -4
- package/src/resources/extensions/shared/next-action-ui.ts +11 -5
- package/src/resources/extensions/shared/tests/next-action-ui-hasui.test.ts +32 -0
- /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_ssgManifest.js +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
718ae1fc3f4ba06c
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
12
|
import { MAX_LOOP_ITERATIONS, } from "./types.js";
|
|
13
13
|
import { _clearCurrentResolve } from "./resolve.js";
|
|
14
|
-
import { runPreDispatch, runDispatch, runGuards, runFinalize, } from "./phases.js";
|
|
14
|
+
import { runPreDispatch, runDispatch, runGuards, runFinalize, STUCK_WINDOW_SIZE, } from "./phases.js";
|
|
15
15
|
import { debugLog } from "../debug-logger.js";
|
|
16
16
|
import { isInfrastructureError, isTransientCooldownError, getCooldownRetryAfterMs, COOLDOWN_FALLBACK_WAIT_MS, MAX_COOLDOWN_RETRIES } from "./infra-errors.js";
|
|
17
17
|
import { ModelPolicyDispatchBlockedError } from "../auto-model-selection.js";
|
|
@@ -57,7 +57,6 @@ import { handleCustomEngineReconcileOutcome } from "./workflow-custom-engine-rec
|
|
|
57
57
|
// helpers degrade to the empty-state fallback that #3704 already
|
|
58
58
|
// tolerates — same behavior as a fresh session.
|
|
59
59
|
const STUCK_RECOVERY_ATTEMPTS_KEY = "stuck_recovery_attempts";
|
|
60
|
-
const RECENT_UNIT_KEYS_LIMIT = 20;
|
|
61
60
|
function stableStuckStateScopeId(s) {
|
|
62
61
|
return normalizeRealPath(s.scope?.workspace.projectRoot ?? (s.originalBasePath || s.basePath));
|
|
63
62
|
}
|
|
@@ -66,7 +65,7 @@ function loadStuckState(s) {
|
|
|
66
65
|
if (!scopeId)
|
|
67
66
|
return { recentUnits: [], stuckRecoveryAttempts: 0 };
|
|
68
67
|
try {
|
|
69
|
-
const recentUnits = getRecentUnitKeysForProjectRoot(scopeId,
|
|
68
|
+
const recentUnits = getRecentUnitKeysForProjectRoot(scopeId, STUCK_WINDOW_SIZE);
|
|
70
69
|
const stuckRecoveryAttempts = getRuntimeKv("global", scopeId, STUCK_RECOVERY_ATTEMPTS_KEY) ?? 0;
|
|
71
70
|
return { recentUnits, stuckRecoveryAttempts };
|
|
72
71
|
}
|
|
@@ -30,7 +30,7 @@ export class AutoOrchestrator {
|
|
|
30
30
|
this.bumpTransition();
|
|
31
31
|
await this.deps.runtime.journalTransition({ name: "start" });
|
|
32
32
|
await this.deps.notifications.notifyLifecycle({ name: "start" });
|
|
33
|
-
return
|
|
33
|
+
return { kind: "started" };
|
|
34
34
|
}
|
|
35
35
|
async advance() {
|
|
36
36
|
try {
|
|
@@ -279,7 +279,7 @@ export class AutoOrchestrator {
|
|
|
279
279
|
this.bumpTransition();
|
|
280
280
|
await this.deps.runtime.journalTransition({ name: "resume" });
|
|
281
281
|
await this.deps.notifications.notifyLifecycle({ name: "resume" });
|
|
282
|
-
return
|
|
282
|
+
return { kind: "resumed" };
|
|
283
283
|
}
|
|
284
284
|
async stop(reason) {
|
|
285
285
|
if (this.status.phase === "stopped") {
|
|
@@ -46,6 +46,7 @@ import { isSuspiciousGhostCompletion } from "../auto-unit-closeout.js";
|
|
|
46
46
|
import { decideVerificationRetry, verificationRetryKey } from "./verification-retry-policy.js";
|
|
47
47
|
import { buildPhaseHandoffOutcome, setAutoOutcomeWidget } from "../auto-dashboard.js";
|
|
48
48
|
import { getConsecutiveDispatchBlocker } from "../dispatch-guard.js";
|
|
49
|
+
export const STUCK_WINDOW_SIZE = 6;
|
|
49
50
|
// ─── Path Comparison Helper ───────────────────────────────────────────────
|
|
50
51
|
/** Compare two paths for physical identity, tolerating trailing slashes and symlinks. */
|
|
51
52
|
function isSamePathLocal(a, b) {
|
|
@@ -97,7 +98,12 @@ export function shouldDegradeEmptyWorktreeToProjectRoot(worktreeClassification,
|
|
|
97
98
|
projectRootClassification.kind !== "invalid-repo");
|
|
98
99
|
}
|
|
99
100
|
function unitWritesSource(unitType) {
|
|
100
|
-
|
|
101
|
+
// Backward compatibility: sidecar queues from older builds may persist
|
|
102
|
+
// prefixed unit types (e.g. "sidecar/quick-task").
|
|
103
|
+
const normalizedUnitType = unitType.startsWith("sidecar/")
|
|
104
|
+
? unitType.slice("sidecar/".length)
|
|
105
|
+
: unitType;
|
|
106
|
+
const manifest = resolveManifest(normalizedUnitType);
|
|
101
107
|
if (!manifest)
|
|
102
108
|
return null;
|
|
103
109
|
return manifest.tools.mode === "all" || manifest.tools.mode === "docs";
|
|
@@ -151,7 +157,8 @@ async function validateSourceWriteWorktreeSafety(ic, unitType, unitId, milestone
|
|
|
151
157
|
if (!writesSource)
|
|
152
158
|
return null;
|
|
153
159
|
const projectRoot = s.canonicalProjectRoot ?? resolveWorktreeProjectRoot(s.basePath, s.originalBasePath);
|
|
154
|
-
|
|
160
|
+
const isolationMode = deps.getIsolationMode(projectRoot);
|
|
161
|
+
if (isolationMode !== "worktree")
|
|
155
162
|
return null;
|
|
156
163
|
const safety = createWorktreeSafetyModule();
|
|
157
164
|
const result = safety.validateUnitRoot({
|
|
@@ -161,6 +168,7 @@ async function validateSourceWriteWorktreeSafety(ic, unitType, unitId, milestone
|
|
|
161
168
|
projectRoot,
|
|
162
169
|
unitRoot: s.basePath,
|
|
163
170
|
milestoneId,
|
|
171
|
+
isolationMode,
|
|
164
172
|
expectedBranch: milestoneId ? deps.autoWorktreeBranch(milestoneId) : null,
|
|
165
173
|
emptyWorktreeWithProjectContent: resolveEmptyWorktreeWithProjectContent(s.basePath, projectRoot),
|
|
166
174
|
lease: s.workerId
|
|
@@ -899,7 +907,6 @@ export async function runPreDispatch(ic, loopState) {
|
|
|
899
907
|
export async function runDispatch(ic, preData, loopState) {
|
|
900
908
|
const { ctx, pi, s, deps, prefs } = ic;
|
|
901
909
|
const { state, mid, midTitle } = preData;
|
|
902
|
-
const STUCK_WINDOW_SIZE = 6;
|
|
903
910
|
const provider = ctx.model?.provider;
|
|
904
911
|
const authMode = provider && typeof ctx.modelRegistry?.getProviderAuthMode === "function"
|
|
905
912
|
? ctx.modelRegistry.getProviderAuthMode(provider)
|
|
@@ -1007,8 +1014,9 @@ export async function runDispatch(ic, preData, loopState) {
|
|
|
1007
1014
|
// Rules 1/3/4 can catch retry loops with repeated failure content (#5719).
|
|
1008
1015
|
// Rules 2/2b suppress legitimate retry backoff through the dispatch ledger.
|
|
1009
1016
|
loopState.recentUnits.push({ key: derivedKey });
|
|
1010
|
-
|
|
1017
|
+
while (loopState.recentUnits.length > STUCK_WINDOW_SIZE) {
|
|
1011
1018
|
loopState.recentUnits.shift();
|
|
1019
|
+
}
|
|
1012
1020
|
const stuckSignal = detectStuck(loopState.recentUnits);
|
|
1013
1021
|
if (stuckSignal) {
|
|
1014
1022
|
debugLog("autoLoop", {
|
|
@@ -4,7 +4,7 @@ import { loadFile, extractUatType, loadActiveOverrides } from "./files.js";
|
|
|
4
4
|
import { isDbAvailable, getMilestoneSlices, getPendingGates, markAllGatesOmitted, getMilestone, insertAssessment, transaction } from "./gsd-db.js";
|
|
5
5
|
import { isClosedStatus } from "./status-guards.js";
|
|
6
6
|
import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js";
|
|
7
|
-
import { gsdRoot, resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveTaskFile, relSliceFile, buildMilestoneFileName, } from "./paths.js";
|
|
7
|
+
import { gsdRoot, resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveTaskFile, relTaskFile, relSliceFile, buildMilestoneFileName, buildTaskFileName, gsdProjectionRoot, } from "./paths.js";
|
|
8
8
|
import { parseRoadmap } from "./parsers-legacy.js";
|
|
9
9
|
import { validateArtifact } from "./schemas/validate.js";
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
@@ -27,6 +27,8 @@ import { annotateBackgroundable } from "./delegation-policy.js";
|
|
|
27
27
|
import { invalidateAllCaches } from "./cache.js";
|
|
28
28
|
import { insertMilestoneValidationGates } from "./milestone-validation-gates.js";
|
|
29
29
|
import { nativeHasChanges } from "./native-git-bridge.js";
|
|
30
|
+
import { debugLog, isDebugEnabled } from "./debug-logger.js";
|
|
31
|
+
import { resolveCanonicalMilestoneRoot } from "./worktree-manager.js";
|
|
30
32
|
let reassessmentChecker = checkNeedsReassessment;
|
|
31
33
|
let researchProjectPromptBuilder = buildResearchProjectPrompt;
|
|
32
34
|
function shouldBypassMilestoneDepthGateInAuto(prefs) {
|
|
@@ -96,6 +98,15 @@ export function shouldRunDeepProjectSetup(state, prefs, basePath, options = {})
|
|
|
96
98
|
}
|
|
97
99
|
return hasPendingDeepStage(prefs, basePath);
|
|
98
100
|
}
|
|
101
|
+
function resolveArtifactBasePath(basePath, mid, session) {
|
|
102
|
+
if (session?.basePath &&
|
|
103
|
+
session.currentMilestoneId === mid &&
|
|
104
|
+
session.basePath !== session.originalBasePath &&
|
|
105
|
+
existsSync(session.basePath)) {
|
|
106
|
+
return session.basePath;
|
|
107
|
+
}
|
|
108
|
+
return resolveCanonicalMilestoneRoot(basePath, mid);
|
|
109
|
+
}
|
|
99
110
|
function missingSliceStop(mid, phase) {
|
|
100
111
|
return {
|
|
101
112
|
action: "stop",
|
|
@@ -951,7 +962,7 @@ export const DISPATCH_RULES = [
|
|
|
951
962
|
},
|
|
952
963
|
{
|
|
953
964
|
name: "executing → execute-task (recover missing task plan → plan-slice)",
|
|
954
|
-
match: async ({ state, mid, midTitle, basePath, sessionContextWindow, modelRegistry, sessionProvider }) => {
|
|
965
|
+
match: async ({ state, mid, midTitle, basePath, session, sessionContextWindow, modelRegistry, sessionProvider }) => {
|
|
955
966
|
if (state.phase !== "executing" || !state.activeTask)
|
|
956
967
|
return null;
|
|
957
968
|
if (!state.activeSlice)
|
|
@@ -959,13 +970,32 @@ export const DISPATCH_RULES = [
|
|
|
959
970
|
const sid = state.activeSlice.id;
|
|
960
971
|
const sTitle = state.activeSlice.title;
|
|
961
972
|
const tid = state.activeTask.id;
|
|
973
|
+
const artifactBasePath = resolveArtifactBasePath(basePath, mid, session);
|
|
962
974
|
// Guard: if the slice plan exists but the individual task plan files are
|
|
963
975
|
// missing, the planner created S##-PLAN.md with task entries but never
|
|
964
976
|
// wrote the tasks/ directory files. Dispatch plan-slice to regenerate
|
|
965
977
|
// them rather than hard-stopping — fixes the infinite-loop described in
|
|
966
978
|
// issue #909.
|
|
967
|
-
const taskPlanPath = resolveTaskFile(
|
|
968
|
-
|
|
979
|
+
const taskPlanPath = resolveTaskFile(artifactBasePath, mid, sid, tid, "PLAN");
|
|
980
|
+
const projectionTaskPlanPath = join(gsdProjectionRoot(artifactBasePath), "milestones", mid, "slices", sid, "tasks", buildTaskFileName(tid, "PLAN"));
|
|
981
|
+
if ((!taskPlanPath || !existsSync(taskPlanPath)) && !existsSync(projectionTaskPlanPath)) {
|
|
982
|
+
if (isDebugEnabled()) {
|
|
983
|
+
const expectedTaskPlanPath = join(artifactBasePath, relTaskFile(artifactBasePath, mid, sid, tid, "PLAN"));
|
|
984
|
+
const originalProjectRoot = session?.originalBasePath || basePath;
|
|
985
|
+
const activeMilestoneWorktreePath = session?.basePath || basePath;
|
|
986
|
+
const artifactExists = taskPlanPath ? existsSync(taskPlanPath) : false;
|
|
987
|
+
debugLog("dispatch-missing-task-plan-recovery", {
|
|
988
|
+
selectedDispatchRule: "executing → execute-task (recover missing task plan → plan-slice)",
|
|
989
|
+
basePathUsedForArtifactChecks: artifactBasePath,
|
|
990
|
+
milestoneRoot: artifactBasePath,
|
|
991
|
+
originalProjectRoot,
|
|
992
|
+
activeMilestoneWorktreePath,
|
|
993
|
+
expectedTaskPlanPath,
|
|
994
|
+
projectionTaskPlanPath,
|
|
995
|
+
artifactExists,
|
|
996
|
+
projectionArtifactExists: existsSync(projectionTaskPlanPath),
|
|
997
|
+
});
|
|
998
|
+
}
|
|
969
999
|
return {
|
|
970
1000
|
action: "dispatch",
|
|
971
1001
|
unitType: "plan-slice",
|
|
@@ -188,6 +188,7 @@ export function hasImplementationArtifacts(basePath, milestoneId) {
|
|
|
188
188
|
return "unknown";
|
|
189
189
|
if (milestoneEvidence.matched)
|
|
190
190
|
return classifyImplementationFiles(milestoneEvidence.files);
|
|
191
|
+
return "unknown";
|
|
191
192
|
}
|
|
192
193
|
if (currentBranch && currentBranch !== "HEAD")
|
|
193
194
|
return "absent";
|
|
@@ -52,7 +52,7 @@ import { createAutoWorktree, teardownAutoWorktree, isInAutoWorktree, getAutoWork
|
|
|
52
52
|
import { pruneQueueOrder } from "./queue-order.js";
|
|
53
53
|
import { startCommandPolling as _startCommandPolling, isRemoteConfigured } from "../remote-questions/manager.js";
|
|
54
54
|
import { debugLog, isDebugEnabled, writeDebugSummary } from "./debug-logger.js";
|
|
55
|
-
import { reconcileMergeState, } from "./auto-recovery.js";
|
|
55
|
+
import { reconcileMergeState, verifyExpectedArtifact, } from "./auto-recovery.js";
|
|
56
56
|
import { classifyMilestoneSummaryContent } from "./milestone-summary-classifier.js";
|
|
57
57
|
import { resolveDispatch, DISPATCH_RULES } from "./auto-dispatch.js";
|
|
58
58
|
import { getErrorMessage } from "./error-utils.js";
|
|
@@ -110,7 +110,7 @@ export { STUB_RECOVERY_THRESHOLD, NEW_SESSION_TIMEOUT_MS, } from "./auto/session
|
|
|
110
110
|
import { autoSession as s } from "./auto-runtime-state.js";
|
|
111
111
|
import { gsdHome } from "./gsd-home.js";
|
|
112
112
|
import { createWorkspace, scopeMilestone } from "./workspace.js";
|
|
113
|
-
import { registerAutoWorker, markWorkerStopping } from "./db/auto-workers.js";
|
|
113
|
+
import { registerAutoWorker, markWorkerStopping, markWorkerStoppingByPid, } from "./db/auto-workers.js";
|
|
114
114
|
import { releaseMilestoneLease } from "./db/milestone-leases.js";
|
|
115
115
|
import { normalizeRealPath } from "./paths.js";
|
|
116
116
|
// ── ENCAPSULATION INVARIANT ─────────────────────────────────────────────────
|
|
@@ -259,6 +259,27 @@ function synthesizePausedSessionRecovery(basePath, unitType, unitId, sessionFile
|
|
|
259
259
|
export function _synthesizePausedSessionRecoveryForTest(basePath, unitType, unitId, sessionFile) {
|
|
260
260
|
return synthesizePausedSessionRecovery(basePath, unitType, unitId, sessionFile);
|
|
261
261
|
}
|
|
262
|
+
function handlePausedSessionResumeRecovery(basePath, state, notify) {
|
|
263
|
+
if (!state.pausedSessionFile)
|
|
264
|
+
return { skippedReplay: false };
|
|
265
|
+
const pausedRecoveryUnitType = state.currentUnit?.type ?? state.pausedUnitType ?? "unknown";
|
|
266
|
+
const pausedRecoveryUnitId = state.currentUnit?.id ?? state.pausedUnitId ?? "unknown";
|
|
267
|
+
const completedPausedUnit = verifyExpectedArtifact(pausedRecoveryUnitType, pausedRecoveryUnitId, basePath);
|
|
268
|
+
if (completedPausedUnit) {
|
|
269
|
+
state.pausedSessionFile = null;
|
|
270
|
+
return { skippedReplay: true };
|
|
271
|
+
}
|
|
272
|
+
const recovery = synthesizePausedSessionRecovery(basePath, pausedRecoveryUnitType, pausedRecoveryUnitId, state.pausedSessionFile);
|
|
273
|
+
if (recovery && recovery.trace.toolCallCount > 0) {
|
|
274
|
+
state.pendingCrashRecovery = recovery.prompt;
|
|
275
|
+
notify(`Recovered ${recovery.trace.toolCallCount} tool calls from paused session. Resuming with context.`);
|
|
276
|
+
}
|
|
277
|
+
state.pausedSessionFile = null;
|
|
278
|
+
return { skippedReplay: false };
|
|
279
|
+
}
|
|
280
|
+
export function _handlePausedSessionResumeRecoveryForTest(basePath, state) {
|
|
281
|
+
return handlePausedSessionResumeRecovery(basePath, state, () => { });
|
|
282
|
+
}
|
|
262
283
|
// `_resolvePausedResumeBasePathForTest` was retired in ADR-016 phase 2 / B3
|
|
263
284
|
// (#5621). Production callers go through
|
|
264
285
|
// `WorktreeLifecycle.resumeFromPausedSession`; the pure helper for tests is
|
|
@@ -566,6 +587,7 @@ export function checkRemoteAutoSession(projectRoot) {
|
|
|
566
587
|
return { running: false };
|
|
567
588
|
if (!isLockProcessAlive(lock)) {
|
|
568
589
|
// Stale lock from a dead process — not a live remote session
|
|
590
|
+
markWorkerStoppingByPid(normalizeRealPath(projectRoot), lock.pid);
|
|
569
591
|
return { running: false };
|
|
570
592
|
}
|
|
571
593
|
return {
|
|
@@ -1430,7 +1452,7 @@ export function createWiredDispatchAdapter(ctx, pi, dispatchBasePath, session) {
|
|
|
1430
1452
|
midTitle: active.title,
|
|
1431
1453
|
state,
|
|
1432
1454
|
prefs,
|
|
1433
|
-
session: input.session,
|
|
1455
|
+
session: input.session ?? session,
|
|
1434
1456
|
structuredQuestionsAvailable,
|
|
1435
1457
|
sessionContextWindow,
|
|
1436
1458
|
sessionProvider,
|
|
@@ -1546,6 +1568,7 @@ export function createWiredAutoOrchestrationModule(ctx, pi, dispatchBasePath, ru
|
|
|
1546
1568
|
projectRoot: runtimeBasePath,
|
|
1547
1569
|
unitRoot: dispatchBasePath,
|
|
1548
1570
|
milestoneId,
|
|
1571
|
+
isolationMode: getIsolationMode(runtimeBasePath),
|
|
1549
1572
|
expectedBranch,
|
|
1550
1573
|
});
|
|
1551
1574
|
if (!result.ok) {
|
|
@@ -2104,14 +2127,7 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
|
|
|
2104
2127
|
});
|
|
2105
2128
|
}
|
|
2106
2129
|
invalidateAllCaches();
|
|
2107
|
-
|
|
2108
|
-
const recovery = synthesizePausedSessionRecovery(s.basePath, s.currentUnit?.type ?? s.pausedUnitType ?? "unknown", s.currentUnit?.id ?? s.pausedUnitId ?? "unknown", s.pausedSessionFile);
|
|
2109
|
-
if (recovery && recovery.trace.toolCallCount > 0) {
|
|
2110
|
-
s.pendingCrashRecovery = recovery.prompt;
|
|
2111
|
-
ctx.ui.notify(`Recovered ${recovery.trace.toolCallCount} tool calls from paused session. Resuming with context.`, "info");
|
|
2112
|
-
}
|
|
2113
|
-
s.pausedSessionFile = null;
|
|
2114
|
-
}
|
|
2130
|
+
handlePausedSessionResumeRecovery(s.basePath, s, (message) => ctx.ui.notify(message, "info"));
|
|
2115
2131
|
captureProjectRootEnv(s.originalBasePath || s.basePath);
|
|
2116
2132
|
registerAutoWorkerForSession(s);
|
|
2117
2133
|
updateSessionLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown");
|
|
@@ -20,7 +20,20 @@ function isObjectRecord(value) {
|
|
|
20
20
|
return !!value && typeof value === "object";
|
|
21
21
|
}
|
|
22
22
|
export function _hasEmptyAgentEndContent(content) {
|
|
23
|
-
|
|
23
|
+
if (content == null)
|
|
24
|
+
return true;
|
|
25
|
+
if (!Array.isArray(content))
|
|
26
|
+
return false;
|
|
27
|
+
if (content.length === 0)
|
|
28
|
+
return true;
|
|
29
|
+
return content.every((block) => {
|
|
30
|
+
if (!block || typeof block !== "object")
|
|
31
|
+
return true;
|
|
32
|
+
const typedBlock = block;
|
|
33
|
+
if (typedBlock.type !== "text")
|
|
34
|
+
return false;
|
|
35
|
+
return typeof typedBlock.text !== "string" || typedBlock.text.trim() === "";
|
|
36
|
+
});
|
|
24
37
|
}
|
|
25
38
|
/**
|
|
26
39
|
* Cap on auto-resume attempts for sustained transient-provider errors.
|
|
@@ -248,9 +261,27 @@ export async function handleAgentEnd(pi, event, ctx) {
|
|
|
248
261
|
return;
|
|
249
262
|
}
|
|
250
263
|
if (isBareClaudeCodeStreamAbortPlaceholder(lastMsg)) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
264
|
+
if (isSessionSwitchAbortGraceActive()) {
|
|
265
|
+
// Old turn leaking through after a session switch — drop it.
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Mid-unit stream abort with no diagnostic. Treat as non-fatal so the loop
|
|
269
|
+
// can continue, but surface it to the user and resolve the in-flight unit.
|
|
270
|
+
ctx.ui.notify("Claude Code stream aborted mid-unit (no diagnostic). Continuing.", "warning");
|
|
271
|
+
try {
|
|
272
|
+
resetRetryState(retryState);
|
|
273
|
+
resolveAgentEnd(event);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
277
|
+
ctx.ui.notify(`Auto-mode error after stream-abort placeholder: ${message}. Stopping auto-mode.`, "error");
|
|
278
|
+
try {
|
|
279
|
+
await pauseAuto(ctx, pi);
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
logWarning("bootstrap", `pauseAuto failed after stream-abort placeholder: ${e.message}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
254
285
|
return;
|
|
255
286
|
}
|
|
256
287
|
if (isObjectRecord(lastMsg) && "stopReason" in lastMsg && lastMsg.stopReason === "aborted") {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { emitJournalEvent, queryJournal, } from "./journal.js";
|
|
24
24
|
import { readFileSync, unlinkSync, existsSync } from "node:fs";
|
|
25
25
|
import { join } from "node:path";
|
|
26
|
-
import { findStaleWorkerForProject, getAllAutoWorkers, markWorkerCrashed, markWorkerStopping, } from "./db/auto-workers.js";
|
|
26
|
+
import { findStaleWorkerForProject, getAllAutoWorkers, markWorkerCrashed, markWorkerStopping, markWorkerStoppingByPid, } from "./db/auto-workers.js";
|
|
27
27
|
import { forceReleaseLeasesForWorker } from "./db/milestone-leases.js";
|
|
28
28
|
import { markLatestActiveForWorkerCanceled } from "./db/unit-dispatches.js";
|
|
29
29
|
import { getRuntimeKv, setRuntimeKv, deleteRuntimeKv } from "./db/runtime-kv.js";
|
|
@@ -197,6 +197,9 @@ export function clearLock(basePath) {
|
|
|
197
197
|
deleteRuntimeKv("worker", staleWorker.worker_id, SESSION_FILE_KV_KEY);
|
|
198
198
|
return;
|
|
199
199
|
}
|
|
200
|
+
const lock = readLegacyLock(basePath);
|
|
201
|
+
if (lock?.pid)
|
|
202
|
+
markWorkerStoppingByPid(projectRoot, lock.pid);
|
|
200
203
|
const worker = findActiveWorkerForCurrentProcess(projectRoot);
|
|
201
204
|
if (worker)
|
|
202
205
|
deleteRuntimeKv("worker", worker.worker_id, SESSION_FILE_KV_KEY);
|
|
@@ -122,6 +122,27 @@ export function markWorkerStopping(workerId) {
|
|
|
122
122
|
db.prepare(`UPDATE workers SET status = 'stopping' WHERE worker_id = :worker_id`).run({ ":worker_id": workerId });
|
|
123
123
|
});
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Mark the active worker row for a specific PID/project root as stopping.
|
|
127
|
+
* Used when we detect a dead PID from lock metadata before heartbeat expiry.
|
|
128
|
+
*/
|
|
129
|
+
export function markWorkerStoppingByPid(projectRootRealpath, pid) {
|
|
130
|
+
if (!isDbAvailable())
|
|
131
|
+
return;
|
|
132
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
133
|
+
return;
|
|
134
|
+
const db = _getAdapter();
|
|
135
|
+
transaction(() => {
|
|
136
|
+
db.prepare(`UPDATE workers
|
|
137
|
+
SET status = 'stopping'
|
|
138
|
+
WHERE pid = :pid
|
|
139
|
+
AND project_root_realpath = :project_root
|
|
140
|
+
AND status = 'active'`).run({
|
|
141
|
+
":pid": pid,
|
|
142
|
+
":project_root": projectRootRealpath,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
125
146
|
/**
|
|
126
147
|
* Return all workers whose status is 'active' AND whose heartbeat is within
|
|
127
148
|
* the TTL window. Workers older than the TTL are NOT auto-marked crashed
|
|
@@ -274,6 +274,10 @@ export function applyModeDefaults(mode, prefs) {
|
|
|
274
274
|
}
|
|
275
275
|
function mergePreferences(base, override) {
|
|
276
276
|
return {
|
|
277
|
+
// Preserve validated preference keys that do not need custom merge logic.
|
|
278
|
+
// The explicit fields below still own defaults, arrays, and deep merges.
|
|
279
|
+
...base,
|
|
280
|
+
...override,
|
|
277
281
|
version: override.version ?? base.version,
|
|
278
282
|
mode: override.mode ?? base.mode,
|
|
279
283
|
always_use_skills: mergeStringLists(base.always_use_skills, override.always_use_skills),
|
|
@@ -405,19 +405,32 @@ function hasProjectState(externalPath) {
|
|
|
405
405
|
* Returns the resolved external path (may differ from the computed identity).
|
|
406
406
|
*/
|
|
407
407
|
function resolveExternalPathWithRecovery(projectPath) {
|
|
408
|
-
const
|
|
408
|
+
const base = process.env.GSD_STATE_DIR || gsdHome();
|
|
409
409
|
const computedId = repoIdentity(projectPath);
|
|
410
|
+
const computedPath = join(base, "projects", computedId);
|
|
411
|
+
const computedHasState = hasProjectState(computedPath);
|
|
412
|
+
const markerId = readGsdIdMarker(projectPath);
|
|
413
|
+
const markerPath = markerId ? join(base, "projects", markerId) : null;
|
|
414
|
+
const markerHasState = markerPath ? hasProjectState(markerPath) : false;
|
|
415
|
+
// Split-brain guard: when marker and computed identities disagree and both
|
|
416
|
+
// directories contain state, prefer the marker-backed directory. This keeps
|
|
417
|
+
// all writers anchored to a single canonical project identity even if a
|
|
418
|
+
// transient identity computation produced a stale computed hash.
|
|
419
|
+
if (markerId
|
|
420
|
+
&& markerPath
|
|
421
|
+
&& markerId !== computedId
|
|
422
|
+
&& markerHasState
|
|
423
|
+
&& computedHasState) {
|
|
424
|
+
return { path: markerPath, identity: markerId };
|
|
425
|
+
}
|
|
410
426
|
// Check if computed path already has state — fast path, no recovery needed.
|
|
411
|
-
if (
|
|
412
|
-
return computedPath;
|
|
427
|
+
if (computedHasState) {
|
|
428
|
+
return { path: computedPath, identity: computedId };
|
|
413
429
|
}
|
|
414
430
|
// Check for .gsd-id marker from a previous location.
|
|
415
|
-
|
|
416
|
-
if (markerId && markerId !== computedId) {
|
|
431
|
+
if (markerId && markerPath && markerId !== computedId) {
|
|
417
432
|
// The marker points to a different identity — the repo was likely moved.
|
|
418
|
-
|
|
419
|
-
const markerPath = join(base, "projects", markerId);
|
|
420
|
-
if (hasProjectState(markerPath)) {
|
|
433
|
+
if (markerHasState) {
|
|
421
434
|
// Recover: use the old state directory and update the marker to the new identity.
|
|
422
435
|
// Move the state from the old hash dir to the new one so future lookups work
|
|
423
436
|
// without the marker.
|
|
@@ -446,11 +459,11 @@ function resolveExternalPathWithRecovery(projectPath) {
|
|
|
446
459
|
}
|
|
447
460
|
catch {
|
|
448
461
|
// If migration fails, just point at the old directory.
|
|
449
|
-
return markerPath;
|
|
462
|
+
return { path: markerPath, identity: markerId };
|
|
450
463
|
}
|
|
451
464
|
}
|
|
452
465
|
}
|
|
453
|
-
return computedPath;
|
|
466
|
+
return { path: computedPath, identity: computedId };
|
|
454
467
|
}
|
|
455
468
|
// ─── Symlink Management ─────────────────────────────────────────────────────
|
|
456
469
|
/**
|
|
@@ -467,17 +480,18 @@ function resolveExternalPathWithRecovery(projectPath) {
|
|
|
467
480
|
* Returns the resolved external path.
|
|
468
481
|
*/
|
|
469
482
|
export function ensureGsdSymlink(projectPath) {
|
|
470
|
-
const result = ensureGsdSymlinkCore(projectPath);
|
|
483
|
+
const { path: result, identity } = ensureGsdSymlinkCore(projectPath);
|
|
471
484
|
// Write .gsd-id marker so future relocations can recover this state (#2750).
|
|
472
485
|
// Only write for the project root (not subdirectories or worktrees that
|
|
473
486
|
// delegate to a parent .gsd).
|
|
474
487
|
if (!isInsideWorktree(projectPath)) {
|
|
475
|
-
writeGsdIdMarker(projectPath,
|
|
488
|
+
writeGsdIdMarker(projectPath, identity);
|
|
476
489
|
}
|
|
477
490
|
return result;
|
|
478
491
|
}
|
|
479
492
|
function ensureGsdSymlinkCore(projectPath) {
|
|
480
|
-
const
|
|
493
|
+
const resolved = resolveExternalPathWithRecovery(projectPath);
|
|
494
|
+
const externalPath = resolved.path;
|
|
481
495
|
const localGsd = join(projectPath, ".gsd");
|
|
482
496
|
const inWorktree = isInsideWorktree(projectPath);
|
|
483
497
|
// Guard: Never create a symlink at ~/.gsd — that's the user-level GSD home,
|
|
@@ -498,7 +512,7 @@ function ensureGsdSymlinkCore(projectPath) {
|
|
|
498
512
|
const localGsdNormalized = normalizeForGuard(localGsd);
|
|
499
513
|
const gsdHomeNorm = normalizeForGuard(gsdHome());
|
|
500
514
|
if (localGsdNormalized === gsdHomeNorm) {
|
|
501
|
-
return localGsd;
|
|
515
|
+
return { path: localGsd, identity: resolved.identity };
|
|
502
516
|
}
|
|
503
517
|
// Guard: If projectPath is a plain subdirectory (not a worktree) of a git
|
|
504
518
|
// repo that already has a .gsd at the git root, do not create a duplicate
|
|
@@ -516,7 +530,10 @@ function ensureGsdSymlinkCore(projectPath) {
|
|
|
516
530
|
try {
|
|
517
531
|
const rootStat = lstatSync(rootGsd);
|
|
518
532
|
if (rootStat.isSymbolicLink() || rootStat.isDirectory()) {
|
|
519
|
-
return
|
|
533
|
+
return {
|
|
534
|
+
path: rootStat.isSymbolicLink() ? realpathSync(rootGsd) : rootGsd,
|
|
535
|
+
identity: resolved.identity,
|
|
536
|
+
};
|
|
520
537
|
}
|
|
521
538
|
}
|
|
522
539
|
catch {
|
|
@@ -544,7 +561,7 @@ function ensureGsdSymlinkCore(projectPath) {
|
|
|
544
561
|
}
|
|
545
562
|
catch { /* already gone */ }
|
|
546
563
|
symlinkSync(externalPath, localGsd, "junction");
|
|
547
|
-
return
|
|
564
|
+
return resolved;
|
|
548
565
|
};
|
|
549
566
|
// Check for dangling symlinks (e.g. after relocation recovery removed the old
|
|
550
567
|
// state dir). existsSync follows symlinks, so it returns false for dangling ones.
|
|
@@ -567,7 +584,7 @@ function ensureGsdSymlinkCore(projectPath) {
|
|
|
567
584
|
}
|
|
568
585
|
catch { /* nothing to remove */ }
|
|
569
586
|
symlinkSync(externalPath, localGsd, "junction");
|
|
570
|
-
return
|
|
587
|
+
return resolved;
|
|
571
588
|
}
|
|
572
589
|
try {
|
|
573
590
|
const stat = lstatSync(localGsd);
|
|
@@ -575,7 +592,7 @@ function ensureGsdSymlinkCore(projectPath) {
|
|
|
575
592
|
// Already a symlink — verify it points to the right place
|
|
576
593
|
const target = realpathSync(localGsd);
|
|
577
594
|
if (target === externalPath) {
|
|
578
|
-
return
|
|
595
|
+
return resolved; // correct symlink, no-op
|
|
579
596
|
}
|
|
580
597
|
// In a worktree, mismatched symlinks are always stale. Heal them so
|
|
581
598
|
// the worktree points at the same external state dir as the main repo.
|
|
@@ -610,24 +627,24 @@ function ensureGsdSymlinkCore(projectPath) {
|
|
|
610
627
|
}
|
|
611
628
|
catch {
|
|
612
629
|
// Migration failed — preserve old symlink
|
|
613
|
-
return target;
|
|
630
|
+
return { path: target, identity: resolved.identity };
|
|
614
631
|
}
|
|
615
632
|
}
|
|
616
633
|
// Outside worktrees, preserve custom overrides or legacy symlinks.
|
|
617
|
-
return target;
|
|
634
|
+
return { path: target, identity: resolved.identity };
|
|
618
635
|
}
|
|
619
636
|
if (stat.isDirectory()) {
|
|
620
637
|
// Real directory in the main repo — migration will handle this later.
|
|
621
638
|
// In worktrees, keep the directory in place and let syncGsdStateToWorktree
|
|
622
639
|
// refresh its contents. Replacing a git-tracked .gsd directory with a
|
|
623
640
|
// symlink makes git think tracked planning files were deleted.
|
|
624
|
-
return localGsd;
|
|
641
|
+
return { path: localGsd, identity: resolved.identity };
|
|
625
642
|
}
|
|
626
643
|
}
|
|
627
644
|
catch {
|
|
628
645
|
// lstat failed — path exists but we can't stat it
|
|
629
646
|
}
|
|
630
|
-
return localGsd;
|
|
647
|
+
return { path: localGsd, identity: resolved.identity };
|
|
631
648
|
}
|
|
632
649
|
// ─── Worktree Detection ─────────────────────────────────────────────────────
|
|
633
650
|
/**
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
19
|
import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
|
|
20
20
|
import { join, dirname } from "node:path";
|
|
21
|
-
import { gsdRoot } from "./paths.js";
|
|
21
|
+
import { gsdRoot, normalizeRealPath } from "./paths.js";
|
|
22
22
|
import { atomicWriteSync } from "./atomic-write.js";
|
|
23
|
+
import { markWorkerStoppingByPid } from "./db/auto-workers.js";
|
|
23
24
|
const _require = createRequire(import.meta.url);
|
|
24
25
|
// ─── Module State ───────────────────────────────────────────────────────────
|
|
25
26
|
/** Release function from proper-lockfile — calling it releases the OS lock. */
|
|
@@ -225,6 +226,12 @@ export function acquireSessionLock(basePath) {
|
|
|
225
226
|
mkdirSync(dirname(lp), { recursive: true });
|
|
226
227
|
// Clean up numbered lock file variants from cloud sync conflicts (#1315)
|
|
227
228
|
cleanupStrayLockFiles(basePath);
|
|
229
|
+
// If lock metadata points to a dead PID, mark that worker row stopping so
|
|
230
|
+
// crash diagnostics do not keep surfacing it as active.
|
|
231
|
+
const existingPreflight = readExistingLockData(lp);
|
|
232
|
+
if (existingPreflight?.pid && !isPidAlive(existingPreflight.pid)) {
|
|
233
|
+
markWorkerStoppingByPid(normalizeRealPath(basePath), existingPreflight.pid);
|
|
234
|
+
}
|
|
228
235
|
// Write our lock data first (the content is informational; the OS lock is the real guard)
|
|
229
236
|
const lockData = {
|
|
230
237
|
pid: process.pid,
|
|
@@ -250,7 +257,10 @@ export function acquireSessionLock(basePath) {
|
|
|
250
257
|
const lockDir = lockTarget + ".lock";
|
|
251
258
|
if (existsSync(lockDir)) {
|
|
252
259
|
const existingData = readExistingLockData(lp);
|
|
253
|
-
const
|
|
260
|
+
const deadPid = existingData?.pid && !isPidAlive(existingData.pid) ? existingData.pid : null;
|
|
261
|
+
if (deadPid)
|
|
262
|
+
markWorkerStoppingByPid(normalizeRealPath(basePath), deadPid);
|
|
263
|
+
const isOrphan = !existingData || !!deadPid;
|
|
254
264
|
if (isOrphan) {
|
|
255
265
|
try {
|
|
256
266
|
rmSync(lockDir, { recursive: true, force: true });
|
|
@@ -288,6 +298,9 @@ export function acquireSessionLock(basePath) {
|
|
|
288
298
|
// Check: if auto.lock is gone and no process is alive, the lock dir is stale.
|
|
289
299
|
const existingData = readExistingLockData(lp);
|
|
290
300
|
const existingPid = existingData?.pid;
|
|
301
|
+
if (existingPid && !isPidAlive(existingPid)) {
|
|
302
|
+
markWorkerStoppingByPid(normalizeRealPath(basePath), existingPid);
|
|
303
|
+
}
|
|
291
304
|
// If no lock file or no alive process, try to clean up and re-acquire (#1245)
|
|
292
305
|
if (!existingData || (existingPid && !isPidAlive(existingPid))) {
|
|
293
306
|
try {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { mkdirSync, existsSync } from "node:fs";
|
|
11
|
-
import { transaction, getMilestone, getMilestoneSlices, getSliceTasks, updateMilestoneStatus, } from "../gsd-db.js";
|
|
11
|
+
import { transaction, getMilestone, getMilestoneSlices, getSliceTasks, getLatestAssessmentByScope, updateMilestoneStatus, } from "../gsd-db.js";
|
|
12
12
|
import { resolveMilestonePath, clearPathCache } from "../paths.js";
|
|
13
13
|
import { isClosedStatus } from "../status-guards.js";
|
|
14
14
|
import { saveFile, clearParseCache } from "../files.js";
|
|
@@ -99,6 +99,14 @@ export async function handleCompleteMilestone(params, basePath) {
|
|
|
99
99
|
alreadyComplete = true;
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
|
+
// Defense-in-depth: only a passing milestone validation permits closeout.
|
|
103
|
+
const validation = getLatestAssessmentByScope(params.milestoneId, "milestone-validation");
|
|
104
|
+
if (validation?.status !== "pass") {
|
|
105
|
+
guardError =
|
|
106
|
+
`Refusing to complete ${params.milestoneId}: latest milestone-validation verdict is ` +
|
|
107
|
+
`"${validation?.status ?? "absent"}". Only verdict=pass permits closeout.`;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
102
110
|
// Verify all slices are complete
|
|
103
111
|
const slices = getMilestoneSlices(params.milestoneId);
|
|
104
112
|
if (slices.length === 0) {
|