gsd-pi 2.62.0-dev.f6ad485 → 2.62.1-dev.1ae2b74

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.
Files changed (131) hide show
  1. package/dist/resources/extensions/ask-user-questions.js +47 -3
  2. package/dist/resources/extensions/gsd/auto/loop.js +8 -1
  3. package/dist/resources/extensions/gsd/auto/phases.js +10 -3
  4. package/dist/resources/extensions/gsd/auto-post-unit.js +6 -4
  5. package/dist/resources/extensions/gsd/auto-start.js +11 -6
  6. package/dist/resources/extensions/gsd/auto-timers.js +8 -2
  7. package/dist/resources/extensions/gsd/auto-verification.js +14 -3
  8. package/dist/resources/extensions/gsd/auto-worktree.js +19 -0
  9. package/dist/resources/extensions/gsd/auto.js +24 -0
  10. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +4 -0
  11. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +11 -1
  12. package/dist/resources/extensions/gsd/db-writer.js +64 -28
  13. package/dist/resources/extensions/gsd/preferences-models.js +74 -0
  14. package/dist/resources/extensions/gsd/preferences-skills.js +6 -1
  15. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
  16. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
  17. package/dist/resources/extensions/gsd/skill-catalog.js +6 -4
  18. package/dist/resources/extensions/gsd/skill-discovery.js +24 -6
  19. package/dist/resources/extensions/gsd/skill-health.js +7 -3
  20. package/dist/resources/extensions/gsd/skill-telemetry.js +5 -2
  21. package/dist/resources/extensions/gsd/state.js +1 -0
  22. package/dist/resources/extensions/gsd/tools/complete-slice.js +3 -3
  23. package/dist/resources/extensions/gsd/workflow-logger.js +13 -8
  24. package/dist/resources/extensions/gsd/workflow-reconcile.js +3 -1
  25. package/dist/web/standalone/.next/BUILD_ID +1 -1
  26. package/dist/web/standalone/.next/app-path-routes-manifest.json +20 -20
  27. package/dist/web/standalone/.next/build-manifest.json +2 -2
  28. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  29. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  30. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.html +1 -1
  46. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app-paths-manifest.json +20 -20
  53. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  54. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  55. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  56. package/package.json +1 -1
  57. package/packages/mcp-server/src/cli.ts +1 -1
  58. package/packages/mcp-server/src/index.ts +15 -1
  59. package/packages/mcp-server/src/readers/captures.ts +119 -0
  60. package/packages/mcp-server/src/readers/doctor-lite.ts +225 -0
  61. package/packages/mcp-server/src/readers/index.ts +16 -0
  62. package/packages/mcp-server/src/readers/knowledge.ts +111 -0
  63. package/packages/mcp-server/src/readers/metrics.ts +118 -0
  64. package/packages/mcp-server/src/readers/paths.ts +217 -0
  65. package/packages/mcp-server/src/readers/readers.test.ts +509 -0
  66. package/packages/mcp-server/src/readers/roadmap.ts +263 -0
  67. package/packages/mcp-server/src/readers/state.ts +223 -0
  68. package/packages/mcp-server/src/server.ts +134 -3
  69. package/packages/pi-ai/dist/utils/repair-tool-json.d.ts +26 -6
  70. package/packages/pi-ai/dist/utils/repair-tool-json.d.ts.map +1 -1
  71. package/packages/pi-ai/dist/utils/repair-tool-json.js +67 -9
  72. package/packages/pi-ai/dist/utils/repair-tool-json.js.map +1 -1
  73. package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js +73 -1
  74. package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js.map +1 -1
  75. package/packages/pi-ai/src/utils/repair-tool-json.ts +74 -10
  76. package/packages/pi-ai/src/utils/tests/repair-tool-json.test.ts +94 -1
  77. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.d.ts +2 -0
  78. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.d.ts.map +1 -0
  79. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.js +16 -0
  80. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.js.map +1 -0
  81. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  82. package/packages/pi-coding-agent/dist/core/agent-session.js +4 -0
  83. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  84. package/packages/pi-coding-agent/dist/core/retry-handler.d.ts +3 -0
  85. package/packages/pi-coding-agent/dist/core/retry-handler.d.ts.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/retry-handler.js +48 -16
  87. package/packages/pi-coding-agent/dist/core/retry-handler.js.map +1 -1
  88. package/packages/pi-coding-agent/dist/core/retry-handler.test.js +20 -3
  89. package/packages/pi-coding-agent/dist/core/retry-handler.test.js.map +1 -1
  90. package/packages/pi-coding-agent/package.json +1 -1
  91. package/packages/pi-coding-agent/src/core/agent-session-model-switch.test.ts +21 -0
  92. package/packages/pi-coding-agent/src/core/agent-session.ts +4 -0
  93. package/packages/pi-coding-agent/src/core/retry-handler.test.ts +30 -3
  94. package/packages/pi-coding-agent/src/core/retry-handler.ts +49 -16
  95. package/pkg/package.json +1 -1
  96. package/src/resources/extensions/ask-user-questions.ts +60 -4
  97. package/src/resources/extensions/gsd/auto/loop.ts +8 -1
  98. package/src/resources/extensions/gsd/auto/phases.ts +8 -6
  99. package/src/resources/extensions/gsd/auto-post-unit.ts +6 -3
  100. package/src/resources/extensions/gsd/auto-start.ts +11 -6
  101. package/src/resources/extensions/gsd/auto-timers.ts +8 -2
  102. package/src/resources/extensions/gsd/auto-verification.ts +14 -3
  103. package/src/resources/extensions/gsd/auto-worktree.ts +18 -0
  104. package/src/resources/extensions/gsd/auto.ts +25 -0
  105. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +4 -0
  106. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +13 -1
  107. package/src/resources/extensions/gsd/db-writer.ts +67 -30
  108. package/src/resources/extensions/gsd/preferences-models.ts +78 -0
  109. package/src/resources/extensions/gsd/preferences-skills.ts +6 -1
  110. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
  111. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
  112. package/src/resources/extensions/gsd/skill-catalog.ts +6 -3
  113. package/src/resources/extensions/gsd/skill-discovery.ts +23 -6
  114. package/src/resources/extensions/gsd/skill-health.ts +7 -3
  115. package/src/resources/extensions/gsd/skill-telemetry.ts +5 -2
  116. package/src/resources/extensions/gsd/state.ts +1 -0
  117. package/src/resources/extensions/gsd/tests/ask-user-questions-dedup.test.ts +120 -0
  118. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +22 -2
  119. package/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts +107 -0
  120. package/src/resources/extensions/gsd/tests/claude-skill-dirs.test.ts +51 -0
  121. package/src/resources/extensions/gsd/tests/db-writer.test.ts +41 -0
  122. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +75 -1
  123. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +17 -4
  124. package/src/resources/extensions/gsd/tests/workflow-logger.test.ts +17 -41
  125. package/src/resources/extensions/gsd/tests/worktree-db-respawn-truncation.test.ts +81 -2
  126. package/src/resources/extensions/gsd/tools/complete-slice.ts +3 -5
  127. package/src/resources/extensions/gsd/workflow-logger.ts +13 -8
  128. package/src/resources/extensions/gsd/workflow-reconcile.ts +3 -1
  129. package/src/resources/extensions/shared/tests/ask-user-freetext.test.ts +6 -1
  130. /package/dist/web/standalone/.next/static/{fbkSIi4k8fmB8mi0Sq9sF → erQZ_8_1lkclnPJLJnCxG}/_buildManifest.js +0 -0
  131. /package/dist/web/standalone/.next/static/{fbkSIi4k8fmB8mi0Sq9sF → erQZ_8_1lkclnPJLJnCxG}/_ssgManifest.js +0 -0
