gsd-pi 2.26.0 → 2.27.0

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 (171) hide show
  1. package/README.md +43 -6
  2. package/dist/cli.js +4 -2
  3. package/dist/headless.d.ts +3 -0
  4. package/dist/headless.js +136 -8
  5. package/dist/help-text.js +3 -0
  6. package/dist/loader.js +33 -4
  7. package/dist/resources/extensions/bg-shell/index.ts +19 -2
  8. package/dist/resources/extensions/bg-shell/process-manager.ts +45 -0
  9. package/dist/resources/extensions/bg-shell/types.ts +21 -1
  10. package/dist/resources/extensions/gsd/auto/session.ts +224 -0
  11. package/dist/resources/extensions/gsd/auto-budget.ts +32 -0
  12. package/dist/resources/extensions/gsd/auto-dashboard.ts +63 -10
  13. package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  14. package/dist/resources/extensions/gsd/auto-dispatch.ts +23 -10
  15. package/dist/resources/extensions/gsd/auto-model-selection.ts +179 -0
  16. package/dist/resources/extensions/gsd/auto-observability.ts +74 -0
  17. package/dist/resources/extensions/gsd/auto-prompts.ts +0 -1
  18. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  19. package/dist/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  20. package/dist/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  21. package/dist/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  22. package/dist/resources/extensions/gsd/auto.ts +977 -1551
  23. package/dist/resources/extensions/gsd/commands.ts +3 -3
  24. package/dist/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  25. package/dist/resources/extensions/gsd/doctor-proactive.ts +9 -4
  26. package/dist/resources/extensions/gsd/export-html.ts +1001 -0
  27. package/dist/resources/extensions/gsd/export.ts +49 -1
  28. package/dist/resources/extensions/gsd/git-service.ts +6 -0
  29. package/dist/resources/extensions/gsd/gitignore.ts +4 -1
  30. package/dist/resources/extensions/gsd/guided-flow.ts +24 -5
  31. package/dist/resources/extensions/gsd/index.ts +54 -1
  32. package/dist/resources/extensions/gsd/native-git-bridge.ts +30 -2
  33. package/dist/resources/extensions/gsd/observability-validator.ts +21 -0
  34. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  35. package/dist/resources/extensions/gsd/preferences.ts +62 -1
  36. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -3
  37. package/dist/resources/extensions/gsd/prompts/system.md +1 -1
  38. package/dist/resources/extensions/gsd/reports.ts +510 -0
  39. package/dist/resources/extensions/gsd/roadmap-slices.ts +1 -1
  40. package/dist/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  41. package/dist/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  42. package/dist/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  43. package/dist/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  44. package/dist/resources/extensions/gsd/state.ts +30 -0
  45. package/dist/resources/extensions/gsd/templates/task-summary.md +9 -0
  46. package/dist/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  47. package/dist/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  48. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  49. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  50. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  51. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  52. package/dist/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  53. package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  54. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  55. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  56. package/dist/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  57. package/dist/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  58. package/dist/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  59. package/dist/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  60. package/dist/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  61. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  62. package/dist/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  63. package/dist/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  64. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  65. package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  66. package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  67. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  68. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  69. package/dist/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  70. package/dist/resources/extensions/gsd/types.ts +38 -0
  71. package/dist/resources/extensions/gsd/verification-evidence.ts +183 -0
  72. package/dist/resources/extensions/gsd/verification-gate.ts +567 -0
  73. package/dist/resources/extensions/gsd/visualizer-data.ts +25 -3
  74. package/dist/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  75. package/dist/resources/extensions/gsd/visualizer-views.ts +15 -66
  76. package/dist/resources/extensions/search-the-web/tool-search.ts +26 -0
  77. package/dist/resources/extensions/shared/format-utils.ts +85 -0
  78. package/dist/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  79. package/dist/resources/extensions/subagent/index.ts +46 -1
  80. package/dist/resources/extensions/subagent/isolation.ts +9 -6
  81. package/package.json +1 -1
  82. package/packages/pi-ai/dist/providers/openai-completions.js +7 -4
  83. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  84. package/packages/pi-ai/src/providers/openai-completions.ts +7 -4
  85. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/lsp/client.js +7 -0
  87. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  88. package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/lsp/config.js +9 -2
  90. package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
  91. package/packages/pi-coding-agent/src/core/lsp/client.ts +8 -0
  92. package/packages/pi-coding-agent/src/core/lsp/config.ts +9 -2
  93. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  94. package/packages/pi-tui/dist/components/editor.js +1 -1
  95. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  96. package/packages/pi-tui/src/components/editor.ts +3 -1
  97. package/scripts/link-workspace-packages.cjs +22 -6
  98. package/src/resources/extensions/bg-shell/index.ts +19 -2
  99. package/src/resources/extensions/bg-shell/process-manager.ts +45 -0
  100. package/src/resources/extensions/bg-shell/types.ts +21 -1
  101. package/src/resources/extensions/gsd/auto/session.ts +224 -0
  102. package/src/resources/extensions/gsd/auto-budget.ts +32 -0
  103. package/src/resources/extensions/gsd/auto-dashboard.ts +63 -10
  104. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  105. package/src/resources/extensions/gsd/auto-dispatch.ts +23 -10
  106. package/src/resources/extensions/gsd/auto-model-selection.ts +179 -0
  107. package/src/resources/extensions/gsd/auto-observability.ts +74 -0
  108. package/src/resources/extensions/gsd/auto-prompts.ts +0 -1
  109. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  110. package/src/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  111. package/src/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  112. package/src/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  113. package/src/resources/extensions/gsd/auto.ts +977 -1551
  114. package/src/resources/extensions/gsd/commands.ts +3 -3
  115. package/src/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  116. package/src/resources/extensions/gsd/doctor-proactive.ts +9 -4
  117. package/src/resources/extensions/gsd/export-html.ts +1001 -0
  118. package/src/resources/extensions/gsd/export.ts +49 -1
  119. package/src/resources/extensions/gsd/git-service.ts +6 -0
  120. package/src/resources/extensions/gsd/gitignore.ts +4 -1
  121. package/src/resources/extensions/gsd/guided-flow.ts +24 -5
  122. package/src/resources/extensions/gsd/index.ts +54 -1
  123. package/src/resources/extensions/gsd/native-git-bridge.ts +30 -2
  124. package/src/resources/extensions/gsd/observability-validator.ts +21 -0
  125. package/src/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  126. package/src/resources/extensions/gsd/preferences.ts +62 -1
  127. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -3
  128. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  129. package/src/resources/extensions/gsd/reports.ts +510 -0
  130. package/src/resources/extensions/gsd/roadmap-slices.ts +1 -1
  131. package/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  132. package/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  133. package/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  134. package/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  135. package/src/resources/extensions/gsd/state.ts +30 -0
  136. package/src/resources/extensions/gsd/templates/task-summary.md +9 -0
  137. package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  138. package/src/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  139. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  140. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  141. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  142. package/src/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  143. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  144. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  145. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  146. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  147. package/src/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  148. package/src/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  149. package/src/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  150. package/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  151. package/src/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  152. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  153. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  154. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  155. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  156. package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  157. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  158. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  159. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  160. package/src/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  161. package/src/resources/extensions/gsd/types.ts +38 -0
  162. package/src/resources/extensions/gsd/verification-evidence.ts +183 -0
  163. package/src/resources/extensions/gsd/verification-gate.ts +567 -0
  164. package/src/resources/extensions/gsd/visualizer-data.ts +25 -3
  165. package/src/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  166. package/src/resources/extensions/gsd/visualizer-views.ts +15 -66
  167. package/src/resources/extensions/search-the-web/tool-search.ts +26 -0
  168. package/src/resources/extensions/shared/format-utils.ts +85 -0
  169. package/src/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  170. package/src/resources/extensions/subagent/index.ts +46 -1
  171. package/src/resources/extensions/subagent/isolation.ts +9 -6
