gsd-pi 2.80.0-dev.e51d2c88c → 2.80.0-dev.e6c48c3af
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/phases.js +30 -6
- package/dist/resources/extensions/gsd/auto/run-unit.js +4 -1
- package/dist/resources/extensions/gsd/auto-direct-dispatch.js +1 -1
- package/dist/resources/extensions/gsd/auto.js +18 -1
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +54 -4
- package/dist/resources/extensions/gsd/clean-root-preflight.js +24 -6
- package/dist/resources/extensions/gsd/git-service.js +36 -4
- package/dist/resources/extensions/gsd/pre-execution-checks.js +7 -0
- package/dist/resources/extensions/gsd/prompts/complete-milestone.md +2 -0
- package/dist/resources/extensions/gsd/worktree-resolver.js +33 -17
- 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 +11 -11
- 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 +11 -11
- 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/packages/pi-coding-agent/dist/core/agent-session-tool-refresh.test.js +15 -0
- package/packages/pi-coding-agent/dist/core/agent-session-tool-refresh.test.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +4 -3
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
- package/packages/pi-coding-agent/src/core/agent-session-tool-refresh.test.ts +18 -0
- package/packages/pi-coding-agent/src/core/agent-session.ts +6 -3
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -0
- package/packages/pi-coding-agent/src/core/extensions/types.ts +5 -0
- package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
- package/src/resources/extensions/gsd/auto/loop-deps.ts +2 -2
- package/src/resources/extensions/gsd/auto/phases.ts +50 -15
- package/src/resources/extensions/gsd/auto/run-unit.ts +4 -1
- package/src/resources/extensions/gsd/auto-direct-dispatch.ts +1 -1
- package/src/resources/extensions/gsd/auto.ts +18 -1
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +66 -4
- package/src/resources/extensions/gsd/clean-root-preflight.ts +32 -7
- package/src/resources/extensions/gsd/git-service.ts +46 -8
- package/src/resources/extensions/gsd/pre-execution-checks.ts +7 -0
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +2 -0
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +144 -1
- package/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts +166 -4
- package/src/resources/extensions/gsd/tests/clean-root-preflight.test.ts +15 -6
- package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +5 -1
- package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +54 -0
- package/src/resources/extensions/gsd/tests/journal-integration.test.ts +5 -1
- package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +63 -1
- package/src/resources/extensions/gsd/worktree-resolver.ts +36 -15
- /package/dist/web/standalone/.next/static/{8F5YpnZNBaooIWGF4GBV3 → 4dQ9NTZJ8pEvFwBgDUX93}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{8F5YpnZNBaooIWGF4GBV3 → 4dQ9NTZJ8pEvFwBgDUX93}/_ssgManifest.js +0 -0
|
@@ -23,7 +23,7 @@ import type { CmuxLogLevel } from "../../shared/cmux-events.js";
|
|
|
23
23
|
import type { JournalEntry } from "../journal.js";
|
|
24
24
|
import type { MergeReconcileResult } from "../auto-recovery.js";
|
|
25
25
|
import type { UokTurnObserver } from "../uok/contracts.js";
|
|
26
|
-
import type { PreflightResult } from "../clean-root-preflight.js";
|
|
26
|
+
import type { PostflightResult, PreflightResult } from "../clean-root-preflight.js";
|
|
27
27
|
|
|
28
28
|
type PauseAutoFn = (
|
|
29
29
|
ctx?: ExtensionContext,
|
|
@@ -141,7 +141,7 @@ export interface LoopDeps {
|
|
|
141
141
|
milestoneId: string,
|
|
142
142
|
stashMarker: string | undefined,
|
|
143
143
|
notify: (message: string, level: "info" | "warning" | "error") => void,
|
|
144
|
-
) =>
|
|
144
|
+
) => PostflightResult;
|
|
145
145
|
|
|
146
146
|
// Budget/context/secrets
|
|
147
147
|
getLedger: () => unknown;
|
|
@@ -57,6 +57,7 @@ import { getEligibleSlices } from "../slice-parallel-eligibility.js";
|
|
|
57
57
|
import { startSliceParallel } from "../slice-parallel-orchestrator.js";
|
|
58
58
|
import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js";
|
|
59
59
|
import type { MinimalModelRegistry } from "../context-budget.js";
|
|
60
|
+
import type { PostflightResult, PreflightResult } from "../clean-root-preflight.js";
|
|
60
61
|
import { ensurePlanV2Graph, isEmptyPlanV2GraphResult, isMissingFinalizedContextResult } from "../uok/plan-v2.js";
|
|
61
62
|
import { resolveUokFlags } from "../uok/flags.js";
|
|
62
63
|
import { UokGateRunner } from "../uok/gate-runner.js";
|
|
@@ -209,6 +210,38 @@ async function closeoutAndStop(
|
|
|
209
210
|
await deps.stopAuto(ctx, pi, reason);
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
async function stopOnPostflightRecoveryNeeded(
|
|
214
|
+
ic: IterationContext,
|
|
215
|
+
result: PostflightResult,
|
|
216
|
+
milestoneId: string,
|
|
217
|
+
): Promise<{ action: "break"; reason: string } | null> {
|
|
218
|
+
if (!result.needsManualRecovery) return null;
|
|
219
|
+
const { ctx, pi, deps } = ic;
|
|
220
|
+
const reason = `Post-merge stash restore failed for milestone ${milestoneId}`;
|
|
221
|
+
ctx.ui.notify(
|
|
222
|
+
`${reason}. Resolve the working tree before resuming auto-mode. ${result.message}`,
|
|
223
|
+
"error",
|
|
224
|
+
);
|
|
225
|
+
await deps.stopAuto(ctx, pi, reason);
|
|
226
|
+
return { action: "break", reason: "postflight-stash-restore-failed" };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function restorePreflightStashOrStop(
|
|
230
|
+
ic: IterationContext,
|
|
231
|
+
preflight: PreflightResult,
|
|
232
|
+
milestoneId: string,
|
|
233
|
+
): Promise<{ action: "break"; reason: string } | null> {
|
|
234
|
+
if (!preflight.stashPushed) return null;
|
|
235
|
+
const { ctx, s, deps } = ic;
|
|
236
|
+
const result = deps.postflightPopStash(
|
|
237
|
+
s.originalBasePath || s.basePath,
|
|
238
|
+
milestoneId,
|
|
239
|
+
preflight.stashMarker,
|
|
240
|
+
ctx.ui.notify.bind(ctx.ui),
|
|
241
|
+
);
|
|
242
|
+
return stopOnPostflightRecoveryNeeded(ic, result, milestoneId);
|
|
243
|
+
}
|
|
244
|
+
|
|
212
245
|
async function emitCancelledUnitEnd(
|
|
213
246
|
ic: IterationContext,
|
|
214
247
|
unitType: string,
|
|
@@ -654,6 +687,8 @@ export async function runPreDispatch(
|
|
|
654
687
|
);
|
|
655
688
|
try {
|
|
656
689
|
deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
|
|
690
|
+
// Prevent stopAuto() from attempting the same merge again if postflight recovery stops here.
|
|
691
|
+
s.milestoneMergedInPhases = true;
|
|
657
692
|
} catch (mergeErr) {
|
|
658
693
|
if (mergeErr instanceof MergeConflictError) {
|
|
659
694
|
// Real code conflicts — stop the loop instead of retrying forever (#2330)
|
|
@@ -674,13 +709,13 @@ export async function runPreDispatch(
|
|
|
674
709
|
return { action: "break", reason: "merge-failed" };
|
|
675
710
|
}
|
|
676
711
|
// #2909: postflight — restore stashed changes after successful merge
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
712
|
+
{
|
|
713
|
+
const postflightStop = await restorePreflightStashOrStop(
|
|
714
|
+
ic,
|
|
715
|
+
preflightTransition,
|
|
680
716
|
s.currentMilestoneId!,
|
|
681
|
-
preflightTransition.stashMarker,
|
|
682
|
-
ctx.ui.notify.bind(ctx.ui),
|
|
683
717
|
);
|
|
718
|
+
if (postflightStop) return postflightStop;
|
|
684
719
|
}
|
|
685
720
|
|
|
686
721
|
// PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
|
|
@@ -788,13 +823,13 @@ export async function runPreDispatch(
|
|
|
788
823
|
return { action: "break", reason: "merge-failed" };
|
|
789
824
|
}
|
|
790
825
|
// #2909: postflight — restore stashed changes after successful merge
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
826
|
+
{
|
|
827
|
+
const postflightStop = await restorePreflightStashOrStop(
|
|
828
|
+
ic,
|
|
829
|
+
preflightAllComplete,
|
|
794
830
|
s.currentMilestoneId,
|
|
795
|
-
preflightAllComplete.stashMarker,
|
|
796
|
-
ctx.ui.notify.bind(ctx.ui),
|
|
797
831
|
);
|
|
832
|
+
if (postflightStop) return postflightStop;
|
|
798
833
|
}
|
|
799
834
|
|
|
800
835
|
// PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
|
|
@@ -917,13 +952,13 @@ export async function runPreDispatch(
|
|
|
917
952
|
return { action: "break", reason: "merge-failed" };
|
|
918
953
|
}
|
|
919
954
|
// #2909: postflight — restore stashed changes after successful merge
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
955
|
+
{
|
|
956
|
+
const postflightStop = await restorePreflightStashOrStop(
|
|
957
|
+
ic,
|
|
958
|
+
preflightComplete,
|
|
923
959
|
s.currentMilestoneId,
|
|
924
|
-
preflightComplete.stashMarker,
|
|
925
|
-
ctx.ui.notify.bind(ctx.ui),
|
|
926
960
|
);
|
|
961
|
+
if (postflightStop) return postflightStop;
|
|
927
962
|
}
|
|
928
963
|
|
|
929
964
|
// PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302)
|
|
@@ -85,7 +85,10 @@ export async function runUnit(
|
|
|
85
85
|
const sessionAbortController = new AbortController();
|
|
86
86
|
_setSessionSwitchInFlight(true);
|
|
87
87
|
try {
|
|
88
|
-
const sessionPromise = s.cmdCtx!.newSession({
|
|
88
|
+
const sessionPromise = s.cmdCtx!.newSession({
|
|
89
|
+
abortSignal: sessionAbortController.signal,
|
|
90
|
+
cwd: s.basePath,
|
|
91
|
+
}).finally(() => {
|
|
89
92
|
if (sessionSwitchGeneration === mySessionSwitchGeneration) {
|
|
90
93
|
_setSessionSwitchInFlight(false);
|
|
91
94
|
}
|
|
@@ -306,7 +306,7 @@ export async function dispatchDirectPhase(
|
|
|
306
306
|
return;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
-
const result = await ctx.newSession();
|
|
309
|
+
const result = await ctx.newSession({ cwd: dispatchBase });
|
|
310
310
|
if (result.cancelled) {
|
|
311
311
|
ctx.ui.notify("Session creation cancelled.", "warning");
|
|
312
312
|
return;
|
|
@@ -1034,6 +1034,8 @@ export async function stopAuto(
|
|
|
1034
1034
|
if (s.workerId) {
|
|
1035
1035
|
markWorkerStopping(s.workerId);
|
|
1036
1036
|
}
|
|
1037
|
+
s.workerId = null;
|
|
1038
|
+
s.milestoneLeaseToken = null;
|
|
1037
1039
|
} catch (e) {
|
|
1038
1040
|
debugLog("stop-cleanup-coordination", { error: e instanceof Error ? e.message : String(e) });
|
|
1039
1041
|
}
|
|
@@ -1171,6 +1173,21 @@ export async function stopAuto(
|
|
|
1171
1173
|
debugLog("stop-cleanup-basepath", { error: e instanceof Error ? e.message : String(e) });
|
|
1172
1174
|
}
|
|
1173
1175
|
|
|
1176
|
+
// Re-root the active command session/tool runtime after worktree teardown.
|
|
1177
|
+
// mergeAndExit restores process.cwd(), but AgentSession has already captured
|
|
1178
|
+
// its own cwd for tools and system prompt; refresh it before returning to the
|
|
1179
|
+
// user so follow-up commands do not target a removed milestone worktree.
|
|
1180
|
+
if (s.originalBasePath && ctx && s.cmdCtx) {
|
|
1181
|
+
try {
|
|
1182
|
+
const result = await s.cmdCtx.newSession({ cwd: s.basePath });
|
|
1183
|
+
if (result.cancelled) {
|
|
1184
|
+
logWarning("engine", "post-stop session re-root was cancelled", { file: "auto.ts", basePath: s.basePath });
|
|
1185
|
+
}
|
|
1186
|
+
} catch (err) {
|
|
1187
|
+
logWarning("engine", `post-stop session re-root failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts", basePath: s.basePath });
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1174
1191
|
// ── Step 8: Ledger notification ──
|
|
1175
1192
|
try {
|
|
1176
1193
|
const ledger = getLedger();
|
|
@@ -2316,7 +2333,7 @@ export async function dispatchHookUnit(
|
|
|
2316
2333
|
return false;
|
|
2317
2334
|
}
|
|
2318
2335
|
|
|
2319
|
-
const result = await s.cmdCtx!.newSession();
|
|
2336
|
+
const result = await s.cmdCtx!.newSession({ cwd: s.basePath });
|
|
2320
2337
|
if (result.cancelled) {
|
|
2321
2338
|
await stopAuto(ctx, pi);
|
|
2322
2339
|
return false;
|
|
@@ -31,6 +31,13 @@ import { approvalGateIdForUnit, isExplicitApprovalResponse, shouldPauseForUserAp
|
|
|
31
31
|
let isFirstSession = true;
|
|
32
32
|
let approvalQuestionAbortInFlight = false;
|
|
33
33
|
|
|
34
|
+
interface DeferredApprovalGate {
|
|
35
|
+
gateId: string;
|
|
36
|
+
basePath: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let deferredApprovalGate: DeferredApprovalGate | null = null;
|
|
40
|
+
|
|
34
41
|
async function deriveGsdState(basePath: string) {
|
|
35
42
|
const { deriveState } = await import("../state.js");
|
|
36
43
|
return deriveState(basePath);
|
|
@@ -85,6 +92,46 @@ async function applyCompactionThresholdOverride(ctx: ExtensionContext): Promise<
|
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
|
|
95
|
+
function clearDeferredApprovalGate(basePath?: string): void {
|
|
96
|
+
if (!basePath || deferredApprovalGate?.basePath === basePath) {
|
|
97
|
+
deferredApprovalGate = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function deferApprovalGate(gateId: string, basePath: string): void {
|
|
102
|
+
deferredApprovalGate = { gateId, basePath };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function activateDeferredApprovalGate(basePath: string): void {
|
|
106
|
+
if (deferredApprovalGate?.basePath !== basePath) return;
|
|
107
|
+
setPendingGate(deferredApprovalGate.gateId, basePath);
|
|
108
|
+
deferredApprovalGate = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isContextDraftSummarySave(toolName: string, input: unknown): boolean {
|
|
112
|
+
if (toolName !== "gsd_summary_save" && toolName !== "summary_save") return false;
|
|
113
|
+
if (!input || typeof input !== "object") return false;
|
|
114
|
+
return (input as { artifact_type?: unknown }).artifact_type === "CONTEXT-DRAFT";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function shouldBlockDeferredApprovalTool(
|
|
118
|
+
toolName: string,
|
|
119
|
+
input: unknown,
|
|
120
|
+
basePath: string,
|
|
121
|
+
): { block: boolean; reason?: string } {
|
|
122
|
+
if (deferredApprovalGate?.basePath !== basePath) return { block: false };
|
|
123
|
+
if (toolName === "ask_user_questions") return { block: false };
|
|
124
|
+
if (isContextDraftSummarySave(toolName, input)) return { block: false };
|
|
125
|
+
return {
|
|
126
|
+
block: true,
|
|
127
|
+
reason: [
|
|
128
|
+
`HARD BLOCK: Approval question "${deferredApprovalGate.gateId}" has been shown to the user.`,
|
|
129
|
+
`Only CONTEXT-DRAFT persistence may finish in this same assistant turn.`,
|
|
130
|
+
`Wait for the user's answer before calling additional tools.`,
|
|
131
|
+
].join(" "),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
88
135
|
export function resolveNotificationStoreBasePath(cwd: string = process.cwd()): string {
|
|
89
136
|
return resolveWorktreeProjectRoot(cwd);
|
|
90
137
|
}
|
|
@@ -140,6 +187,7 @@ export function registerHooks(
|
|
|
140
187
|
resetWriteGateState(process.cwd());
|
|
141
188
|
resetToolCallLoopGuard();
|
|
142
189
|
approvalQuestionAbortInFlight = false;
|
|
190
|
+
clearDeferredApprovalGate();
|
|
143
191
|
await resetAskUserQuestionsTurnCache();
|
|
144
192
|
await syncServiceTierStatus(ctx);
|
|
145
193
|
await applyDisabledModelProviderPolicy(ctx);
|
|
@@ -189,6 +237,7 @@ export function registerHooks(
|
|
|
189
237
|
initSessionNotifications(ctx);
|
|
190
238
|
resetWriteGateState(process.cwd());
|
|
191
239
|
resetToolCallLoopGuard();
|
|
240
|
+
clearDeferredApprovalGate();
|
|
192
241
|
await resetAskUserQuestionsTurnCache();
|
|
193
242
|
clearDiscussionFlowState(process.cwd());
|
|
194
243
|
await syncServiceTierStatus(ctx);
|
|
@@ -225,6 +274,7 @@ export function registerHooks(
|
|
|
225
274
|
if (milestoneId) markDepthVerified(milestoneId, beforeAgentBasePath);
|
|
226
275
|
clearPendingGate(beforeAgentBasePath);
|
|
227
276
|
}
|
|
277
|
+
clearDeferredApprovalGate(beforeAgentBasePath);
|
|
228
278
|
|
|
229
279
|
// GSD's own context injection (existing behavior — unchanged).
|
|
230
280
|
const { buildBeforeAgentStartResult } = await import("./system-context.js");
|
|
@@ -275,7 +325,11 @@ export function registerHooks(
|
|
|
275
325
|
resetToolCallLoopGuard();
|
|
276
326
|
await resetAskUserQuestionsTurnCache();
|
|
277
327
|
const { handleAgentEnd } = await import("./agent-end-recovery.js");
|
|
278
|
-
|
|
328
|
+
try {
|
|
329
|
+
await handleAgentEnd(pi, event, ctx);
|
|
330
|
+
} finally {
|
|
331
|
+
activateDeferredApprovalGate(process.cwd());
|
|
332
|
+
}
|
|
279
333
|
});
|
|
280
334
|
|
|
281
335
|
// Squash-merge quick-task branch back to the original branch after the
|
|
@@ -377,15 +431,16 @@ export function registerHooks(
|
|
|
377
431
|
if (!shouldPauseForUserApprovalQuestion(unitType, [event.message])) return;
|
|
378
432
|
|
|
379
433
|
const gateId = approvalGateIdForUnit(unitType, unitId);
|
|
380
|
-
if (gateId)
|
|
434
|
+
if (gateId) deferApprovalGate(gateId, process.cwd());
|
|
381
435
|
|
|
382
436
|
approvalQuestionAbortInFlight = true;
|
|
383
437
|
ctx.ui.notify(
|
|
384
438
|
`${unitType}${unitId ? ` ${unitId}` : ""} is waiting for your approval - pausing before more tool calls run.`,
|
|
385
439
|
"info",
|
|
386
440
|
);
|
|
387
|
-
// The pending gate
|
|
388
|
-
//
|
|
441
|
+
// The durable pending gate is activated at agent_end so same-turn
|
|
442
|
+
// CONTEXT-DRAFT persistence can finish after the text boundary streams.
|
|
443
|
+
// The tool_call hook below still blocks non-draft tools in this turn.
|
|
389
444
|
// Aborting mid-stream eats the model's question text on external CLI
|
|
390
445
|
// providers (Claude Code SDK) because lastTextContent isn't populated
|
|
391
446
|
// from in-flight builder state — the user only ever sees "Claude Code
|
|
@@ -417,6 +472,13 @@ export function registerHooks(
|
|
|
417
472
|
return { block: true, reason: loopCheck.reason };
|
|
418
473
|
}
|
|
419
474
|
|
|
475
|
+
const deferredGateGuard = shouldBlockDeferredApprovalTool(
|
|
476
|
+
toolName,
|
|
477
|
+
event.input,
|
|
478
|
+
discussionBasePath,
|
|
479
|
+
);
|
|
480
|
+
if (deferredGateGuard.block) return deferredGateGuard;
|
|
481
|
+
|
|
420
482
|
// ── Discussion gate enforcement: track pending gate questions ─────────
|
|
421
483
|
// Only gate-shaped ask_user_questions calls should block execution.
|
|
422
484
|
// The gate stays pending until the user selects the approval option.
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* #2909: Adds a fast-path git status check before milestone completion merges.
|
|
5
5
|
* When the working tree is dirty the user is warned and changes are auto-stashed
|
|
6
|
-
* so the merge can proceed cleanly.
|
|
7
|
-
* restores the stashed changes.
|
|
6
|
+
* so the merge can proceed cleanly. After the merge completes, postflightPopStash
|
|
7
|
+
* restores the stashed changes and reports whether manual recovery is needed.
|
|
8
8
|
*
|
|
9
9
|
* Design constraints (from Trek-e approval):
|
|
10
10
|
* - Warn the user before stashing (no silent surprises)
|
|
11
11
|
* - git stash push / git stash pop only — no custom stash management layer
|
|
12
|
-
* - Stash/pop errors are logged but MUST NOT block the merge
|
|
12
|
+
* - Stash/pop errors are logged but MUST NOT block the merge itself
|
|
13
13
|
* - Fast-path status check — clean trees pay no extra cost
|
|
14
14
|
*/
|
|
15
15
|
|
|
@@ -27,6 +27,13 @@ export interface PreflightResult {
|
|
|
27
27
|
summary: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export interface PostflightResult {
|
|
31
|
+
restored: boolean;
|
|
32
|
+
needsManualRecovery: boolean;
|
|
33
|
+
message: string;
|
|
34
|
+
stashRef?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
function findPreflightStashRef(basePath: string, milestoneId: string, stashMarker?: string): string | null {
|
|
31
38
|
const markerPrefix = `gsd-preflight-stash:${milestoneId}:`;
|
|
32
39
|
let fallbackRef: string | null = null;
|
|
@@ -112,14 +119,15 @@ export function preflightCleanRoot(
|
|
|
112
119
|
*
|
|
113
120
|
* Only called when preflightCleanRoot returned stashPushed=true.
|
|
114
121
|
* Any pop error (e.g. conflict) is logged and notified but does NOT throw —
|
|
115
|
-
* the merge already completed successfully.
|
|
122
|
+
* the merge already completed successfully. Callers must treat
|
|
123
|
+
* needsManualRecovery=true as a dirty workspace stop, not a clean completion.
|
|
116
124
|
*/
|
|
117
125
|
export function postflightPopStash(
|
|
118
126
|
basePath: string,
|
|
119
127
|
milestoneId: string,
|
|
120
128
|
stashMarker: string | undefined,
|
|
121
129
|
notify: (message: string, level: "info" | "warning" | "error") => void,
|
|
122
|
-
):
|
|
130
|
+
): PostflightResult {
|
|
123
131
|
let stashRef: string | null = null;
|
|
124
132
|
try {
|
|
125
133
|
stashRef = findPreflightStashRef(basePath, milestoneId, stashMarker);
|
|
@@ -127,7 +135,11 @@ export function postflightPopStash(
|
|
|
127
135
|
const msg = `No matching GSD preflight stash found for milestone ${milestoneId}; leaving stash list untouched.`;
|
|
128
136
|
logWarning("preflight", msg);
|
|
129
137
|
notify(msg, "warning");
|
|
130
|
-
return
|
|
138
|
+
return {
|
|
139
|
+
restored: false,
|
|
140
|
+
needsManualRecovery: true,
|
|
141
|
+
message: msg,
|
|
142
|
+
};
|
|
131
143
|
}
|
|
132
144
|
execFileSync("git", ["stash", "pop", stashRef], {
|
|
133
145
|
cwd: basePath,
|
|
@@ -135,7 +147,14 @@ export function postflightPopStash(
|
|
|
135
147
|
encoding: "utf-8",
|
|
136
148
|
env: GIT_NO_PROMPT_ENV,
|
|
137
149
|
});
|
|
138
|
-
|
|
150
|
+
const msg = `Restored stashed changes after milestone ${milestoneId} merge.`;
|
|
151
|
+
notify(msg, "info");
|
|
152
|
+
return {
|
|
153
|
+
restored: true,
|
|
154
|
+
needsManualRecovery: false,
|
|
155
|
+
message: msg,
|
|
156
|
+
stashRef,
|
|
157
|
+
};
|
|
139
158
|
} catch (err) {
|
|
140
159
|
// Pop conflicts mean the merged code collides with the stashed changes.
|
|
141
160
|
// Log a warning — the user needs to resolve manually, but the merge succeeded.
|
|
@@ -145,5 +164,11 @@ export function postflightPopStash(
|
|
|
145
164
|
const msg = `git stash pop ${stashRef ?? ""}`.trim() + ` failed after merge of milestone ${milestoneId}: ${err instanceof Error ? err.message : String(err)}. ${restoreHint}`;
|
|
146
165
|
logWarning("preflight", msg);
|
|
147
166
|
notify(msg, "warning");
|
|
167
|
+
return {
|
|
168
|
+
restored: false,
|
|
169
|
+
needsManualRecovery: true,
|
|
170
|
+
message: msg,
|
|
171
|
+
...(stashRef ? { stashRef } : {}),
|
|
172
|
+
};
|
|
148
173
|
}
|
|
149
174
|
}
|
|
@@ -14,6 +14,7 @@ import { isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
|
14
14
|
import { gsdRoot } from "./paths.js";
|
|
15
15
|
import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
|
|
16
16
|
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
17
|
+
import { logWarning } from "./workflow-logger.js";
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
import {
|
|
@@ -722,16 +723,53 @@ export class GitServiceImpl {
|
|
|
722
723
|
if (keyFiles.length === 0) return false;
|
|
723
724
|
|
|
724
725
|
const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
726
|
+
const normalized = keyFiles
|
|
727
|
+
.map(file => normalizeRepoRelativePath(this.basePath, file))
|
|
728
|
+
.filter((file): file is string => file !== null)
|
|
729
|
+
.filter(file => !isExcludedScopedPath(file, allExclusions));
|
|
730
|
+
|
|
731
|
+
// Drop entries that don't exist on disk. The LLM occasionally lists files
|
|
732
|
+
// it intended to write but didn't (or names them with wrong casing/path).
|
|
733
|
+
// Pre-`b304f738b` `git add -A` swallowed these silently; the scoped
|
|
734
|
+
// pathspec form passes each path explicitly, so a single bad entry made
|
|
735
|
+
// the whole commit fail (see #5500). Filter so valid paths still commit.
|
|
736
|
+
const missing: string[] = [];
|
|
737
|
+
const existing: string[] = [];
|
|
738
|
+
for (const path of normalized) {
|
|
739
|
+
if (existsSync(join(this.basePath, path))) {
|
|
740
|
+
existing.push(path);
|
|
741
|
+
} else {
|
|
742
|
+
missing.push(path);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (missing.length > 0) {
|
|
746
|
+
logWarning(
|
|
747
|
+
"engine",
|
|
748
|
+
`scoped stage: dropping ${missing.length} non-existent keyFile(s) from task commit: ${missing.join(", ")}`,
|
|
749
|
+
{ file: "git-service.ts" },
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const paths = Array.from(new Set(existing));
|
|
731
754
|
if (paths.length === 0) return false;
|
|
732
755
|
|
|
733
|
-
|
|
734
|
-
|
|
756
|
+
try {
|
|
757
|
+
nativeAddPaths(this.basePath, paths);
|
|
758
|
+
return true;
|
|
759
|
+
} catch (err) {
|
|
760
|
+
// Defense-in-depth: even after existence filtering, libgit2/git can
|
|
761
|
+
// still reject paths (gitignore matches, case-only differences on
|
|
762
|
+
// case-insensitive FS, submodule boundaries). Returning false lets
|
|
763
|
+
// autoCommit fall through to smartStage so the commit still goes out
|
|
764
|
+
// — restoring the resilience the unscoped path used to provide.
|
|
765
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
766
|
+
logWarning(
|
|
767
|
+
"engine",
|
|
768
|
+
`scoped stage failed (${msg}); falling back to smartStage`,
|
|
769
|
+
{ file: "git-service.ts" },
|
|
770
|
+
);
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
735
773
|
}
|
|
736
774
|
|
|
737
775
|
/** Tracks whether runtime file cleanup has run this session. */
|
|
@@ -380,6 +380,8 @@ function shouldValidateInputAsPath(raw: string): boolean {
|
|
|
380
380
|
const trimmed = raw.trim();
|
|
381
381
|
if (!trimmed) return false;
|
|
382
382
|
|
|
383
|
+
if (isRuntimeOnlyInput(trimmed)) return false;
|
|
384
|
+
|
|
383
385
|
const candidate = extractPathFromAnnotation(trimmed);
|
|
384
386
|
if (!candidate) return false;
|
|
385
387
|
|
|
@@ -405,6 +407,10 @@ function shouldValidateInputAsPath(raw: string): boolean {
|
|
|
405
407
|
);
|
|
406
408
|
}
|
|
407
409
|
|
|
410
|
+
function isRuntimeOnlyInput(raw: string): boolean {
|
|
411
|
+
return /\(\s*runtime\s*\)/i.test(raw);
|
|
412
|
+
}
|
|
413
|
+
|
|
408
414
|
function containsGlobPattern(candidate: string): boolean {
|
|
409
415
|
return ["*", "?", "[", "]", "{", "}"].some((char) => candidate.includes(char));
|
|
410
416
|
}
|
|
@@ -535,6 +541,7 @@ export function checkTaskOrdering(
|
|
|
535
541
|
const filesToCheck = [...task.inputs];
|
|
536
542
|
|
|
537
543
|
for (const file of filesToCheck) {
|
|
544
|
+
if (isRuntimeOnlyInput(file)) continue;
|
|
538
545
|
if (!shouldValidateInputAsPath(file)) continue;
|
|
539
546
|
|
|
540
547
|
const normalizedFile = normalizeFilePath(file);
|
|
@@ -6,6 +6,8 @@ You are executing GSD auto-mode.
|
|
|
6
6
|
|
|
7
7
|
Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory.
|
|
8
8
|
|
|
9
|
+
If any inlined plan, summary, verification command, or prior artifact names an absolute path outside `{{workingDirectory}}`, treat that path as stale context. Convert it to the equivalent relative path under `{{workingDirectory}}` before reading, writing, or executing. If no equivalent path exists under `{{workingDirectory}}`, record a verification failure and stop; do not edit or run commands in another checkout.
|
|
10
|
+
|
|
9
11
|
## Mission
|
|
10
12
|
|
|
11
13
|
All slices are complete. Verify the integrated work, persist milestone completion, refresh project state, and write the final record future milestones will rely on.
|
|
@@ -689,7 +689,11 @@ function makeMockDeps(
|
|
|
689
689
|
resolveMilestoneFile: () => null,
|
|
690
690
|
reconcileMergeState: () => "clean",
|
|
691
691
|
preflightCleanRoot: () => ({ stashPushed: false, summary: "" }),
|
|
692
|
-
postflightPopStash: () => {
|
|
692
|
+
postflightPopStash: () => ({
|
|
693
|
+
restored: true,
|
|
694
|
+
needsManualRecovery: false,
|
|
695
|
+
message: "restored",
|
|
696
|
+
}),
|
|
693
697
|
getLedger: () => null,
|
|
694
698
|
getProjectTotals: () => ({ cost: 0 }),
|
|
695
699
|
formatCost: (c: number) => `$${c.toFixed(2)}`,
|
|
@@ -857,6 +861,145 @@ test("autoLoop exits on terminal complete state", async (t) => {
|
|
|
857
861
|
);
|
|
858
862
|
});
|
|
859
863
|
|
|
864
|
+
test("autoLoop stops before success notification when postflight stash restore needs recovery", async () => {
|
|
865
|
+
_resetPendingResolve();
|
|
866
|
+
|
|
867
|
+
const notifications: Array<{ msg: string; level: string }> = [];
|
|
868
|
+
const ctx = makeMockCtx();
|
|
869
|
+
ctx.ui.setStatus = () => {};
|
|
870
|
+
ctx.ui.notify = (msg: string, level: string) => {
|
|
871
|
+
notifications.push({ msg, level });
|
|
872
|
+
};
|
|
873
|
+
const pi = makeMockPi();
|
|
874
|
+
const s = makeLoopSession();
|
|
875
|
+
let stopReason = "";
|
|
876
|
+
|
|
877
|
+
const deps = makeMockDeps({
|
|
878
|
+
deriveState: async () => {
|
|
879
|
+
deps.callLog.push("deriveState");
|
|
880
|
+
return {
|
|
881
|
+
phase: "complete",
|
|
882
|
+
activeMilestone: { id: "M001", title: "Test", status: "complete" },
|
|
883
|
+
activeSlice: null,
|
|
884
|
+
activeTask: null,
|
|
885
|
+
registry: [{ id: "M001", status: "complete" }],
|
|
886
|
+
blockers: [],
|
|
887
|
+
} as any;
|
|
888
|
+
},
|
|
889
|
+
preflightCleanRoot: () => ({
|
|
890
|
+
stashPushed: true,
|
|
891
|
+
stashMarker: "gsd-preflight-stash:M001:test",
|
|
892
|
+
summary: "stashed",
|
|
893
|
+
}),
|
|
894
|
+
postflightPopStash: () => ({
|
|
895
|
+
restored: false,
|
|
896
|
+
needsManualRecovery: true,
|
|
897
|
+
message: "git stash pop stash@{0} failed after merge of milestone M001",
|
|
898
|
+
stashRef: "stash@{0}",
|
|
899
|
+
}),
|
|
900
|
+
sendDesktopNotification: () => {
|
|
901
|
+
deps.callLog.push("sendDesktopNotification");
|
|
902
|
+
},
|
|
903
|
+
logCmuxEvent: () => {
|
|
904
|
+
deps.callLog.push("logCmuxEvent");
|
|
905
|
+
},
|
|
906
|
+
stopAuto: async (_ctx, _pi, reason) => {
|
|
907
|
+
deps.callLog.push("stopAuto");
|
|
908
|
+
stopReason = reason ?? "";
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
await autoLoop(ctx, pi, s, deps);
|
|
913
|
+
|
|
914
|
+
assert.equal(stopReason, "Post-merge stash restore failed for milestone M001");
|
|
915
|
+
assert.ok(
|
|
916
|
+
notifications.some(
|
|
917
|
+
(n) => n.level === "error" && n.msg.includes("Post-merge stash restore failed for milestone M001"),
|
|
918
|
+
),
|
|
919
|
+
"failed postflight restore must be surfaced as an error",
|
|
920
|
+
);
|
|
921
|
+
assert.ok(
|
|
922
|
+
!deps.callLog.includes("sendDesktopNotification"),
|
|
923
|
+
"must not emit milestone success desktop notification after stash restore failure",
|
|
924
|
+
);
|
|
925
|
+
assert.ok(
|
|
926
|
+
!deps.callLog.includes("logCmuxEvent"),
|
|
927
|
+
"must not emit milestone success cmux event after stash restore failure",
|
|
928
|
+
);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("autoLoop marks transition merge complete before postflight recovery stop", async () => {
|
|
932
|
+
_resetPendingResolve();
|
|
933
|
+
|
|
934
|
+
const ctx = makeMockCtx();
|
|
935
|
+
ctx.ui.setStatus = () => {};
|
|
936
|
+
ctx.ui.notify = () => {};
|
|
937
|
+
const pi = makeMockPi();
|
|
938
|
+
const s = makeLoopSession();
|
|
939
|
+
let mergeCalls = 0;
|
|
940
|
+
let stopReason = "";
|
|
941
|
+
|
|
942
|
+
const deps = makeMockDeps({
|
|
943
|
+
deriveState: async () => {
|
|
944
|
+
deps.callLog.push("deriveState");
|
|
945
|
+
return {
|
|
946
|
+
phase: "executing",
|
|
947
|
+
activeMilestone: { id: "M002", title: "Next", status: "active" },
|
|
948
|
+
activeSlice: null,
|
|
949
|
+
activeTask: null,
|
|
950
|
+
registry: [
|
|
951
|
+
{ id: "M001", title: "Done", status: "complete" },
|
|
952
|
+
{ id: "M002", title: "Next", status: "active" },
|
|
953
|
+
],
|
|
954
|
+
blockers: [],
|
|
955
|
+
} as any;
|
|
956
|
+
},
|
|
957
|
+
preflightCleanRoot: () => ({
|
|
958
|
+
stashPushed: true,
|
|
959
|
+
stashMarker: "gsd-preflight-stash:M001:test",
|
|
960
|
+
summary: "stashed",
|
|
961
|
+
}),
|
|
962
|
+
postflightPopStash: () => ({
|
|
963
|
+
restored: false,
|
|
964
|
+
needsManualRecovery: true,
|
|
965
|
+
message: "git stash pop stash@{0} failed after merge of milestone M001",
|
|
966
|
+
stashRef: "stash@{0}",
|
|
967
|
+
}),
|
|
968
|
+
resolver: {
|
|
969
|
+
get workPath() {
|
|
970
|
+
return "/tmp/project";
|
|
971
|
+
},
|
|
972
|
+
get projectRoot() {
|
|
973
|
+
return "/tmp/project";
|
|
974
|
+
},
|
|
975
|
+
get lockPath() {
|
|
976
|
+
return "/tmp/project";
|
|
977
|
+
},
|
|
978
|
+
enterMilestone: () => {
|
|
979
|
+
assert.fail("must not enter the next milestone after postflight recovery fails");
|
|
980
|
+
},
|
|
981
|
+
exitMilestone: () => {},
|
|
982
|
+
mergeAndExit: () => {
|
|
983
|
+
mergeCalls += 1;
|
|
984
|
+
},
|
|
985
|
+
mergeAndEnterNext: () => {},
|
|
986
|
+
} as any,
|
|
987
|
+
stopAuto: async (_ctx, _pi, reason) => {
|
|
988
|
+
deps.callLog.push("stopAuto");
|
|
989
|
+
stopReason = reason ?? "";
|
|
990
|
+
if (!s.milestoneMergedInPhases) {
|
|
991
|
+
deps.resolver.mergeAndExit("M001", ctx.ui);
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
await autoLoop(ctx, pi, s, deps);
|
|
997
|
+
|
|
998
|
+
assert.equal(stopReason, "Post-merge stash restore failed for milestone M001");
|
|
999
|
+
assert.equal(s.milestoneMergedInPhases, true);
|
|
1000
|
+
assert.equal(mergeCalls, 1, "postflight recovery stop must not re-run an already completed transition merge");
|
|
1001
|
+
});
|
|
1002
|
+
|
|
860
1003
|
test("autoLoop pauses when provider readiness cancels before dispatch", async () => {
|
|
861
1004
|
_resetPendingResolve();
|
|
862
1005
|
|