gsd-pi 2.61.0-dev.7aed0bf → 2.62.0-dev.a987556
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/ask-user-questions.js +47 -3
- package/dist/resources/extensions/gsd/auto-start.js +11 -6
- package/dist/resources/extensions/gsd/auto-timers.js +8 -2
- package/dist/resources/extensions/gsd/auto-worktree.js +19 -0
- package/dist/resources/extensions/gsd/auto.js +24 -0
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +4 -0
- package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +11 -1
- package/dist/resources/extensions/gsd/commands-handlers.js +18 -7
- package/dist/resources/extensions/gsd/db-writer.js +64 -28
- package/dist/resources/extensions/gsd/preferences-models.js +74 -0
- package/dist/resources/extensions/gsd/preferences-skills.js +6 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
- package/dist/resources/extensions/gsd/skill-catalog.js +6 -4
- package/dist/resources/extensions/gsd/skill-discovery.js +24 -6
- package/dist/resources/extensions/gsd/skill-health.js +7 -3
- package/dist/resources/extensions/gsd/skill-telemetry.js +5 -2
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +16 -16
- 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/required-server-files.json +1 -1
- 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 +16 -16
- 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/dist/web/standalone/server.js +1 -1
- package/package.json +1 -1
- package/packages/mcp-server/src/cli.ts +1 -1
- package/packages/mcp-server/src/index.ts +15 -1
- package/packages/mcp-server/src/readers/captures.ts +119 -0
- package/packages/mcp-server/src/readers/doctor-lite.ts +225 -0
- package/packages/mcp-server/src/readers/index.ts +16 -0
- package/packages/mcp-server/src/readers/knowledge.ts +111 -0
- package/packages/mcp-server/src/readers/metrics.ts +118 -0
- package/packages/mcp-server/src/readers/paths.ts +217 -0
- package/packages/mcp-server/src/readers/readers.test.ts +509 -0
- package/packages/mcp-server/src/readers/roadmap.ts +263 -0
- package/packages/mcp-server/src/readers/state.ts +223 -0
- package/packages/mcp-server/src/server.ts +134 -3
- package/packages/pi-ai/dist/utils/repair-tool-json.d.ts +26 -6
- package/packages/pi-ai/dist/utils/repair-tool-json.d.ts.map +1 -1
- package/packages/pi-ai/dist/utils/repair-tool-json.js +67 -9
- package/packages/pi-ai/dist/utils/repair-tool-json.js.map +1 -1
- package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js +73 -1
- package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js.map +1 -1
- package/packages/pi-ai/src/utils/repair-tool-json.ts +74 -10
- package/packages/pi-ai/src/utils/tests/repair-tool-json.test.ts +94 -1
- package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.js +16 -0
- package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.js.map +1 -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 -0
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/retry-handler.d.ts +3 -0
- package/packages/pi-coding-agent/dist/core/retry-handler.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/retry-handler.js +48 -16
- package/packages/pi-coding-agent/dist/core/retry-handler.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/retry-handler.test.js +20 -3
- package/packages/pi-coding-agent/dist/core/retry-handler.test.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/agent-session-model-switch.test.ts +21 -0
- package/packages/pi-coding-agent/src/core/agent-session.ts +4 -0
- package/packages/pi-coding-agent/src/core/retry-handler.test.ts +30 -3
- package/packages/pi-coding-agent/src/core/retry-handler.ts +49 -16
- package/pkg/package.json +1 -1
- package/src/resources/extensions/ask-user-questions.ts +60 -4
- package/src/resources/extensions/gsd/auto-start.ts +11 -6
- package/src/resources/extensions/gsd/auto-timers.ts +8 -2
- package/src/resources/extensions/gsd/auto-worktree.ts +18 -0
- package/src/resources/extensions/gsd/auto.ts +25 -0
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +4 -0
- package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +13 -1
- package/src/resources/extensions/gsd/commands-handlers.ts +20 -7
- package/src/resources/extensions/gsd/db-writer.ts +67 -30
- package/src/resources/extensions/gsd/preferences-models.ts +78 -0
- package/src/resources/extensions/gsd/preferences-skills.ts +6 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
- package/src/resources/extensions/gsd/skill-catalog.ts +6 -3
- package/src/resources/extensions/gsd/skill-discovery.ts +23 -6
- package/src/resources/extensions/gsd/skill-health.ts +7 -3
- package/src/resources/extensions/gsd/skill-telemetry.ts +5 -2
- package/src/resources/extensions/gsd/tests/ask-user-questions-dedup.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +22 -2
- package/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts +107 -0
- package/src/resources/extensions/gsd/tests/claude-skill-dirs.test.ts +51 -0
- package/src/resources/extensions/gsd/tests/db-writer.test.ts +41 -0
- package/src/resources/extensions/gsd/tests/model-isolation.test.ts +75 -1
- package/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts +108 -0
- package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +17 -4
- package/src/resources/extensions/gsd/tests/worktree-db-respawn-truncation.test.ts +81 -2
- package/src/resources/extensions/shared/tests/ask-user-freetext.test.ts +6 -1
- /package/dist/web/standalone/.next/static/{b7FOoMHaUb3FPoLNbxar4 → AsCFY1___4XTI6GkTQkFb}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{b7FOoMHaUb3FPoLNbxar4 → AsCFY1___4XTI6GkTQkFb}/_ssgManifest.js +0 -0
|
@@ -37,6 +37,8 @@ export class RetryHandler {
|
|
|
37
37
|
private _retryAttempt = 0;
|
|
38
38
|
private _retryPromise: Promise<void> | undefined = undefined;
|
|
39
39
|
private _retryResolve: (() => void) | undefined = undefined;
|
|
40
|
+
private _retryGeneration = 0;
|
|
41
|
+
private _continueTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
40
42
|
|
|
41
43
|
constructor(private readonly _deps: RetryHandlerDeps) {}
|
|
42
44
|
|
|
@@ -134,6 +136,7 @@ export class RetryHandler {
|
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
// Try credential fallback before counting against retry budget.
|
|
139
|
+
const retryGeneration = this._retryGeneration;
|
|
137
140
|
if (this._deps.getModel() && message.errorMessage) {
|
|
138
141
|
const errorType = this._classifyErrorType(message.errorMessage);
|
|
139
142
|
const isCredentialError = errorType === "rate_limit" || errorType === "quota_exhausted";
|
|
@@ -157,9 +160,7 @@ export class RetryHandler {
|
|
|
157
160
|
});
|
|
158
161
|
|
|
159
162
|
// Retry immediately with the next credential - don't increment _retryAttempt
|
|
160
|
-
|
|
161
|
-
this._deps.agent.continue().catch(() => {});
|
|
162
|
-
}, 0);
|
|
163
|
+
this._scheduleContinue(retryGeneration);
|
|
163
164
|
|
|
164
165
|
return true;
|
|
165
166
|
}
|
|
@@ -193,9 +194,7 @@ export class RetryHandler {
|
|
|
193
194
|
});
|
|
194
195
|
|
|
195
196
|
// Retry immediately with fallback provider - don't increment _retryAttempt
|
|
196
|
-
|
|
197
|
-
this._deps.agent.continue().catch(() => {});
|
|
198
|
-
}, 0);
|
|
197
|
+
this._scheduleContinue(retryGeneration);
|
|
199
198
|
|
|
200
199
|
return true;
|
|
201
200
|
}
|
|
@@ -203,7 +202,7 @@ export class RetryHandler {
|
|
|
203
202
|
// No fallback available either
|
|
204
203
|
if (errorType === "quota_exhausted") {
|
|
205
204
|
// Try long-context model downgrade ([1m] → base) before giving up
|
|
206
|
-
const downgraded = this._tryLongContextDowngrade(message);
|
|
205
|
+
const downgraded = this._tryLongContextDowngrade(message, retryGeneration);
|
|
207
206
|
if (downgraded) return true;
|
|
208
207
|
|
|
209
208
|
this._deps.emit({
|
|
@@ -274,7 +273,12 @@ export class RetryHandler {
|
|
|
274
273
|
try {
|
|
275
274
|
await sleep(delayMs, this._retryAbortController.signal);
|
|
276
275
|
} catch {
|
|
277
|
-
// Aborted during sleep
|
|
276
|
+
// Aborted during sleep. If the retry generation already advanced, this
|
|
277
|
+
// cancellation was handled externally (e.g. explicit model switch).
|
|
278
|
+
if (retryGeneration !== this._retryGeneration) {
|
|
279
|
+
this._retryAbortController = undefined;
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
278
282
|
const attempt = this._retryAttempt;
|
|
279
283
|
this._retryAttempt = 0;
|
|
280
284
|
this._retryAbortController = undefined;
|
|
@@ -290,16 +294,36 @@ export class RetryHandler {
|
|
|
290
294
|
this._retryAbortController = undefined;
|
|
291
295
|
|
|
292
296
|
// Retry via continue() - use setTimeout to break out of event handler chain
|
|
293
|
-
|
|
294
|
-
this._deps.agent.continue().catch(() => {});
|
|
295
|
-
}, 0);
|
|
297
|
+
this._scheduleContinue(retryGeneration);
|
|
296
298
|
|
|
297
299
|
return true;
|
|
298
300
|
}
|
|
299
301
|
|
|
300
302
|
/** Cancel in-progress retry */
|
|
301
303
|
abortRetry(): void {
|
|
302
|
-
|
|
304
|
+
const hadRetry =
|
|
305
|
+
this._retryPromise !== undefined
|
|
306
|
+
|| this._retryAbortController !== undefined
|
|
307
|
+
|| this._continueTimeout !== undefined;
|
|
308
|
+
if (!hadRetry) return;
|
|
309
|
+
|
|
310
|
+
const attempt = this._retryAttempt > 0 ? this._retryAttempt : 1;
|
|
311
|
+
this._retryGeneration++;
|
|
312
|
+
if (this._continueTimeout) {
|
|
313
|
+
clearTimeout(this._continueTimeout);
|
|
314
|
+
this._continueTimeout = undefined;
|
|
315
|
+
}
|
|
316
|
+
if (this._retryAbortController) {
|
|
317
|
+
this._retryAbortController.abort();
|
|
318
|
+
this._retryAbortController = undefined;
|
|
319
|
+
}
|
|
320
|
+
this._retryAttempt = 0;
|
|
321
|
+
this._deps.emit({
|
|
322
|
+
type: "auto_retry_end",
|
|
323
|
+
success: false,
|
|
324
|
+
attempt,
|
|
325
|
+
finalError: "Retry cancelled",
|
|
326
|
+
});
|
|
303
327
|
this._resolveRetry();
|
|
304
328
|
}
|
|
305
329
|
|
|
@@ -330,6 +354,17 @@ export class RetryHandler {
|
|
|
330
354
|
}
|
|
331
355
|
}
|
|
332
356
|
|
|
357
|
+
private _scheduleContinue(retryGeneration: number): void {
|
|
358
|
+
if (this._continueTimeout) {
|
|
359
|
+
clearTimeout(this._continueTimeout);
|
|
360
|
+
}
|
|
361
|
+
this._continueTimeout = setTimeout(() => {
|
|
362
|
+
this._continueTimeout = undefined;
|
|
363
|
+
if (retryGeneration !== this._retryGeneration) return;
|
|
364
|
+
this._deps.agent.continue().catch(() => {});
|
|
365
|
+
}, 0);
|
|
366
|
+
}
|
|
367
|
+
|
|
333
368
|
private _findLastAssistantInMessages(
|
|
334
369
|
messages: Array<{ role: string } & Record<string, any>>,
|
|
335
370
|
): AssistantMessage | undefined {
|
|
@@ -361,7 +396,7 @@ export class RetryHandler {
|
|
|
361
396
|
* base model (claude-opus-4-6) when the account lacks the long-context billing
|
|
362
397
|
* entitlement. Returns true if the downgrade was initiated.
|
|
363
398
|
*/
|
|
364
|
-
private _tryLongContextDowngrade(message: AssistantMessage): boolean {
|
|
399
|
+
private _tryLongContextDowngrade(message: AssistantMessage, retryGeneration: number): boolean {
|
|
365
400
|
const currentModel = this._deps.getModel();
|
|
366
401
|
if (!currentModel) return false;
|
|
367
402
|
|
|
@@ -393,9 +428,7 @@ export class RetryHandler {
|
|
|
393
428
|
errorMessage: `${message.errorMessage} (long context downgrade)`,
|
|
394
429
|
});
|
|
395
430
|
|
|
396
|
-
|
|
397
|
-
this._deps.agent.continue().catch(() => {});
|
|
398
|
-
}, 0);
|
|
431
|
+
this._scheduleContinue(retryGeneration);
|
|
399
432
|
|
|
400
433
|
return true;
|
|
401
434
|
}
|
package/pkg/package.json
CHANGED
|
@@ -72,6 +72,41 @@ const AskUserQuestionsParams = Type.Object({
|
|
|
72
72
|
}),
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
// ─── Per-turn deduplication ──────────────────────────────────────────────────
|
|
76
|
+
// Prevents duplicate question dispatches (especially to remote channels like
|
|
77
|
+
// Discord) when the LLM calls ask_user_questions multiple times with the same
|
|
78
|
+
// questions in a single turn. Keyed by full canonicalized payload (id, header,
|
|
79
|
+
// question, options, allowMultiple) — not just IDs — so that calls with the
|
|
80
|
+
// same IDs but different text/options are treated as distinct.
|
|
81
|
+
|
|
82
|
+
import { createHash } from "node:crypto";
|
|
83
|
+
|
|
84
|
+
interface CachedResult {
|
|
85
|
+
content: { type: "text"; text: string }[];
|
|
86
|
+
details: AskUserQuestionsDetails;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const turnCache = new Map<string, CachedResult>();
|
|
90
|
+
|
|
91
|
+
/** @internal Exported for testing only. */
|
|
92
|
+
export function questionSignature(questions: Question[]): string {
|
|
93
|
+
const canonical = questions
|
|
94
|
+
.map((q) => ({
|
|
95
|
+
id: q.id,
|
|
96
|
+
header: q.header,
|
|
97
|
+
question: q.question,
|
|
98
|
+
options: (q.options || []).map((o) => ({ label: o.label, description: o.description })),
|
|
99
|
+
allowMultiple: !!q.allowMultiple,
|
|
100
|
+
}))
|
|
101
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
102
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Reset the dedup cache. Called on session boundaries. */
|
|
106
|
+
export function resetAskUserQuestionsCache(): void {
|
|
107
|
+
turnCache.clear();
|
|
108
|
+
}
|
|
109
|
+
|
|
75
110
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
76
111
|
|
|
77
112
|
const OTHER_OPTION_LABEL = "None of the above";
|
|
@@ -121,6 +156,16 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|
|
121
156
|
parameters: AskUserQuestionsParams,
|
|
122
157
|
|
|
123
158
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
159
|
+
// ── Per-turn dedup: return cached result for identical question sets ──
|
|
160
|
+
const sig = questionSignature(params.questions);
|
|
161
|
+
const cached = turnCache.get(sig);
|
|
162
|
+
if (cached) {
|
|
163
|
+
return {
|
|
164
|
+
content: [{ type: "text" as const, text: cached.content[0].text + "\n(Returned cached answer — this question set was already asked this turn.)" }],
|
|
165
|
+
details: cached.details,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
124
169
|
// Validation
|
|
125
170
|
if (params.questions.length === 0 || params.questions.length > 3) {
|
|
126
171
|
return errorResult("Error: questions must contain 1-3 items", params.questions);
|
|
@@ -140,7 +185,14 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|
|
140
185
|
// this is a no-op when the user has not set up Slack/Discord/Telegram.
|
|
141
186
|
const { tryRemoteQuestions } = await import("./remote-questions/manager.js");
|
|
142
187
|
const remoteResult = await tryRemoteQuestions(params.questions, signal);
|
|
143
|
-
if (remoteResult)
|
|
188
|
+
if (remoteResult) {
|
|
189
|
+
// Cache successful remote results to prevent duplicate Discord dispatches
|
|
190
|
+
const remoteDetails = remoteResult.details as Record<string, unknown> | undefined;
|
|
191
|
+
if (remoteDetails && !remoteDetails.timed_out && !remoteDetails.error) {
|
|
192
|
+
turnCache.set(sig, remoteResult as unknown as CachedResult);
|
|
193
|
+
}
|
|
194
|
+
return { ...remoteResult, details: remoteResult.details as unknown };
|
|
195
|
+
}
|
|
144
196
|
|
|
145
197
|
if (!ctx.hasUI) {
|
|
146
198
|
return errorResult("Error: UI not available (non-interactive mode)", params.questions);
|
|
@@ -197,7 +249,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|
|
197
249
|
]),
|
|
198
250
|
),
|
|
199
251
|
};
|
|
200
|
-
|
|
252
|
+
const fallbackResult = {
|
|
201
253
|
content: [{ type: "text" as const, text: JSON.stringify({ answers }) }],
|
|
202
254
|
details: {
|
|
203
255
|
questions: params.questions,
|
|
@@ -205,6 +257,8 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|
|
205
257
|
cancelled: false,
|
|
206
258
|
} satisfies LocalResultDetails,
|
|
207
259
|
};
|
|
260
|
+
turnCache.set(sig, fallbackResult);
|
|
261
|
+
return fallbackResult;
|
|
208
262
|
}
|
|
209
263
|
|
|
210
264
|
// Check if cancelled (empty answers = user exited)
|
|
@@ -216,10 +270,12 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|
|
216
270
|
};
|
|
217
271
|
}
|
|
218
272
|
|
|
219
|
-
|
|
220
|
-
content: [{ type: "text", text: formatForLLM(result) }],
|
|
273
|
+
const successResult = {
|
|
274
|
+
content: [{ type: "text" as const, text: formatForLLM(result) }],
|
|
221
275
|
details: { questions: params.questions, response: result, cancelled: false } satisfies LocalResultDetails,
|
|
222
276
|
};
|
|
277
|
+
turnCache.set(sig, successResult);
|
|
278
|
+
return successResult;
|
|
223
279
|
},
|
|
224
280
|
|
|
225
281
|
// ─── Rendering ────────────────────────────────────────────────────────
|
|
@@ -80,6 +80,7 @@ import { join } from "node:path";
|
|
|
80
80
|
import { sep as pathSep } from "node:path";
|
|
81
81
|
|
|
82
82
|
import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
|
|
83
|
+
import { resolveDefaultSessionModel } from "./preferences-models.js";
|
|
83
84
|
import type { WorktreeResolver } from "./worktree-resolver.js";
|
|
84
85
|
|
|
85
86
|
export interface BootstrapDeps {
|
|
@@ -155,12 +156,16 @@ export async function bootstrapAutoSession(
|
|
|
155
156
|
|
|
156
157
|
// Capture the user's session model before guided-flow dispatch can apply a
|
|
157
158
|
// phase-specific planning model for a discuss turn (#2829).
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
//
|
|
160
|
+
// GSD PREFERENCES.md takes priority over the session model from settings.json
|
|
161
|
+
// (#3517). The session model (ctx.model) comes from findInitialModel() which
|
|
162
|
+
// reads defaultProvider/defaultModel from ~/.gsd/agent/settings.json. When
|
|
163
|
+
// the user has explicit model preferences in PREFERENCES.md, those should win.
|
|
164
|
+
const preferredModel = resolveDefaultSessionModel(ctx.model?.provider);
|
|
165
|
+
const startModelSnapshot = preferredModel
|
|
166
|
+
?? (ctx.model
|
|
167
|
+
? { provider: ctx.model.provider, id: ctx.model.id }
|
|
168
|
+
: null);
|
|
164
169
|
|
|
165
170
|
try {
|
|
166
171
|
// Validate GSD_PROJECT_ID early so the user gets immediate feedback
|
|
@@ -122,6 +122,10 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
|
|
|
122
122
|
phase: "wrapup-warning-sent",
|
|
123
123
|
wrapupWarningSent: true,
|
|
124
124
|
});
|
|
125
|
+
// Only trigger a new turn if no tools are currently in flight.
|
|
126
|
+
// Triggering during active tool calls causes tool results to be skipped
|
|
127
|
+
// with "Skipped due to queued user message", leading to provider errors (#3512).
|
|
128
|
+
const softTrigger = getInFlightToolCount() === 0;
|
|
125
129
|
pi.sendMessage(
|
|
126
130
|
{
|
|
127
131
|
customType: "gsd-auto-wrapup",
|
|
@@ -136,7 +140,7 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
|
|
|
136
140
|
"4. leave precise resume notes if anything remains unfinished",
|
|
137
141
|
].join("\n"),
|
|
138
142
|
},
|
|
139
|
-
{ triggerTurn:
|
|
143
|
+
{ triggerTurn: softTrigger },
|
|
140
144
|
);
|
|
141
145
|
}, softTimeoutMs);
|
|
142
146
|
|
|
@@ -293,6 +297,8 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
|
|
|
293
297
|
);
|
|
294
298
|
}
|
|
295
299
|
|
|
300
|
+
// Only trigger a new turn if no tools are currently in flight (#3512).
|
|
301
|
+
const contextTrigger = getInFlightToolCount() === 0;
|
|
296
302
|
pi.sendMessage(
|
|
297
303
|
{
|
|
298
304
|
customType: "gsd-auto-wrapup",
|
|
@@ -308,7 +314,7 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
|
|
|
308
314
|
"Do NOT start new sub-tasks or investigations.",
|
|
309
315
|
].join("\n"),
|
|
310
316
|
},
|
|
311
|
-
{ triggerTurn:
|
|
317
|
+
{ triggerTurn: contextTrigger },
|
|
312
318
|
);
|
|
313
319
|
|
|
314
320
|
if (s.continueHereHandle) {
|
|
@@ -314,10 +314,28 @@ export function syncProjectRootToWorktree(
|
|
|
314
314
|
// openDatabase re-creates it, causing "no such table" failures (#2815).
|
|
315
315
|
try {
|
|
316
316
|
const wtDb = join(wtGsd, "gsd.db");
|
|
317
|
+
let deleteSidecars = false;
|
|
317
318
|
if (existsSync(wtDb)) {
|
|
318
319
|
const size = statSync(wtDb).size;
|
|
319
320
|
if (size === 0) {
|
|
320
321
|
unlinkSync(wtDb);
|
|
322
|
+
deleteSidecars = true;
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
// Main DB already missing — sidecars are orphaned from a previous
|
|
326
|
+
// partial cleanup and must still be removed.
|
|
327
|
+
deleteSidecars = true;
|
|
328
|
+
}
|
|
329
|
+
// Always clean up WAL/SHM sidecar files when the main DB was deleted
|
|
330
|
+
// or is already missing. Orphaned WAL/SHM files cause SQLite WAL
|
|
331
|
+
// recovery on next open, which triggers a CPU spin on Node 24's
|
|
332
|
+
// node:sqlite DatabaseSync implementation (#2478).
|
|
333
|
+
if (deleteSidecars) {
|
|
334
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
335
|
+
const f = wtDb + suffix;
|
|
336
|
+
if (existsSync(f)) {
|
|
337
|
+
unlinkSync(f);
|
|
338
|
+
}
|
|
321
339
|
}
|
|
322
340
|
}
|
|
323
341
|
} catch (err) {
|
|
@@ -606,6 +606,18 @@ export async function stopAuto(
|
|
|
606
606
|
debugLog("stop-cleanup-locks", { error: e instanceof Error ? e.message : String(e) });
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
// ── Step 1b: Flush queued follow-up messages (#3512) ──
|
|
610
|
+
// Late async notifications (async_job_result, gsd-auto-wrapup) can trigger
|
|
611
|
+
// extra LLM turns after stop. Flush them the same way run-unit.ts does.
|
|
612
|
+
try {
|
|
613
|
+
const cmdCtxAny = s.cmdCtx as Record<string, unknown> | null;
|
|
614
|
+
if (typeof cmdCtxAny?.clearQueue === "function") {
|
|
615
|
+
(cmdCtxAny.clearQueue as () => unknown)();
|
|
616
|
+
}
|
|
617
|
+
} catch (e) {
|
|
618
|
+
debugLog("stop-cleanup-queue", { error: e instanceof Error ? e.message : String(e) });
|
|
619
|
+
}
|
|
620
|
+
|
|
609
621
|
// ── Step 2: Skill state ──
|
|
610
622
|
try {
|
|
611
623
|
clearSkillSnapshot();
|
|
@@ -834,6 +846,19 @@ export async function pauseAuto(
|
|
|
834
846
|
): Promise<void> {
|
|
835
847
|
if (!s.active) return;
|
|
836
848
|
clearUnitTimeout();
|
|
849
|
+
|
|
850
|
+
// Flush queued follow-up messages (#3512).
|
|
851
|
+
// Late async notifications (async_job_result, gsd-auto-wrapup) can trigger
|
|
852
|
+
// extra LLM turns after pause. Flush them the same way run-unit.ts does.
|
|
853
|
+
try {
|
|
854
|
+
const cmdCtxAny = s.cmdCtx as Record<string, unknown> | null;
|
|
855
|
+
if (typeof cmdCtxAny?.clearQueue === "function") {
|
|
856
|
+
(cmdCtxAny.clearQueue as () => unknown)();
|
|
857
|
+
}
|
|
858
|
+
} catch (e) {
|
|
859
|
+
debugLog("pause-cleanup-queue", { error: e instanceof Error ? e.message : String(e) });
|
|
860
|
+
}
|
|
861
|
+
|
|
837
862
|
// Unblock any pending unit promise so the auto-loop is not orphaned.
|
|
838
863
|
// Pass errorContext so runUnitPhase can distinguish user-initiated pause
|
|
839
864
|
// from provider-error pause and avoid hard-stopping (#2762).
|
|
@@ -17,6 +17,7 @@ import { getAutoDashboardData, isAutoActive, isAutoPaused, markToolEnd, markTool
|
|
|
17
17
|
import { isParallelActive, shutdownParallel } from "../parallel-orchestrator.js";
|
|
18
18
|
import { checkToolCallLoop, resetToolCallLoopGuard } from "./tool-call-loop-guard.js";
|
|
19
19
|
import { saveActivityLog } from "../activity-log.js";
|
|
20
|
+
import { resetAskUserQuestionsCache } from "../../ask-user-questions.js";
|
|
20
21
|
|
|
21
22
|
// Skip the welcome screen on the very first session_start — cli.ts already
|
|
22
23
|
// printed it before the TUI launched. Only re-print on /clear (subsequent sessions).
|
|
@@ -31,6 +32,7 @@ export function registerHooks(pi: ExtensionAPI): void {
|
|
|
31
32
|
pi.on("session_start", async (_event, ctx) => {
|
|
32
33
|
resetWriteGateState();
|
|
33
34
|
resetToolCallLoopGuard();
|
|
35
|
+
resetAskUserQuestionsCache();
|
|
34
36
|
await syncServiceTierStatus(ctx);
|
|
35
37
|
|
|
36
38
|
// Apply show_token_cost preference (#1515)
|
|
@@ -67,6 +69,7 @@ export function registerHooks(pi: ExtensionAPI): void {
|
|
|
67
69
|
pi.on("session_switch", async (_event, ctx) => {
|
|
68
70
|
resetWriteGateState();
|
|
69
71
|
resetToolCallLoopGuard();
|
|
72
|
+
resetAskUserQuestionsCache();
|
|
70
73
|
clearDiscussionFlowState();
|
|
71
74
|
await syncServiceTierStatus(ctx);
|
|
72
75
|
loadToolApiKeys();
|
|
@@ -78,6 +81,7 @@ export function registerHooks(pi: ExtensionAPI): void {
|
|
|
78
81
|
|
|
79
82
|
pi.on("agent_end", async (event, ctx: ExtensionContext) => {
|
|
80
83
|
resetToolCallLoopGuard();
|
|
84
|
+
resetAskUserQuestionsCache();
|
|
81
85
|
await handleAgentEnd(pi, event, ctx);
|
|
82
86
|
});
|
|
83
87
|
|
|
@@ -16,8 +16,13 @@ import { createHash } from "node:crypto";
|
|
|
16
16
|
|
|
17
17
|
const MAX_CONSECUTIVE_IDENTICAL_CALLS = 4;
|
|
18
18
|
|
|
19
|
+
/** Interactive/user-facing tools where even 1 duplicate is confusing. */
|
|
20
|
+
const STRICT_LOOP_TOOLS = new Set(["ask_user_questions"]);
|
|
21
|
+
const MAX_CONSECUTIVE_STRICT = 1;
|
|
22
|
+
|
|
19
23
|
let consecutiveCount = 0;
|
|
20
24
|
let lastSignature = "";
|
|
25
|
+
let lastToolName = "";
|
|
21
26
|
let enabled = true;
|
|
22
27
|
|
|
23
28
|
/** Hash tool name + args into a compact signature for comparison. */
|
|
@@ -55,9 +60,14 @@ export function checkToolCallLoop(
|
|
|
55
60
|
} else {
|
|
56
61
|
consecutiveCount = 1;
|
|
57
62
|
lastSignature = sig;
|
|
63
|
+
lastToolName = toolName;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
|
|
66
|
+
const threshold = STRICT_LOOP_TOOLS.has(toolName)
|
|
67
|
+
? MAX_CONSECUTIVE_STRICT
|
|
68
|
+
: MAX_CONSECUTIVE_IDENTICAL_CALLS;
|
|
69
|
+
|
|
70
|
+
if (consecutiveCount > threshold) {
|
|
61
71
|
return {
|
|
62
72
|
block: true,
|
|
63
73
|
reason:
|
|
@@ -75,6 +85,7 @@ export function checkToolCallLoop(
|
|
|
75
85
|
export function resetToolCallLoopGuard(): void {
|
|
76
86
|
consecutiveCount = 0;
|
|
77
87
|
lastSignature = "";
|
|
88
|
+
lastToolName = "";
|
|
78
89
|
enabled = true;
|
|
79
90
|
}
|
|
80
91
|
|
|
@@ -83,6 +94,7 @@ export function disableToolCallLoopGuard(): void {
|
|
|
83
94
|
enabled = false;
|
|
84
95
|
consecutiveCount = 0;
|
|
85
96
|
lastSignature = "";
|
|
97
|
+
lastToolName = "";
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
/** Get current consecutive count for diagnostics. */
|
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
selectDoctorScope,
|
|
21
21
|
filterDoctorIssues,
|
|
22
22
|
} from "./doctor.js";
|
|
23
|
-
import { isAutoActive } from "./auto.js";
|
|
23
|
+
import { isAutoActive, checkRemoteAutoSession } from "./auto.js";
|
|
24
|
+
import { getAutoWorktreePath } from "./auto-worktree.js";
|
|
24
25
|
import { projectRoot } from "./commands/context.js";
|
|
25
26
|
import { loadPrompt } from "./prompt-loader.js";
|
|
26
27
|
|
|
@@ -222,7 +223,19 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext,
|
|
|
222
223
|
const sid = state.activeSlice?.id ?? "none";
|
|
223
224
|
const tid = state.activeTask?.id ?? "none";
|
|
224
225
|
const appliedAt = `${mid}/${sid}/${tid}`;
|
|
225
|
-
|
|
226
|
+
|
|
227
|
+
// Resolve the correct target path: only route to a worktree when auto-mode
|
|
228
|
+
// is actively running there (in-process or remote). A worktree directory may
|
|
229
|
+
// exist from a previous session without being the active runtime path —
|
|
230
|
+
// writing there without a live session would silently drop the override.
|
|
231
|
+
const autoRunning = isAutoActive() || checkRemoteAutoSession(basePath).running;
|
|
232
|
+
const wtPath = autoRunning && mid !== "none"
|
|
233
|
+
? getAutoWorktreePath(basePath, mid)
|
|
234
|
+
: null;
|
|
235
|
+
const targetPath = wtPath ?? basePath;
|
|
236
|
+
await appendOverride(targetPath, change, appliedAt);
|
|
237
|
+
|
|
238
|
+
const overrideLoc = wtPath ? "worktree `.gsd/OVERRIDES.md`" : "`.gsd/OVERRIDES.md`";
|
|
226
239
|
|
|
227
240
|
if (isAutoActive()) {
|
|
228
241
|
pi.sendMessage({
|
|
@@ -232,14 +245,14 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext,
|
|
|
232
245
|
"",
|
|
233
246
|
`**Override:** ${change}`,
|
|
234
247
|
"",
|
|
235
|
-
|
|
248
|
+
`This override has been saved to ${overrideLoc} and will be injected into all future task prompts.`,
|
|
236
249
|
"A document rewrite unit will run before the next task to propagate this change across all active plan documents.",
|
|
237
250
|
"",
|
|
238
251
|
"If you are mid-task, finish your current work respecting this override. The next dispatched unit will be a document rewrite.",
|
|
239
252
|
].join("\n"),
|
|
240
253
|
display: false,
|
|
241
254
|
}, { triggerTurn: true });
|
|
242
|
-
ctx.ui.notify(`Override registered: "${change}". Will be applied before next task dispatch.`, "info");
|
|
255
|
+
ctx.ui.notify(`Override registered (${overrideLoc}): "${change}". Will be applied before next task dispatch.`, "info");
|
|
243
256
|
} else {
|
|
244
257
|
pi.sendMessage({
|
|
245
258
|
customType: "gsd-hard-steer",
|
|
@@ -248,13 +261,13 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext,
|
|
|
248
261
|
"",
|
|
249
262
|
`**Override:** ${change}`,
|
|
250
263
|
"",
|
|
251
|
-
|
|
252
|
-
|
|
264
|
+
`This override has been saved to ${overrideLoc}.`,
|
|
265
|
+
`Before continuing, read ${overrideLoc} and update the current plan documents to reflect this change.`,
|
|
253
266
|
"Focus on: active slice plan, incomplete task plans, and DECISIONS.md.",
|
|
254
267
|
].join("\n"),
|
|
255
268
|
display: false,
|
|
256
269
|
}, { triggerTurn: true });
|
|
257
|
-
ctx.ui.notify(`Override registered: "${change}". Update plan documents to reflect this change.`, "info");
|
|
270
|
+
ctx.ui.notify(`Override registered (${overrideLoc}): "${change}". Update plan documents to reflect this change.`, "info");
|
|
258
271
|
}
|
|
259
272
|
}
|
|
260
273
|
|
|
@@ -272,6 +272,10 @@ export interface SaveRequirementFields {
|
|
|
272
272
|
/**
|
|
273
273
|
* Save a new requirement to DB and regenerate REQUIREMENTS.md.
|
|
274
274
|
* Auto-assigns the next ID via nextRequirementId().
|
|
275
|
+
*
|
|
276
|
+
* The ID computation and insert are wrapped in a single transaction
|
|
277
|
+
* to prevent parallel race conditions (same pattern as saveDecisionToDb).
|
|
278
|
+
*
|
|
275
279
|
* Returns the assigned ID.
|
|
276
280
|
*/
|
|
277
281
|
export async function saveRequirementToDb(
|
|
@@ -281,24 +285,37 @@ export async function saveRequirementToDb(
|
|
|
281
285
|
try {
|
|
282
286
|
const db = await import('./gsd-db.js');
|
|
283
287
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
288
|
+
// Atomic ID assignment + insert inside a transaction.
|
|
289
|
+
const id = db.transaction(() => {
|
|
290
|
+
const adapter = db._getAdapter();
|
|
291
|
+
if (!adapter) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
|
|
292
|
+
|
|
293
|
+
const row = adapter
|
|
294
|
+
.prepare('SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM requirements')
|
|
295
|
+
.get();
|
|
296
|
+
const maxNum = row ? (row['max_num'] as number | null) : null;
|
|
297
|
+
const nextId = (maxNum == null || isNaN(maxNum))
|
|
298
|
+
? 'R001'
|
|
299
|
+
: `R${String(maxNum + 1).padStart(3, '0')}`;
|
|
300
|
+
|
|
301
|
+
const requirement: Requirement = {
|
|
302
|
+
id: nextId,
|
|
303
|
+
class: fields.class,
|
|
304
|
+
status: fields.status ?? 'active',
|
|
305
|
+
description: fields.description,
|
|
306
|
+
why: fields.why,
|
|
307
|
+
source: fields.source,
|
|
308
|
+
primary_owner: fields.primary_owner ?? '',
|
|
309
|
+
supporting_slices: fields.supporting_slices ?? '',
|
|
310
|
+
validation: fields.validation ?? '',
|
|
311
|
+
notes: fields.notes ?? '',
|
|
312
|
+
full_content: '',
|
|
313
|
+
superseded_by: null,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
db.upsertRequirement(requirement);
|
|
317
|
+
return nextId;
|
|
318
|
+
});
|
|
302
319
|
|
|
303
320
|
// Fetch all requirements for full file regeneration
|
|
304
321
|
const adapter = db._getAdapter();
|
|
@@ -358,6 +375,11 @@ export interface SaveDecisionFields {
|
|
|
358
375
|
/**
|
|
359
376
|
* Save a new decision to DB and regenerate DECISIONS.md.
|
|
360
377
|
* Auto-assigns the next ID via nextDecisionId().
|
|
378
|
+
*
|
|
379
|
+
* The ID computation (SELECT MAX) and insert are wrapped in a single
|
|
380
|
+
* transaction to prevent parallel tool calls from computing the same ID
|
|
381
|
+
* and silently overwriting each other (#3326, #3339, #3459).
|
|
382
|
+
*
|
|
361
383
|
* Returns the assigned ID.
|
|
362
384
|
*/
|
|
363
385
|
export async function saveDecisionToDb(
|
|
@@ -367,18 +389,33 @@ export async function saveDecisionToDb(
|
|
|
367
389
|
try {
|
|
368
390
|
const db = await import('./gsd-db.js');
|
|
369
391
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
db.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
392
|
+
// Atomic ID assignment + insert inside a transaction to prevent
|
|
393
|
+
// parallel calls from racing on the same MAX(id) value.
|
|
394
|
+
const id = db.transaction(() => {
|
|
395
|
+
const adapter = db._getAdapter();
|
|
396
|
+
if (!adapter) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
|
|
397
|
+
|
|
398
|
+
const row = adapter
|
|
399
|
+
.prepare('SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM decisions')
|
|
400
|
+
.get();
|
|
401
|
+
const maxNum = row ? (row['max_num'] as number | null) : null;
|
|
402
|
+
const nextId = (maxNum == null || isNaN(maxNum))
|
|
403
|
+
? 'D001'
|
|
404
|
+
: `D${String(maxNum + 1).padStart(3, '0')}`;
|
|
405
|
+
|
|
406
|
+
db.upsertDecision({
|
|
407
|
+
id: nextId,
|
|
408
|
+
when_context: fields.when_context ?? '',
|
|
409
|
+
scope: fields.scope,
|
|
410
|
+
decision: fields.decision,
|
|
411
|
+
choice: fields.choice,
|
|
412
|
+
rationale: fields.rationale,
|
|
413
|
+
revisable: fields.revisable ?? 'Yes',
|
|
414
|
+
made_by: fields.made_by ?? 'agent',
|
|
415
|
+
superseded_by: null,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
return nextId;
|
|
382
419
|
});
|
|
383
420
|
|
|
384
421
|
// Fetch all decisions (including superseded for the full register)
|