@@ -93,9 +93,57 @@ export function writeExportFile(
93
93
  }
94
94
 
95
95
  /**
96
- * Export session/milestone data to JSON or markdown.
96
+ * Export session/milestone data to JSON, markdown, or HTML.
97
97
  */
98
98
  export async function handleExport(args: string, ctx: ExtensionCommandContext, basePath: string): Promise<void> {
99
+ // HTML report — delegates to the full visualizer-data pipeline
100
+ if (args.includes("--html")) {
101
+ try {
102
+ const { loadVisualizerData } = await import("./visualizer-data.js");
103
+ const { generateHtmlReport } = await import("./export-html.js");
104
+ const { writeReportSnapshot, reportsDir } = await import("./reports.js");
105
+ const { basename: bn } = await import("node:path");
106
+ const data = await loadVisualizerData(basePath);
107
+ const projName = basename(basePath);
108
+ const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
109
+ const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
110
+ const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0);
111
+ const outPath = writeReportSnapshot({
112
+ basePath,
113
+ html: generateHtmlReport(data, {
114
+ projectName: projName,
115
+ projectPath: basePath,
116
+ gsdVersion,
117
+ indexRelPath: "index.html",
118
+ }),
119
+ milestoneId: data.milestones.find(m => m.status === "active")?.id ?? "manual",
120
+ milestoneTitle: data.milestones.find(m => m.status === "active")?.title ?? "",
121
+ kind: "manual",
122
+ projectName: projName,
123
+ projectPath: basePath,
124
+ gsdVersion,
125
+ totalCost: data.totals?.cost ?? 0,
126
+ totalTokens: data.totals?.tokens.total ?? 0,
127
+ totalDuration: data.totals?.duration ?? 0,
128
+ doneSlices,
129
+ totalSlices,
130
+ doneMilestones: data.milestones.filter(m => m.status === "complete").length,
131
+ totalMilestones: data.milestones.length,
132
+ phase: data.phase,
133
+ });
134
+ ctx.ui.notify(
135
+ `HTML report saved: .gsd/reports/${bn(outPath)}\nBrowse all reports: .gsd/reports/index.html`,
136
+ "success",
137
+ );
138
+ } catch (err) {
139
+ ctx.ui.notify(
140
+ `HTML export failed: ${err instanceof Error ? err.message : String(err)}`,
141
+ "error",
142
+ );
143
+ }
144
+ return;
145
+ }
146
+
99
147
  const format = args.includes("--json") ? "json" : "markdown";