@@ -33,6 +33,31 @@ const AskUserQuestionsParams = Type.Object({
33
33
  description: "Questions to show the user. Prefer 1 and do not exceed 3.",
34
34
  }),
35
35
  });
36
+ // ─── Per-turn deduplication ──────────────────────────────────────────────────
37
+ // Prevents duplicate question dispatches (especially to remote channels like
38
+ // Discord) when the LLM calls ask_user_questions multiple times with the same
39
+ // questions in a single turn. Keyed by full canonicalized payload (id, header,
40
+ // question, options, allowMultiple) — not just IDs — so that calls with the
41
+ // same IDs but different text/options are treated as distinct.
42
+ import { createHash } from "node:crypto";
43
+ const turnCache = new Map();
44
+ /** @internal Exported for testing only. */
45
+ export function questionSignature(questions) {
46
+ const canonical = questions
47
+ .map((q) => ({
48
+ id: q.id,
49
+ header: q.header,
50
+ question: q.question,
51
+ options: (q.options || []).map((o) => ({ label: o.label, description: o.description })),
52
+ allowMultiple: !!q.allowMultiple,
53
+ }))
54
+ .sort((a, b) => a.id.localeCompare(b.id));
55
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
56
+ }
57
+ /** Reset the dedup cache. Called on session boundaries. */
58
+ export function resetAskUserQuestionsCache() {
59
+ turnCache.clear();
60
+ }
36
61
  // ─── Helpers ──────────────────────────────────────────────────────────────────
37
62
  const OTHER_OPTION_LABEL = "None of the above";