100
148
 
101
149
  const ledger = getLedger();
@@ -53,6 +53,12 @@ export interface GitPreferences {
53
53
  * Default: true (planning docs are tracked in git).
54
54
  */
55
55
  commit_docs?: boolean;
56
+ /** When false, GSD will not modify .gitignore at all — no baseline patterns
57
+ * are added and no self-healing occurs. Use this if you manage your own
58
+ * .gitignore and don't want GSD touching it.
59
+ * Default: true (GSD ensures baseline patterns are present).
60
+ */
61
+ manage_gitignore?: boolean;
56
62
  /** Script to run after a worktree is created (#597).
57
63
  * Receives SOURCE_DIR and WORKTREE_DIR as environment variables.
58
64
  * Can be an absolute path or relative to the project root.
@@ -85,7 +85,10 @@ const BASELINE_PATTERNS = [
85
85
  * .gitignore instead of individual runtime patterns, keeping all GSD
86
86
  * artifacts local-only.
87
87
  */
88
- export function ensureGitignore(basePath: string, options?: { commitDocs?: boolean }): boolean {
88
+ export function ensureGitignore(basePath: string, options?: { commitDocs?: boolean; manageGitignore?: boolean }): boolean {
89
+ // If manage_gitignore is explicitly false, do not touch .gitignore at all
90
+ if (options?.manageGitignore === false) return false;
91
+
89
92
  const gitignorePath = join(basePath, ".gitignore");
90
93
  const commitDocs = options?.commitDocs !== false; // default true
91
94
 
@@ -10,7 +10,7 @@ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@g
10
10
  import { showNextAction } from "../shared/next-action-ui.js";
11
11
  import { loadFile, parseRoadmap } from "./files.js";
12
12
  import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
13
- import { deriveState } from "./state.js";
13
+ import { deriveState, invalidateStateCache } from "./state.js";
14
14
  import { startAuto } from "./auto.js";
15
15
  import { readCrashLock, clearLock, formatCrashInfo } from "./crash-recovery.js";
16
16
  import { listUnitRuntimeRecords, clearUnitRuntimeRecord } from "./unit-runtime.js";
@@ -959,10 +959,28 @@ export async function showDiscuss(
959
959
 
960
960
  // Loop: show picker, dispatch discuss, repeat until "not_yet"
961
961
  while (true) {
962
- const actions = pendingSlices.map((s, i) => {
963
- // Check if this slice has already been discussed (CONTEXT file exists)
962
+ // Build discussion-state map: which slices have CONTEXT files already?
963
+ const discussedMap = new Map<string, boolean>();
964
+ for (const s of pendingSlices) {
964
965
  const contextFile = resolveSliceFile(basePath, mid, s.id, "CONTEXT");
965
- const discussed = !!contextFile;
966
+ discussedMap.set(s.id, !!contextFile);
967
+ }
968
+
969
+ // If all pending slices are discussed, notify and exit instead of looping
970
+ const allDiscussed = pendingSlices.every(s => discussedMap.get(s.id));
971
+ if (allDiscussed) {
972
+ ctx.ui.notify(
973
+ `All ${pendingSlices.length} slices discussed. Run /gsd to start planning.`,
974
+ "info",
975
+ );
976
+ return;
977
+ }
978
+
979
+ // Find the first undiscussed slice to recommend
980
+ const firstUndiscussedId = pendingSlices.find(s => !discussedMap.get(s.id))?.id;
981
+
982
+ const actions = pendingSlices.map((s) => {
983
+ const discussed = discussedMap.get(s.id) ?? false;
966
984
  const statusParts: string[] = [];
967
985
  if (state.activeSlice?.id === s.id) statusParts.push("active");
968
986
  else statusParts.push("upcoming");
@@ -972,7 +990,7 @@ export async function showDiscuss(
972
990
  id: s.id,
973
991
  label: `${s.id}: ${s.title}`,
974
992
  description: statusParts.join(" · "),
975
- recommended: i === 0,
993
+ recommended: s.id === firstUndiscussedId,
976
994
  };
977
995
  });
978
996
 
@@ -996,6 +1014,7 @@ export async function showDiscuss(
996
1014
 
997
1015
  // Wait for the discuss session to finish, then loop back to the picker
998
1016
  await ctx.waitForIdle();
1017
+ invalidateStateCache();
999
1018
  }
1000
1019
  }
1001
1020
 
@@ -44,6 +44,7 @@ import {
44
44
  resolveAllSkillReferences,
45
45
  resolveModelWithFallbacksForUnit,
46
46
  getNextFallbackModel,
47
+ isTransientNetworkError,
47
48
  } from "./preferences.js";
48
49
  import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "./skill-discovery.js";
49
50
  import {
@@ -60,6 +61,7 @@ import { shortcutDesc } from "../shared/terminal.js";
60
61
  import { Text } from "@gsd/pi-tui";
61
62
  import { pauseAutoForProviderError } from "./provider-error-pause.js";
62
63
  import { toPosixPath } from "../shared/path-display.js";
64
+ import { isParallelActive, shutdownParallel } from "./parallel-orchestrator.js";
63
65
 
64
66
  // ── Agent Instructions ────────────────────────────────────────────────────
65
67
  // Lightweight "always follow" files injected into every GSD agent session.
@@ -92,6 +94,11 @@ function loadAgentInstructions(): string | null {
92
94
  // ── Depth verification state ──────────────────────────────────────────────
93
95
  let depthVerificationDone = false;
94
96
 
97
+ // ── Network error retry counters ──────────────────────────────────────────
98
+ // Tracks per-model retry attempts for transient network errors.
99
+ // Cleared when a model switch occurs or retries are exhausted.
100
+ const networkRetryCounters = new Map<string, number>();
101
+
95
102
  export function isDepthVerified(): boolean {
96
103
  return depthVerificationDone;
97
104
  }
@@ -727,6 +734,43 @@ export default function (pi: ExtensionAPI) {
727
734
  ? `: ${lastMsg.errorMessage}`
728
735
  : "";
729
736
 
737
+ const errorMsg = ("errorMessage" in lastMsg && lastMsg.errorMessage) ? String(lastMsg.errorMessage) : "";
738
+
739
+ // ── Transient network error retry ──────────────────────────────────
740
+ // Before falling back to a different model, retry the current model
741
+ // for transient network errors (connection reset, timeout, DNS, etc.).
742
+ // This prevents providers with occasional network flakiness from being
743
+ // immediately abandoned in favor of fallback models (#941).
744
+ if (isTransientNetworkError(errorMsg)) {
745
+ const currentModelId = ctx.model?.id ?? "unknown";
746
+ const retryKey = `network-retry:${currentModelId}`;
747
+ const maxRetries = 2;
748
+ const currentRetries = networkRetryCounters.get(retryKey) ?? 0;
749
+
750
+ if (currentRetries < maxRetries) {
751
+ networkRetryCounters.set(retryKey, currentRetries + 1);
752
+ const attempt = currentRetries + 1;
753
+ const delayMs = attempt * 3000; // 3s, 6s backoff
754
+ ctx.ui.notify(
755
+ `Network error on ${currentModelId}${errorDetail}. Retry ${attempt}/${maxRetries} in ${delayMs / 1000}s...`,
756
+ "warning",
757
+ );
758
+ setTimeout(() => {
759
+ pi.sendMessage(
760
+ { customType: "gsd-auto-timeout-recovery", content: "Continue execution — retrying after transient network error.", display: false },
761
+ { triggerTurn: true },
762
+ );
763
+ }, delayMs);
764
+ return;
765
+ }
766
+ // Retries exhausted — clear counter and fall through to fallback logic
767
+ networkRetryCounters.delete(retryKey);
768
+ ctx.ui.notify(
769
+ `Network retries exhausted for ${currentModelId}. Attempting model fallback.`,
770
+ "warning",
771
+ );
772
+ }
773
+
730
774
  const dash = getAutoDashboardData();
731
775
  if (dash.currentUnit) {
732
776
  const modelConfig = resolveModelWithFallbacksForUnit(dash.currentUnit.type);
@@ -737,6 +781,9 @@ export default function (pi: ExtensionAPI) {
737
781
  const nextModelId = getNextFallbackModel(currentModelId, modelConfig);
738
782
 
739
783
  if (nextModelId) {
784
+ // Clear any network retry counters when switching models
785
+ networkRetryCounters.clear();
786
+
740
787
  let modelToSet;
741
788
  const slashIdx = nextModelId.indexOf("/");
742
789
  if (slashIdx !== -1) {
@@ -771,7 +818,6 @@ export default function (pi: ExtensionAPI) {
771
818
  }
772
819
 
773
820
  // Detect rate-limit errors and extract retry delay for auto-resume
774
- const errorMsg = ("errorMessage" in lastMsg && lastMsg.errorMessage) ? String(lastMsg.errorMessage) : "";
775
821
  const isRateLimit = /rate.?limit|too many requests|429/i.test(errorMsg);
776
822
  const retryAfterMs = ("retryAfterMs" in lastMsg && typeof lastMsg.retryAfterMs === "number")
777
823
  ? lastMsg.retryAfterMs
@@ -791,6 +837,7 @@ export default function (pi: ExtensionAPI) {
791
837
  }
792
838
 
793
839
  try {
840
+ networkRetryCounters.clear(); // Clear network retry state on successful unit completion
794
841
  await handleAgentEnd(ctx, pi);
795
842
  } catch (err) {
796
843
  // Safety net: if handleAgentEnd throws despite its internal try-catch,
@@ -856,6 +903,12 @@ export default function (pi: ExtensionAPI) {
856
903
 
857
904
  // ── session_shutdown: save activity log on Ctrl+C / SIGTERM ─────────────
858
905
  pi.on("session_shutdown", async (_event, ctx: ExtensionContext) => {
906
+ if (isParallelActive()) {
907
+ try {
908
+ await shutdownParallel(process.cwd());
909
+ } catch { /* best-effort */ }
910
+ }
911
+
859
912
  if (!isAutoActive() && !isAutoPaused()) return;
860
913
 
861
914
  // Save the current session — the lock file stays on disk
@@ -249,18 +249,46 @@ export function nativeWorkingTreeStatus(basePath: string): string {
249
249
  return gitExec(basePath, ["status", "--porcelain"], true);
250
250
  }
251
251
 
252
+ // ─── nativeHasChanges fallback cache (10s TTL) ─────────────────────────
253
+ let _hasChangesCachedResult: boolean = false;
254
+ let _hasChangesCachedAt: number = 0;
255
+ let _hasChangesCachedPath: string = "";
256
+ const HAS_CHANGES_CACHE_TTL_MS = 10_000; // 10 seconds
257
+
252
258
  /**
253
259
  * Quick check: any staged or unstaged changes?
254
260
  * Native: libgit2 status check (single syscall).
255
- * Fallback: `git status --short`.
261
+ * Fallback: `git status --short` (cached for 10s per basePath).
256
262
  */
257
263
  export function nativeHasChanges(basePath: string): boolean {
258
264
  const native = loadNative();
259
265
  if (native) {
260
266
  return native.gitHasChanges(basePath);
261
267
  }
268
+
269
+ const now = Date.now();
270
+ if (
271
+ basePath === _hasChangesCachedPath &&
272
+ now - _hasChangesCachedAt < HAS_CHANGES_CACHE_TTL_MS
273
+ ) {
274
+ return _hasChangesCachedResult;
275
+ }
276
+
262
277
  const result = gitExec(basePath, ["status", "--short"], true);
263
- return result !== "";
278
+ const hasChanges = result !== "";
279
+
280
+ _hasChangesCachedResult = hasChanges;
281
+ _hasChangesCachedAt = now;
282
+ _hasChangesCachedPath = basePath;
283
+
284
+ return hasChanges;
285
+ }
286
+
287
+ /** Reset the nativeHasChanges fallback cache (exported for testing). */
288
+ export function _resetHasChangesCache(): void {
289
+ _hasChangesCachedResult = false;
290
+ _hasChangesCachedAt = 0;
291
+ _hasChangesCachedPath = "";
264
292
  }
265
293
 
266
294
  /**
@@ -298,6 +298,27 @@ export function validateTaskSummaryContent(file: string, content: string): Valid
298
298
  });
299
299
  }
300
300
 
301
+ const evidence = getSection(content, "Verification Evidence", 2);
302
+ if (!evidence) {
303
+ issues.push({
304
+ severity: "warning",
305
+ scope: "task-summary",
306
+ file,
307
+ ruleId: "evidence_block_missing",
308
+ message: "Task summary is missing `## Verification Evidence`.",
309
+ suggestion: "Add a verification evidence table showing gate check results (command, exit code, verdict, duration).",
310
+ });
311
+ } else if (sectionLooksPlaceholderOnly(evidence)) {
312
+ issues.push({
313
+ severity: "warning",
314
+ scope: "task-summary",
315
+ file,
316
+ ruleId: "evidence_block_placeholder",
317
+ message: "Task summary verification evidence section still looks like placeholder text.",
318
+ suggestion: "Replace placeholders with actual gate results or note that no verification commands were discovered.",
319
+ });
320
+ }
321
+
301
322
  return issues;
302
323
  }
303
324
 
@@ -8,7 +8,14 @@
8
8
  */
9
9
 
10
10
  import { spawn, type ChildProcess } from "node:child_process";
11
- import { existsSync } from "node:fs";
11
+ import {
12
+ existsSync,
13
+ writeFileSync,
14
+ readFileSync,
15
+ renameSync,
16
+ unlinkSync,
17
+ mkdirSync,
18
+ } from "node:fs";
12
19
  import { join, dirname } from "node:path";
13
20
  import { fileURLToPath } from "node:url";
14
21
  import { gsdRoot } from "./paths.js";
@@ -58,6 +65,142 @@ export interface OrchestratorState {
58
65
 
59
66
  let state: OrchestratorState | null = null;
60
67
 
68
+ // ─── Persistence ──────────────────────────────────────────────────────────
69
+
70
+ const ORCHESTRATOR_STATE_FILE = "orchestrator.json";
71
+ const TMP_SUFFIX = ".tmp";
72
+
73
+ export interface PersistedState {
74
+ active: boolean;
75
+ workers: Array<{
76
+ milestoneId: string;
77
+ title: string;
78
+ pid: number;
79
+ worktreePath: string;
80
+ startedAt: number;
81
+ state: "running" | "paused" | "stopped" | "error";
82
+ completedUnits: number;
83
+ cost: number;
84
+ }>;
85
+ totalCost: number;
86
+ startedAt: number;
87
+ configSnapshot: { max_workers: number; budget_ceiling?: number };
88
+ }
89
+
90
+ function stateFilePath(basePath: string): string {
91
+ return join(gsdRoot(basePath), ORCHESTRATOR_STATE_FILE);
92
+ }
93
+
94
+ /**
95
+ * Persist the current orchestrator state to .gsd/orchestrator.json.
96
+ * Uses atomic write (tmp + rename) to prevent partial reads.
97
+ */
98
+ export function persistState(basePath: string): void {
99
+ if (!state) return;
100
+ try {
101
+ const dir = gsdRoot(basePath);
102
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
103
+
104
+ const persisted: PersistedState = {
105
+ active: state.active,
106
+ workers: [...state.workers.values()].map((w) => ({
107
+ milestoneId: w.milestoneId,
108
+ title: w.title,
109
+ pid: w.pid,
110
+ worktreePath: w.worktreePath,
111
+ startedAt: w.startedAt,
112
+ state: w.state,
113
+ completedUnits: w.completedUnits,
114
+ cost: w.cost,
115
+ })),
116
+ totalCost: state.totalCost,
117
+ startedAt: state.startedAt,
118
+ configSnapshot: {
119
+ max_workers: state.config.max_workers,
120
+ budget_ceiling: state.config.budget_ceiling,
121
+ },
122
+ };
123
+
124
+ const dest = stateFilePath(basePath);
125
+ const tmp = dest + TMP_SUFFIX;
126
+ writeFileSync(tmp, JSON.stringify(persisted, null, 2), "utf-8");
127
+ renameSync(tmp, dest);
128
+ } catch { /* non-fatal */ }
129
+ }
130
+
131
+ /**
132
+ * Remove the persisted state file.
133
+ */
134
+ function removeStateFile(basePath: string): void {
135
+ try {
136
+ const p = stateFilePath(basePath);
137
+ if (existsSync(p)) unlinkSync(p);
138
+ } catch { /* non-fatal */ }
139
+ }
140
+
141
+ function isPidAlive(pid: number): boolean {
142
+ if (!Number.isInteger(pid) || pid <= 0) return false;
143
+ try {
144
+ process.kill(pid, 0);
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Restore orchestrator state from .gsd/orchestrator.json.
153
+ * Checks PID liveness for each worker:
154
+ * - Living PID → state "running", process stays null (no handle)
155
+ * - Dead PID → removed from restored state
156
+ * Returns null if no state file exists or no workers survive.
157
+ */
158
+ export function restoreState(basePath: string): PersistedState | null {
159
+ try {
160
+ const p = stateFilePath(basePath);
161
+ if (!existsSync(p)) return null;
162
+ const raw = readFileSync(p, "utf-8");
163
+ const persisted = JSON.parse(raw) as PersistedState;
164
+
165
+ // Filter to only workers with living PIDs
166
+ persisted.workers = persisted.workers.filter((w) => {
167
+ if (w.state === "stopped" || w.state === "error") return false;
168
+ return isPidAlive(w.pid);
169
+ });
170
+
171
+ if (persisted.workers.length === 0) {
172
+ // No surviving workers — clean up and return null
173
+ removeStateFile(basePath);
174
+ return null;
175
+ }
176
+
177
+ return persisted;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ async function waitForWorkerExit(worker: WorkerInfo, timeoutMs: number): Promise<boolean> {
184
+ if (worker.process) {
185
+ await new Promise<void>((resolve) => {
186
+ const done = () => resolve();
187
+ const timer = setTimeout(done, timeoutMs);
188
+ worker.process!.once("exit", () => {
189
+ clearTimeout(timer);
190
+ resolve();
191
+ });
192
+ });
193
+ return worker.process === null || !isPidAlive(worker.pid);
194
+ }
195
+
196
+ const startedAt = Date.now();
197
+ while (Date.now() - startedAt < timeoutMs) {
198
+ if (!isPidAlive(worker.pid)) return true;
199
+ await new Promise((resolve) => setTimeout(resolve, 50));
200
+ }
201
+ return !isPidAlive(worker.pid);
202
+ }
203
+
61
204
  // ─── Accessors ─────────────────────────────────────────────────────────────
62
205
 
63
206
  /** Returns true if the orchestrator is active and has been initialized. */
@@ -81,12 +224,26 @@ export function getWorkerStatuses(): WorkerInfo[] {
81
224
  /**
82
225
  * Analyze eligibility and prepare for parallel start.
83
226
  * Returns the candidates report without actually starting workers.
227
+ * Also detects orphaned sessions from prior crashes.
84
228
  */
85
229
  export async function prepareParallelStart(
86
230
  basePath: string,
87
231
  _prefs: GSDPreferences | undefined,
88
- ): Promise<ParallelCandidates> {
89
- return analyzeParallelEligibility(basePath);
232
+ ): Promise<ParallelCandidates & { orphans?: Array<{ milestoneId: string; pid: number; alive: boolean }> }> {
233
+ // Detect orphaned sessions before eligibility analysis
234
+ const sessions = readAllSessionStatuses(basePath);
235
+ const orphans: Array<{ milestoneId: string; pid: number; alive: boolean }> = [];
236
+ for (const session of sessions) {
237
+ const alive = isPidAlive(session.pid);
238
+ orphans.push({ milestoneId: session.milestoneId, pid: session.pid, alive });
239
+ if (!alive) {
240
+ // Clean up dead session
241
+ removeSessionStatus(basePath, session.milestoneId);
242
+ }
243
+ }
244
+
245
+ const candidates = await analyzeParallelEligibility(basePath);
246
+ return orphans.length > 0 ? { ...candidates, orphans } : candidates;
90
247
  }
91
248
 
92
249
  // ─── Start ─────────────────────────────────────────────────────────────────
@@ -106,6 +263,36 @@ export async function startParallel(
106
263
  }
107
264
 
108
265
  const config = resolveParallelConfig(prefs);
266
+
267
+ // Try to restore from a previous crash
268
+ const restored = restoreState(basePath);
269
+ if (restored && restored.workers.length > 0) {
270
+ // Adopt surviving workers instead of starting new ones
271
+ state = {
272
+ active: true,
273
+ workers: new Map(),
274
+ config,
275
+ totalCost: restored.totalCost,
276
+ startedAt: restored.startedAt,
277
+ };
278
+ const adopted: string[] = [];
279
+ for (const w of restored.workers) {
280
+ state.workers.set(w.milestoneId, {
281
+ milestoneId: w.milestoneId,
282
+ title: w.title,
283
+ pid: w.pid,
284
+ process: null, // no handle for adopted workers
285
+ worktreePath: w.worktreePath,
286
+ startedAt: w.startedAt,
287
+ state: "running",
288
+ completedUnits: w.completedUnits,
289
+ cost: w.cost,
290
+ });
291
+ adopted.push(w.milestoneId);
292
+ }
293
+ return { started: adopted, errors: [] };
294
+ }
295
+
109
296
  const now = Date.now();
110
297
 
111
298
  // Initialize orchestrator state
@@ -144,7 +331,7 @@ export async function startParallel(
144
331
  const worker: WorkerInfo = {
145
332
  milestoneId: mid,
146
333
  title: mid,
147
- pid: process.pid,
334
+ pid: 0, // placeholder — real PID set by spawnWorker()
148
335
  process: null,
149
336
  worktreePath: wtPath,
150
337
  startedAt: now,
@@ -155,28 +342,24 @@ export async function startParallel(
155
342
 
156
343
  state.workers.set(mid, worker);
157
344
 
158
- // Write initial session status
159
- const sessionStatus: SessionStatus = {
345
+ // Spawn BEFORE writing session status so the file gets the real worker PID.
346
+ const spawned = spawnWorker(basePath, mid);
347
+ if (!spawned) {
348
+ worker.state = "error";
349
+ }
350
+
351
+ // Write session status with real PID (or 0 if spawn failed)
352
+ writeSessionStatus(basePath, {
160
353
  milestoneId: mid,
161
354
  pid: worker.pid,
162
- state: "running",
355
+ state: worker.state,
163
356
  currentUnit: null,
164
357
  completedUnits: 0,
165
358
  cost: 0,
166
359
  lastHeartbeat: now,
167
360
  startedAt: now,
168
361
  worktreePath: wtPath,
169
- };
170
- writeSessionStatus(basePath, sessionStatus);
171
-
172
- // Attempt to spawn the worker process.
173
- // Spawning may fail if the CLI binary is not available (e.g., in tests).
174
- // The worker is still tracked and can be spawned later via spawnWorker().
175
- const spawned = spawnWorker(basePath, mid);
176
- if (!spawned) {
177
- // Worker tracked but not yet running a process.
178
- // State stays "running" so coordinator can retry or user can investigate.
179
- }
362
+ });
180
363
 
181
364
  started.push(mid);
182
365
  } catch (err) {
@@ -190,6 +373,9 @@ export async function startParallel(
190
373
  state.active = false;
191
374
  }
192
375
 
376
+ // Persist state for crash recovery
377
+ persistState(basePath);
378
+
193
379
  return { started, errors };
194
380
  }
195
381
 
@@ -323,7 +509,7 @@ export function spawnWorker(
323
509
  w.state = "error";
324
510
  }
325
511
 
326
- // Update session status
512
+ // Update session status and persist orchestrator state for crash recovery
327
513
  writeSessionStatus(basePath, {
328
514
  milestoneId,
329
515
  pid: w.pid,
@@ -335,6 +521,7 @@ export function spawnWorker(
335
521
  startedAt: w.startedAt,
336
522
  worktreePath: w.worktreePath,
337
523
  });
524
+ persistState(basePath);
338
525
  });
339
526
 
340
527
  return true;
@@ -485,12 +672,24 @@ export async function stopParallel(
485
672
  try {
486
673
  if (worker.process) {
487
674
  worker.process.kill("SIGTERM");
488
- } else {
675
+ } else if (worker.pid !== process.pid) {
489
676
  process.kill(worker.pid, "SIGTERM");
490
677
  }
491
678
  } catch { /* process may already be dead */ }
492
679
  }
493
680
 
681
+ const exitedAfterTerm = await waitForWorkerExit(worker, 750);
682
+ if (!exitedAfterTerm && worker.pid > 0) {
683
+ try {
684
+ if (worker.process) {
685
+ worker.process.kill("SIGKILL");
686
+ } else if (worker.pid !== process.pid) {
687
+ process.kill(worker.pid, "SIGKILL");
688
+ }
689
+ } catch { /* process may already be dead */ }
690
+ await waitForWorkerExit(worker, 250);
691
+ }
692
+
494
693
  // Update in-memory state
495
694
  worker.state = "stopped";
496
695
  worker.process = null;
@@ -503,6 +702,15 @@ export async function stopParallel(
503
702
  if (!milestoneId) {
504
703
  state.active = false;
505
704
  }
705
+
706
+ // Persist final state and clean up state file
707
+ removeStateFile(basePath);
708
+ }
709
+
710
+ export async function shutdownParallel(basePath: string): Promise<void> {
711
+ if (!state) return;
712
+ await stopParallel(basePath);
713
+ resetOrchestrator();
506
714
  }
507
715
 
508
716
  // ─── Pause / Resume ────────────────────────────────────────────────────────
@@ -589,6 +797,9 @@ export function refreshWorkerStatuses(basePath: string): void {
589
797
  for (const worker of state.workers.values()) {
590
798
  state.totalCost += worker.cost;
591
799
  }
800
+
801
+ // Persist updated state for crash recovery
802
+ persistState(basePath);
592
803
  }
593
804
 
594
805
  // ─── Budget ────────────────────────────────────────────────────────────────