38
63
  function errorResult(message, questions = []) {
@@ -73,6 +98,15 @@ export default function AskUserQuestions(pi) {
73
98
  ],
74
99
  parameters: AskUserQuestionsParams,
75
100
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
101
+ // ── Per-turn dedup: return cached result for identical question sets ──
102
+ const sig = questionSignature(params.questions);
103
+ const cached = turnCache.get(sig);
104
+ if (cached) {
105
+ return {
106
+ content: [{ type: "text", text: cached.content[0].text + "\n(Returned cached answer — this question set was already asked this turn.)" }],
107
+ details: cached.details,
108
+ };
109
+ }
76
110
  // Validation
77
111
  if (params.questions.length === 0 || params.questions.length > 3) {
78
112
  return errorResult("Error: questions must contain 1-3 items", params.questions);
@@ -87,8 +121,14 @@ export default function AskUserQuestions(pi) {
87
121
  // this is a no-op when the user has not set up Slack/Discord/Telegram.
88
122
  const { tryRemoteQuestions } = await import("./remote-questions/manager.js");
89
123
  const remoteResult = await tryRemoteQuestions(params.questions, signal);
90
- if (remoteResult)
124
+ if (remoteResult) {
125
+ // Cache successful remote results to prevent duplicate Discord dispatches
126
+ const remoteDetails = remoteResult.details;
127
+ if (remoteDetails && !remoteDetails.timed_out && !remoteDetails.error) {
128
+ turnCache.set(sig, remoteResult);
129
+ }
91
130
  return { ...remoteResult, details: remoteResult.details };
131
+ }
92
132
  if (!ctx.hasUI) {
93
133
  return errorResult("Error: UI not available (non-interactive mode)", params.questions);
94
134
  }
@@ -131,7 +171,7 @@ export default function AskUserQuestions(pi) {
131
171
  { selected: a.answers.length === 1 ? a.answers[0] : a.answers, notes: "" },
132
172
  ])),
133
173
  };
134
- return {
174
+ const fallbackResult = {
135
175
  content: [{ type: "text", text: JSON.stringify({ answers }) }],
136
176
  details: {
137
177
  questions: params.questions,
@@ -139,6 +179,8 @@ export default function AskUserQuestions(pi) {
139
179
  cancelled: false,
140
180
  },
141
181
  };
182
+ turnCache.set(sig, fallbackResult);
183
+ return fallbackResult;
142
184
  }
143
185
  // Check if cancelled (empty answers = user exited)
144
186
  const hasAnswers = Object.keys(result.answers).length > 0;
@@ -148,10 +190,12 @@ export default function AskUserQuestions(pi) {
148
190
  details: { questions: params.questions, response: null, cancelled: true },
149
191
  };
150
192
  }
151
- return {
193
+ const successResult = {
152
194
  content: [{ type: "text", text: formatForLLM(result) }],
153
195
  details: { questions: params.questions, response: result, cancelled: false },
154
196
  };
197
+ turnCache.set(sig, successResult);
198
+ return successResult;
155
199
  },
156
200
  // ─── Rendering ────────────────────────────────────────────────────────
157
201
  renderCall(args, theme) {
@@ -26,6 +26,7 @@ export async function autoLoop(ctx, pi, s, deps) {
26
26
  let iteration = 0;
27
27
  const loopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
28
28
  let consecutiveErrors = 0;
29
+ const recentErrorMessages = [];
29
30
  while (s.active) {
30
31
  iteration++;
31
32
  debugLog("autoLoop", { phase: "loop-top", iteration });
@@ -157,6 +158,7 @@ export async function autoLoop(ctx, pi, s, deps) {
157
158
  });
158
159
  deps.clearUnitTimeout();
159
160
  consecutiveErrors = 0;
161
+ recentErrorMessages.length = 0;
160
162
  deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration } });
161
163
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
162
164
  continue;
@@ -206,6 +208,7 @@ export async function autoLoop(ctx, pi, s, deps) {
206
208
  if (finalizeResult.action === "continue")
207
209
  continue;
208
210
  consecutiveErrors = 0; // Iteration completed successfully
211
+ recentErrorMessages.length = 0;
209
212
  deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration } });
210
213
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
211
214
  }
@@ -228,6 +231,7 @@ export async function autoLoop(ctx, pi, s, deps) {
228
231
  break;
229
232
  }
230
233
  consecutiveErrors++;
234
+ recentErrorMessages.push(msg.length > 120 ? msg.slice(0, 120) + "..." : msg);
231
235
  debugLog("autoLoop", {
232
236
  phase: "iteration-error",
233
237
  iteration,
@@ -236,7 +240,10 @@ export async function autoLoop(ctx, pi, s, deps) {
236
240
  });
237
241
  if (consecutiveErrors >= 3) {
238
242
  // 3+ consecutive: hard stop — something is fundamentally broken
239
- ctx.ui.notify(`Auto-mode stopped: ${consecutiveErrors} consecutive iteration failures. Last: ${msg}`, "error");
243
+ const errorHistory = recentErrorMessages
244
+ .map((m, i) => ` ${i + 1}. ${m}`)
245
+ .join("\n");
246
+ ctx.ui.notify(`Auto-mode stopped: ${consecutiveErrors} consecutive iteration failures:\n${errorHistory}`, "error");
240
247
  await deps.stopAuto(ctx, pi, `${consecutiveErrors} consecutive iteration failures`);
241
248
  break;
242
249
  }
@@ -18,7 +18,7 @@ import { existsSync, cpSync } from "node:fs";
18
18
  import { logWarning, logError } from "../workflow-logger.js";
19
19
  import { gsdRoot } from "../paths.js";
20
20
  import { atomicWriteSync } from "../atomic-write.js";
21
- import { verifyExpectedArtifact } from "../auto-recovery.js";
21
+ import { verifyExpectedArtifact, diagnoseExpectedArtifact, buildLoopRemediationSteps } from "../auto-recovery.js";
22
22
  import { writeUnitRuntimeRecord } from "../unit-runtime.js";
23
23
  // ─── generateMilestoneReport ──────────────────────────────────────────────────
24
24
  /**
@@ -116,7 +116,7 @@ export async function runPreDispatch(ic, loopState) {
116
116
  ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info");
117
117
  }
118
118
  if (!healthGate.proceed) {
119
- ctx.ui.notify(healthGate.reason ?? "Pre-dispatch health check failed.", "error");
119
+ ctx.ui.notify(healthGate.reason || "Pre-dispatch health check failed — run /gsd doctor for details.", "error");
120
120
  await deps.pauseAuto(ctx, pi);
121
121
  debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" });
122
122
  return { action: "break", reason: "health-gate-failed" };
@@ -431,8 +431,15 @@ export async function runDispatch(ic, preData, loopState) {
431
431
  unitId,
432
432
  reason: stuckSignal.reason,
433
433
  });
434
+ const stuckDiag = diagnoseExpectedArtifact(unitType, unitId, s.basePath);
435
+ const stuckRemediation = buildLoopRemediationSteps(unitType, unitId, s.basePath);
436
+ const stuckParts = [`Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}.`];
437
+ if (stuckDiag)
438
+ stuckParts.push(`Expected: ${stuckDiag}`);
439
+ if (stuckRemediation)
440
+ stuckParts.push(`To recover:\n${stuckRemediation}`);
441
+ ctx.ui.notify(stuckParts.join(" "), "error");
434
442
  await deps.stopAuto(ctx, pi, `Stuck: ${stuckSignal.reason}`);
435
- ctx.ui.notify(`Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}. The expected artifact was not written.`, "error");
436
443
  return { action: "break", reason: "stuck-detected" };
437
444
  }
438
445
  }
@@ -19,7 +19,7 @@ import { invalidateAllCaches } from "./cache.js";
19
19
  import { parseUnitId } from "./unit-id.js";
20
20
  import { closeoutUnit } from "./auto-unit-closeout.js";
21
21
  import { autoCommitCurrentBranch, } from "./worktree.js";
22
- import { verifyExpectedArtifact, resolveExpectedArtifactPath, } from "./auto-recovery.js";
22
+ import { verifyExpectedArtifact, resolveExpectedArtifactPath, diagnoseExpectedArtifact, } from "./auto-recovery.js";
23
23
  import { regenerateIfMissing } from "./workflow-projections.js";
24
24
  import { syncStateToProjectRoot } from "./auto-worktree.js";
25
25
  import { isDbAvailable, getTask, getSlice, getMilestone, updateTaskStatus, _getAdapter } from "./gsd-db.js";
@@ -383,7 +383,8 @@ export async function postUnitPreVerification(pctx, opts) {
383
383
  // db_unavailable so the artifact was never written. Retrying would
384
384
  // produce an infinite re-dispatch loop (#2517).
385
385
  debugLog("postUnit", { phase: "artifact-verify-skip-db-unavailable", unitType: s.currentUnit.type, unitId: s.currentUnit.id });
386
- ctx.ui.notify(`Artifact missing for ${s.currentUnit.type} ${s.currentUnit.id} but DB is unavailable — skipping retry to avoid loop (#2517)`, "error");
386
+ const dbSkipDiag = diagnoseExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath);
387
+ ctx.ui.notify(`Artifact missing for ${s.currentUnit.type} ${s.currentUnit.id} — DB unavailable, skipping retry.${dbSkipDiag ? ` Expected: ${dbSkipDiag}` : ""}`, "error");
387
388
  }
388
389
  else if (!triggerArtifactVerified) {
389
390
  const hasExpectedArtifact = resolveExpectedArtifactPath(s.currentUnit.type, s.currentUnit.id, s.basePath) !== null;
@@ -391,13 +392,14 @@ export async function postUnitPreVerification(pctx, opts) {
391
392
  const retryKey = `${s.currentUnit.type}:${s.currentUnit.id}`;
392
393
  const attempt = (s.verificationRetryCount.get(retryKey) ?? 0) + 1;
393
394
  s.verificationRetryCount.set(retryKey, attempt);
395
+ const retryDiag = diagnoseExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath);
394
396
  s.pendingVerificationRetry = {
395
397
  unitId: s.currentUnit.id,
396
- failureContext: `Artifact verification failed: expected artifact for ${s.currentUnit.type} "${s.currentUnit.id}" was not found on disk after unit execution (attempt ${attempt}).`,
398
+ failureContext: `Artifact verification failed: expected artifact for ${s.currentUnit.type} "${s.currentUnit.id}" was not found on disk after unit execution (attempt ${attempt}).${retryDiag ? ` Expected: ${retryDiag}` : ""}`,
397
399
  attempt,
398
400
  };
399
401
  debugLog("postUnit", { phase: "artifact-verify-retry", unitType: s.currentUnit.type, unitId: s.currentUnit.id, attempt });
400
- ctx.ui.notify(`Artifact missing for ${s.currentUnit.type} ${s.currentUnit.id} — retrying (attempt ${attempt})`, "warning");
402
+ ctx.ui.notify(`Artifact missing for ${s.currentUnit.type} ${s.currentUnit.id} — retrying (attempt ${attempt}).${retryDiag ? ` Expected: ${retryDiag}` : ""}`, "warning");
401
403
  return "retry";
402
404
  }
403
405
  }
@@ -39,6 +39,7 @@ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, } from "node:
39
39
  import { join } from "node:path";
40
40
  import { sep as pathSep } from "node:path";
41
41
  import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
42
+ import { resolveDefaultSessionModel } from "./preferences-models.js";
42
43
  /**
43
44
  * Bootstrap a fresh auto-mode session. Handles everything from git init
44
45
  * through secrets collection, returning when ready for the first
@@ -89,12 +90,16 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
89
90
  }
90
91
  // Capture the user's session model before guided-flow dispatch can apply a
91
92
  // phase-specific planning model for a discuss turn (#2829).
92
- const startModelSnapshot = ctx.model
93
- ? {
94
- provider: ctx.model.provider,
95
- id: ctx.model.id,
96
- }
97
- : null;
93
+ //
94
+ // GSD PREFERENCES.md takes priority over the session model from settings.json
95
+ // (#3517). The session model (ctx.model) comes from findInitialModel() which
96
+ // reads defaultProvider/defaultModel from ~/.gsd/agent/settings.json. When
97
+ // the user has explicit model preferences in PREFERENCES.md, those should win.
98
+ const preferredModel = resolveDefaultSessionModel(ctx.model?.provider);
99
+ const startModelSnapshot = preferredModel
100
+ ?? (ctx.model
101
+ ? { provider: ctx.model.provider, id: ctx.model.id }
102
+ : null);
98
103
  try {
99
104
  // Validate GSD_PROJECT_ID early so the user gets immediate feedback
100
105
  const customProjectId = process.env.GSD_PROJECT_ID;
@@ -92,6 +92,10 @@ export function startUnitSupervision(sctx) {
92
92
  phase: "wrapup-warning-sent",
93
93
  wrapupWarningSent: true,
94
94
  });
95
+ // Only trigger a new turn if no tools are currently in flight.
96
+ // Triggering during active tool calls causes tool results to be skipped
97
+ // with "Skipped due to queued user message", leading to provider errors (#3512).
98
+ const softTrigger = getInFlightToolCount() === 0;
95
99
  pi.sendMessage({
96
100
  customType: "gsd-auto-wrapup",
97
101
  display: s.verbose,
@@ -104,7 +108,7 @@ export function startUnitSupervision(sctx) {
104
108
  "3. mark task or slice state on disk correctly",
105
109
  "4. leave precise resume notes if anything remains unfinished",
106
110
  ].join("\n"),
107
- }, { triggerTurn: true });
111
+ }, { triggerTurn: softTrigger });
108
112
  }, softTimeoutMs);
109
113
  // ── 2. Idle watchdog ──
110
114
  s.idleWatchdogHandle = setInterval(async () => {
@@ -245,6 +249,8 @@ export function startUnitSupervision(sctx) {
245
249
  if (s.verbose) {
246
250
  ctx.ui.notify(`Context at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%) — sending wrap-up signal.`, "info");
247
251
  }
252
+ // Only trigger a new turn if no tools are currently in flight (#3512).
253
+ const contextTrigger = getInFlightToolCount() === 0;
248
254
  pi.sendMessage({
249
255
  customType: "gsd-auto-wrapup",
250
256
  display: s.verbose,
@@ -258,7 +264,7 @@ export function startUnitSupervision(sctx) {
258
264
  "4. Leave precise resume notes if anything remains unfinished",
259
265
  "Do NOT start new sub-tasks or investigations.",
260
266
  ].join("\n"),
261
- }, { triggerTurn: true });
267
+ }, { triggerTurn: contextTrigger });
262
268
  if (s.continueHereHandle) {
263
269
  clearInterval(s.continueHereHandle);
264
270
  s.continueHereHandle = null;
@@ -142,16 +142,27 @@ export async function runPostUnitVerification(vctx, pauseAuto) {
142
142
  failureContext: formatFailureContext(result),
143
143
  attempt: nextAttempt,
144
144
  };
145
- ctx.ui.notify(`Verification failed auto-fix attempt ${nextAttempt}/${maxRetries}`, "warning");
145
+ const failedCmds = result.checks
146
+ .filter((c) => c.exitCode !== 0)
147
+ .map((c) => c.command);
148
+ const cmdSummary = failedCmds.length <= 3
149
+ ? failedCmds.join(", ")
150
+ : `${failedCmds.slice(0, 3).join(", ")}... and ${failedCmds.length - 3} more`;
151
+ ctx.ui.notify(`Verification failed (${cmdSummary}) — auto-fix attempt ${nextAttempt}/${maxRetries}`, "warning");
146
152
  // Return "retry" — the autoLoop while loop will re-iterate with the retry context
147
153
  return "retry";
148
154
  }
149
155
  else {
150
156
  // Gate failed, retries exhausted
151
- const exhaustedAttempt = attempt + 1;
152
157
  s.verificationRetryCount.delete(s.currentUnit.id);
153
158
  s.pendingVerificationRetry = null;
154
- ctx.ui.notify(`Verification gate FAILED after ${exhaustedAttempt > maxRetries ? exhaustedAttempt - 1 : exhaustedAttempt} retries — pausing for human review`, "error");
159
+ const exhaustedFails = result.checks
160
+ .filter((c) => c.exitCode !== 0)
161
+ .map((c) => c.command);
162
+ const exhaustedSummary = exhaustedFails.length <= 3
163
+ ? exhaustedFails.join(", ")
164
+ : `${exhaustedFails.slice(0, 3).join(", ")}... and ${exhaustedFails.length - 3} more`;
165
+ ctx.ui.notify(`Verification gate FAILED after ${attempt} ${attempt === 1 ? "retry" : "retries"} (${exhaustedSummary}) — pausing for human review`, "error");
155
166
  await pauseAuto(ctx, pi);
156
167
  return "pause";
157
168
  }
@@ -234,10 +234,29 @@ export function syncProjectRootToWorktree(projectRoot, worktreePath_, milestoneI
234
234
  // openDatabase re-creates it, causing "no such table" failures (#2815).
235
235
  try {
236
236
  const wtDb = join(wtGsd, "gsd.db");
237
+ let deleteSidecars = false;
237
238
  if (existsSync(wtDb)) {
238
239
  const size = statSync(wtDb).size;
239
240
  if (size === 0) {
240
241
  unlinkSync(wtDb);
242
+ deleteSidecars = true;
243
+ }
244
+ }
245
+ else {
246
+ // Main DB already missing — sidecars are orphaned from a previous
247
+ // partial cleanup and must still be removed.
248
+ deleteSidecars = true;
249
+ }
250
+ // Always clean up WAL/SHM sidecar files when the main DB was deleted
251
+ // or is already missing. Orphaned WAL/SHM files cause SQLite WAL
252
+ // recovery on next open, which triggers a CPU spin on Node 24's
253
+ // node:sqlite DatabaseSync implementation (#2478).
254
+ if (deleteSidecars) {
255
+ for (const suffix of ["-wal", "-shm"]) {
256
+ const f = wtDb + suffix;
257
+ if (existsSync(f)) {
258
+ unlinkSync(f);
259
+ }
241
260
  }
242
261
  }
243
262
  }
@@ -377,6 +377,18 @@ export async function stopAuto(ctx, pi, reason) {
377
377
  catch (e) {
378
378
  debugLog("stop-cleanup-locks", { error: e instanceof Error ? e.message : String(e) });
379
379
  }
380
+ // ── Step 1b: Flush queued follow-up messages (#3512) ──
381
+ // Late async notifications (async_job_result, gsd-auto-wrapup) can trigger
382
+ // extra LLM turns after stop. Flush them the same way run-unit.ts does.
383
+ try {
384
+ const cmdCtxAny = s.cmdCtx;
385
+ if (typeof cmdCtxAny?.clearQueue === "function") {
386
+ cmdCtxAny.clearQueue();
387
+ }
388
+ }
389
+ catch (e) {
390
+ debugLog("stop-cleanup-queue", { error: e instanceof Error ? e.message : String(e) });
391
+ }
380
392
  // ── Step 2: Skill state ──
381
393
  try {
382
394
  clearSkillSnapshot();
@@ -589,6 +601,18 @@ export async function pauseAuto(ctx, _pi, _errorContext) {
589
601
  if (!s.active)
590
602
  return;
591
603
  clearUnitTimeout();
604
+ // Flush queued follow-up messages (#3512).
605
+ // Late async notifications (async_job_result, gsd-auto-wrapup) can trigger
606
+ // extra LLM turns after pause. Flush them the same way run-unit.ts does.
607
+ try {
608
+ const cmdCtxAny = s.cmdCtx;
609
+ if (typeof cmdCtxAny?.clearQueue === "function") {
610
+ cmdCtxAny.clearQueue();
611
+ }
612
+ }
613
+ catch (e) {
614
+ debugLog("pause-cleanup-queue", { error: e instanceof Error ? e.message : String(e) });
615
+ }
592
616
  // Unblock any pending unit promise so the auto-loop is not orphaned.
593
617
  // Pass errorContext so runUnitPhase can distinguish user-initiated pause
594
618
  // from provider-error pause and avoid hard-stopping (#2762).
@@ -14,6 +14,7 @@ import { getAutoDashboardData, isAutoActive, isAutoPaused, markToolEnd, markTool
14
14
  import { isParallelActive, shutdownParallel } from "../parallel-orchestrator.js";
15
15
  import { checkToolCallLoop, resetToolCallLoopGuard } from "./tool-call-loop-guard.js";
16
16
  import { saveActivityLog } from "../activity-log.js";
17
+ import { resetAskUserQuestionsCache } from "../../ask-user-questions.js";
17
18
  // Skip the welcome screen on the very first session_start — cli.ts already
18
19
  // printed it before the TUI launched. Only re-print on /clear (subsequent sessions).
19
20
  let isFirstSession = true;
@@ -25,6 +26,7 @@ export function registerHooks(pi) {
25
26
  pi.on("session_start", async (_event, ctx) => {
26
27
  resetWriteGateState();
27
28
  resetToolCallLoopGuard();
29
+ resetAskUserQuestionsCache();
28
30
  await syncServiceTierStatus(ctx);
29
31
  // Apply show_token_cost preference (#1515)
30
32
  try {
@@ -60,6 +62,7 @@ export function registerHooks(pi) {
60
62
  pi.on("session_switch", async (_event, ctx) => {
61
63
  resetWriteGateState();
62
64
  resetToolCallLoopGuard();
65
+ resetAskUserQuestionsCache();
63
66
  clearDiscussionFlowState();
64
67
  await syncServiceTierStatus(ctx);
65
68
  loadToolApiKeys();
@@ -69,6 +72,7 @@ export function registerHooks(pi) {
69
72
  });
70
73
  pi.on("agent_end", async (event, ctx) => {
71
74
  resetToolCallLoopGuard();
75
+ resetAskUserQuestionsCache();
72
76
  await handleAgentEnd(pi, event, ctx);
73
77
  });
74
78
  // Squash-merge quick-task branch back to the original branch after the
@@ -13,8 +13,12 @@
13
13
  */
14
14
  import { createHash } from "node:crypto";
15
15
  const MAX_CONSECUTIVE_IDENTICAL_CALLS = 4;
16
+ /** Interactive/user-facing tools where even 1 duplicate is confusing. */
17
+ const STRICT_LOOP_TOOLS = new Set(["ask_user_questions"]);
18
+ const MAX_CONSECUTIVE_STRICT = 1;
16
19
  let consecutiveCount = 0;
17
20
  let lastSignature = "";
21
+ let lastToolName = "";
18
22
  let enabled = true;
19
23
  /** Hash tool name + args into a compact signature for comparison. */
20
24
  function hashToolCall(toolName, args) {
@@ -45,8 +49,12 @@ export function checkToolCallLoop(toolName, args) {
45
49
  else {
46
50
  consecutiveCount = 1;
47
51
  lastSignature = sig;
52
+ lastToolName = toolName;
48
53
  }
49
- if (consecutiveCount > MAX_CONSECUTIVE_IDENTICAL_CALLS) {
54
+ const threshold = STRICT_LOOP_TOOLS.has(toolName)
55
+ ? MAX_CONSECUTIVE_STRICT
56
+ : MAX_CONSECUTIVE_IDENTICAL_CALLS;
57
+ if (consecutiveCount > threshold) {
50
58
  return {
51
59
  block: true,
52
60
  reason: `Tool loop detected: ${toolName} called ${consecutiveCount} times ` +
@@ -61,6 +69,7 @@ export function checkToolCallLoop(toolName, args) {
61
69
  export function resetToolCallLoopGuard() {
62
70
  consecutiveCount = 0;
63
71
  lastSignature = "";
72
+ lastToolName = "";
64
73
  enabled = true;
65
74
  }
66
75
  /** Disable the guard (e.g. during shutdown). */
@@ -68,6 +77,7 @@ export function disableToolCallLoopGuard() {
68
77
  enabled = false;
69
78
  consecutiveCount = 0;
70
79
  lastSignature = "";
80
+ lastToolName = "";
71
81
  }
72
82
  /** Get current consecutive count for diagnostics. */
73
83
  export function getToolCallLoopCount() {
@@ -11,7 +11,7 @@ import { resolve } from 'node:path';
11
11
  import { readFileSync, existsSync, statSync } from 'node:fs';
12
12
  import { resolveGsdRootFile } from './paths.js';
13
13
  import { saveFile } from './files.js';
14
- import { GSDError, GSD_IO_ERROR } from './errors.js';
14
+ import { GSDError, GSD_STALE_STATE, GSD_IO_ERROR } from './errors.js';
15
15
  import { logWarning, logError } from './workflow-logger.js';
16
16
  import { invalidateStateCache } from './state.js';
17
17
  import { clearPathCache } from './paths.js';
@@ -234,27 +234,44 @@ export async function nextRequirementId() {
234
234
  /**
235
235
  * Save a new requirement to DB and regenerate REQUIREMENTS.md.
236
236
  * Auto-assigns the next ID via nextRequirementId().
237
+ *
238
+ * The ID computation and insert are wrapped in a single transaction
239
+ * to prevent parallel race conditions (same pattern as saveDecisionToDb).
240
+ *
237
241
  * Returns the assigned ID.
238
242
  */
239
243
  export async function saveRequirementToDb(fields, basePath) {
240
244
  try {
241
245
  const db = await import('./gsd-db.js');
242
- const id = await nextRequirementId();
243
- const requirement = {
244
- id,
245
- class: fields.class,
246
- status: fields.status ?? 'active',
247
- description: fields.description,
248
- why: fields.why,
249
- source: fields.source,
250
- primary_owner: fields.primary_owner ?? '',
251
- supporting_slices: fields.supporting_slices ?? '',
252
- validation: fields.validation ?? '',
253
- notes: fields.notes ?? '',
254
- full_content: '',
255
- superseded_by: null,
256
- };
257
- db.upsertRequirement(requirement);
246
+ // Atomic ID assignment + insert inside a transaction.
247
+ const id = db.transaction(() => {
248
+ const adapter = db._getAdapter();
249
+ if (!adapter)
250
+ throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
251
+ const row = adapter
252
+ .prepare('SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM requirements')
253
+ .get();
254
+ const maxNum = row ? row['max_num'] : null;
255
+ const nextId = (maxNum == null || isNaN(maxNum))
256
+ ? 'R001'
257
+ : `R${String(maxNum + 1).padStart(3, '0')}`;
258
+ const requirement = {
259
+ id: nextId,
260
+ class: fields.class,
261
+ status: fields.status ?? 'active',
262
+ description: fields.description,
263
+ why: fields.why,
264
+ source: fields.source,
265
+ primary_owner: fields.primary_owner ?? '',
266
+ supporting_slices: fields.supporting_slices ?? '',
267
+ validation: fields.validation ?? '',
268
+ notes: fields.notes ?? '',
269
+ full_content: '',
270
+ superseded_by: null,
271
+ };
272
+ db.upsertRequirement(requirement);
273
+ return nextId;
274
+ });
258
275
  // Fetch all requirements for full file regeneration
259
276
  const adapter = db._getAdapter();
260
277
  let allRequirements = [];
@@ -300,22 +317,41 @@ export async function saveRequirementToDb(fields, basePath) {
300
317
  /**
301
318
  * Save a new decision to DB and regenerate DECISIONS.md.
302
319
  * Auto-assigns the next ID via nextDecisionId().
320
+ *
321
+ * The ID computation (SELECT MAX) and insert are wrapped in a single
322
+ * transaction to prevent parallel tool calls from computing the same ID
323
+ * and silently overwriting each other (#3326, #3339, #3459).
324
+ *
303
325
  * Returns the assigned ID.
304
326
  */
305
327
  export async function saveDecisionToDb(fields, basePath) {
306
328
  try {
307
329
  const db = await import('./gsd-db.js');
308
- const id = await nextDecisionId();
309
- db.upsertDecision({
310
- id,
311
- when_context: fields.when_context ?? '',
312
- scope: fields.scope,
313
- decision: fields.decision,
314
- choice: fields.choice,
315
- rationale: fields.rationale,
316
- revisable: fields.revisable ?? 'Yes',
317
- made_by: fields.made_by ?? 'agent',
318
- superseded_by: null,
330
+ // Atomic ID assignment + insert inside a transaction to prevent
331
+ // parallel calls from racing on the same MAX(id) value.
332
+ const id = db.transaction(() => {
333
+ const adapter = db._getAdapter();
334
+ if (!adapter)
335
+ throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
336
+ const row = adapter
337
+ .prepare('SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM decisions')
338
+ .get();
339
+ const maxNum = row ? row['max_num'] : null;
340
+ const nextId = (maxNum == null || isNaN(maxNum))
341
+ ? 'D001'
342
+ : `D${String(maxNum + 1).padStart(3, '0')}`;
343
+ db.upsertDecision({
344
+ id: nextId,
345
+ when_context: fields.when_context ?? '',
346
+ scope: fields.scope,
347
+ decision: fields.decision,
348
+ choice: fields.choice,
349
+ rationale: fields.rationale,
350
+ revisable: fields.revisable ?? 'Yes',
351
+ made_by: fields.made_by ?? 'agent',
352
+ superseded_by: null,
353
+ });
354
+ return nextId;
319
355
  });
320
356
  // Fetch all decisions (including superseded for the full register)
321
357
  const adapter = db._getAdapter();