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
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Timeout recovery logic for auto-mode units.
3
+ * Handles idle and hard timeout recovery with escalation, steering messages,
4
+ * and blocker placeholder generation.
5
+ */
6
+
7
+ import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
8
+ import {
9
+ readUnitRuntimeRecord,
10
+ writeUnitRuntimeRecord,
11
+ formatExecuteTaskRecoveryStatus,
12
+ inspectExecuteTaskDurability,
13
+ } from "./unit-runtime.js";
14
+ import {
15
+ resolveExpectedArtifactPath,
16
+ diagnoseExpectedArtifact,
17
+ skipExecuteTask,
18
+ writeBlockerPlaceholder,
19
+ } from "./auto-recovery.js";
20
+ import { existsSync } from "node:fs";
21
+
22
+ export interface RecoveryContext {
23
+ basePath: string;
24
+ verbose: boolean;
25
+ currentUnitStartedAt: number;
26
+ unitRecoveryCount: Map<string, number>;
27
+ dispatchNextUnit: (ctx: ExtensionContext, pi: ExtensionAPI) => Promise<void>;
28
+ }
29
+
30
+ export async function recoverTimedOutUnit(
31
+ ctx: ExtensionContext,
32
+ pi: ExtensionAPI,
33
+ unitType: string,
34
+ unitId: string,
35
+ reason: "idle" | "hard",
36
+ rctx: RecoveryContext,
37
+ ): Promise<"recovered" | "paused"> {
38
+ const { basePath, verbose, currentUnitStartedAt, unitRecoveryCount, dispatchNextUnit } = rctx;
39
+
40
+ const runtime = readUnitRuntimeRecord(basePath, unitType, unitId);
41
+ const recoveryAttempts = runtime?.recoveryAttempts ?? 0;
42
+ const maxRecoveryAttempts = reason === "idle" ? 2 : 1;
43
+
44
+ const recoveryKey = `${unitType}/${unitId}`;
45
+ const attemptNumber = (unitRecoveryCount.get(recoveryKey) ?? 0) + 1;
46
+ unitRecoveryCount.set(recoveryKey, attemptNumber);
47
+
48
+ if (attemptNumber > 1) {
49
+ // Exponential backoff: 2^(n-1) seconds, capped at 30s
50
+ const backoffMs = Math.min(1000 * Math.pow(2, attemptNumber - 2), 30000);
51
+ ctx.ui.notify(
52
+ `Recovery attempt ${attemptNumber} for ${unitType} ${unitId}. Waiting ${backoffMs / 1000}s before retry.`,
53
+ "info",
54
+ );
55
+ await new Promise(r => setTimeout(r, backoffMs));
56
+ }
57
+
58
+ if (unitType === "execute-task") {
59
+ const status = await inspectExecuteTaskDurability(basePath, unitId);
60
+ if (!status) return "paused";
61
+
62
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
63
+ recovery: status,
64
+ });
65
+
66
+ const durableComplete = status.summaryExists && status.taskChecked && status.nextActionAdvanced;
67
+ if (durableComplete) {
68
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
69
+ phase: "finalized",
70
+ recovery: status,
71
+ });
72
+ ctx.ui.notify(
73
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode. (attempt ${attemptNumber})`,
74
+ "info",
75
+ );
76
+ unitRecoveryCount.delete(recoveryKey);
77
+ await dispatchNextUnit(ctx, pi);
78
+ return "recovered";
79
+ }
80
+
81
+ if (recoveryAttempts < maxRecoveryAttempts) {
82
+ const isEscalation = recoveryAttempts > 0;
83
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
84
+ phase: "recovered",
85
+ recovery: status,
86
+ recoveryAttempts: recoveryAttempts + 1,
87
+ lastRecoveryReason: reason,
88
+ lastProgressAt: Date.now(),
89
+ progressCount: (runtime?.progressCount ?? 0) + 1,
90
+ lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
91
+ });
92
+
93
+ const steeringLines = isEscalation
94
+ ? [
95
+ `**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before this task is skipped.**`,
96
+ `You are still executing ${unitType} ${unitId}.`,
97
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
98
+ `Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
99
+ "You MUST finish the durable output NOW, even if incomplete.",
100
+ "Write the task summary with whatever you have accomplished so far.",
101
+ "Mark the task [x] in the plan. Commit your work.",
102
+ "A partial summary is infinitely better than no summary.",
103
+ ]
104
+ : [
105
+ `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — do not stop.**`,
106
+ `You are still executing ${unitType} ${unitId}.`,
107
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
108
+ `Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
109
+ "Do not keep exploring.",
110
+ "Immediately finish the required durable output for this unit.",
111
+ "If full completion is impossible, write the partial artifact/state needed for recovery and make the blocker explicit.",
112
+ ];
113
+
114
+ pi.sendMessage(
115
+ {
116
+ customType: "gsd-auto-timeout-recovery",
117
+ display: verbose,
118
+ content: steeringLines.join("\n"),
119
+ },
120
+ { triggerTurn: true, deliverAs: "steer" },
121
+ );
122
+ ctx.ui.notify(
123
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to finish durable output (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
124
+ "warning",
125
+ );
126
+ return "recovered";
127
+ }
128
+
129
+ // Retries exhausted — write missing durable artifacts and advance.
130
+ const diagnostic = formatExecuteTaskRecoveryStatus(status);
131
+ const [mid, sid, tid] = unitId.split("/");
132
+ const skipped = mid && sid && tid
133
+ ? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
134
+ : false;
135
+
136
+ if (skipped) {
137
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
138
+ phase: "skipped",
139
+ recovery: status,
140
+ recoveryAttempts: recoveryAttempts + 1,
141
+ lastRecoveryReason: reason,
142
+ });
143
+ ctx.ui.notify(
144
+ `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts (${diagnostic}). Blocker artifacts written. Advancing pipeline. (attempt ${attemptNumber})`,
145
+ "warning",
146
+ );
147
+ unitRecoveryCount.delete(recoveryKey);
148
+ await dispatchNextUnit(ctx, pi);
149
+ return "recovered";
150
+ }
151
+
152
+ // Fallback: couldn't write skip artifacts — pause as before.
153
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
154
+ phase: "paused",
155
+ recovery: status,
156
+ recoveryAttempts: recoveryAttempts + 1,
157
+ lastRecoveryReason: reason,
158
+ });
159
+ ctx.ui.notify(
160
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery check for ${unitType} ${unitId}: ${diagnostic}`,
161
+ "warning",
162
+ );
163
+ return "paused";
164
+ }
165
+
166
+ const expected = diagnoseExpectedArtifact(unitType, unitId, basePath) ?? "required durable artifact";
167
+
168
+ // Check if the artifact already exists on disk — agent may have written it
169
+ // without signaling completion.
170
+ const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath);
171
+ if (artifactPath && existsSync(artifactPath)) {
172
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
173
+ phase: "finalized",
174
+ recoveryAttempts: recoveryAttempts + 1,
175
+ lastRecoveryReason: reason,
176
+ });
177
+ ctx.ui.notify(
178
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} artifact already exists on disk. Advancing. (attempt ${attemptNumber})`,
179
+ "info",
180
+ );
181
+ unitRecoveryCount.delete(recoveryKey);
182
+ await dispatchNextUnit(ctx, pi);
183
+ return "recovered";
184
+ }
185
+
186
+ if (recoveryAttempts < maxRecoveryAttempts) {
187
+ const isEscalation = recoveryAttempts > 0;
188
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
189
+ phase: "recovered",
190
+ recoveryAttempts: recoveryAttempts + 1,
191
+ lastRecoveryReason: reason,
192
+ lastProgressAt: Date.now(),
193
+ progressCount: (runtime?.progressCount ?? 0) + 1,
194
+ lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
195
+ });
196
+
197
+ const steeringLines = isEscalation
198
+ ? [
199
+ `**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before skip.**`,
200
+ `You are still executing ${unitType} ${unitId}.`,
201
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts} — next failure skips this unit.`,
202
+ `Expected durable output: ${expected}.`,
203
+ "You MUST write the artifact file NOW, even if incomplete.",
204
+ "Write whatever you have — partial research, preliminary findings, best-effort analysis.",
205
+ "A partial artifact is infinitely better than no artifact.",
206
+ "If you are truly blocked, write the file with a BLOCKER section explaining why.",
207
+ ]
208
+ : [
209
+ `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — stay in auto-mode.**`,
210
+ `You are still executing ${unitType} ${unitId}.`,
211
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
212
+ `Expected durable output: ${expected}.`,
213
+ "Stop broad exploration.",
214
+ "Write the required artifact now.",
215
+ "If blocked, write the partial artifact and explicitly record the blocker instead of going silent.",
216
+ ];
217
+
218
+ pi.sendMessage(
219
+ {
220
+ customType: "gsd-auto-timeout-recovery",
221
+ display: verbose,
222
+ content: steeringLines.join("\n"),
223
+ },
224
+ { triggerTurn: true, deliverAs: "steer" },
225
+ );
226
+ ctx.ui.notify(
227
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to produce ${expected} (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
228
+ "warning",
229
+ );
230
+ return "recovered";
231
+ }
232
+
233
+ // Retries exhausted — write a blocker placeholder and advance the pipeline
234
+ // instead of silently stalling.
235
+ const placeholder = writeBlockerPlaceholder(
236
+ unitType, unitId, basePath,
237
+ `${reason} recovery exhausted ${maxRecoveryAttempts} attempts without producing the artifact.`,
238
+ );
239
+
240
+ if (placeholder) {
241
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
242
+ phase: "skipped",
243
+ recoveryAttempts: recoveryAttempts + 1,
244
+ lastRecoveryReason: reason,
245
+ });
246
+ ctx.ui.notify(
247
+ `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts. Blocker placeholder written to ${placeholder}. Advancing pipeline. (attempt ${attemptNumber})`,
248
+ "warning",
249
+ );
250
+ unitRecoveryCount.delete(recoveryKey);
251
+ await dispatchNextUnit(ctx, pi);
252
+ return "recovered";
253
+ }
254
+
255
+ // Fallback: couldn't resolve artifact path — pause as before.
256
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
257
+ phase: "paused",
258
+ recoveryAttempts: recoveryAttempts + 1,
259
+ lastRecoveryReason: reason,
260
+ });
261
+ return "paused";
262
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * In-flight tool call tracking for auto-mode idle detection.
3
+ * Tracks which tool calls are currently executing so the idle watchdog
4
+ * can distinguish "waiting for tool completion" from "truly idle".
5
+ */
6
+
7
+ const inFlightTools = new Map<string, number>();
8
+
9
+ /**
10
+ * Mark a tool execution as in-flight.
11
+ * Records start time so the idle watchdog can detect tools hung longer than the idle timeout.
12
+ */
13
+ export function markToolStart(toolCallId: string, isActive: boolean): void {
14
+ if (!isActive) return;
15
+ inFlightTools.set(toolCallId, Date.now());
16
+ }
17
+
18
+ /**
19
+ * Mark a tool execution as completed.
20
+ */
21
+ export function markToolEnd(toolCallId: string): void {
22
+ inFlightTools.delete(toolCallId);
23
+ }
24
+
25
+ /**
26
+ * Returns the age (ms) of the oldest currently in-flight tool, or 0 if none.
27
+ */
28
+ export function getOldestInFlightToolAgeMs(): number {
29
+ if (inFlightTools.size === 0) return 0;
30
+ const oldestStart = Math.min(...inFlightTools.values());
31
+ return Date.now() - oldestStart;
32
+ }
33
+
34
+ /**
35
+ * Returns the number of currently in-flight tools.
36
+ */
37
+ export function getInFlightToolCount(): number {
38
+ return inFlightTools.size;
39
+ }
40
+
41
+ /**
42
+ * Returns the start timestamp of the oldest in-flight tool, or undefined if none.
43
+ */
44
+ export function getOldestInFlightToolStart(): number | undefined {
45
+ if (inFlightTools.size === 0) return undefined;
46
+ return Math.min(...inFlightTools.values());
47
+ }
48
+
49
+ /**
50
+ * Clear all in-flight tool tracking state.
51
+ */
52
+ export function clearInFlightTools(): void {
53
+ inFlightTools.clear();
54
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Unit closeout helper — consolidates the repeated pattern of
3
+ * snapshotting metrics + saving activity log + extracting memories
4
+ * that appears 6+ times in auto.ts.
5
+ */
6
+
7
+ import type { ExtensionContext } from "@gsd/pi-coding-agent";
8
+ import { snapshotUnitMetrics } from "./metrics.js";
9
+ import { saveActivityLog } from "./activity-log.js";
10
+
11
+ export interface CloseoutOptions {
12
+ promptCharCount?: number;
13
+ baselineCharCount?: number;
14
+ tier?: string;
15
+ modelDowngraded?: boolean;
16
+ continueHereFired?: boolean;
17
+ }
18
+
19
+ /**
20
+ * Snapshot metrics, save activity log, and fire-and-forget memory extraction
21
+ * for a completed unit. Returns the activity log file path (if any).
22
+ */
23
+ export async function closeoutUnit(
24
+ ctx: ExtensionContext,
25
+ basePath: string,
26
+ unitType: string,
27
+ unitId: string,
28
+ startedAt: number,
29
+ opts?: CloseoutOptions,
30
+ ): Promise<string | undefined> {
31
+ const modelId = ctx.model?.id ?? "unknown";
32
+ snapshotUnitMetrics(ctx, unitType, unitId, startedAt, modelId, opts);
33
+ const activityFile = saveActivityLog(ctx, basePath, unitType, unitId);
34
+
35
+ if (activityFile) {
36
+ try {
37
+ const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
38
+ const llmCallFn = buildMemoryLLMCall(ctx);
39
+ if (llmCallFn) {
40
+ extractMemoriesFromUnit(activityFile, unitType, unitId, llmCallFn).catch(() => {});
41
+ }
42
+ } catch { /* non-fatal */ }
43
+ }
44
+
45
+ return activityFile ?? undefined;
46
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Worktree ↔ project root state synchronization for auto-mode.
3
+ *
4
+ * When auto-mode runs inside a worktree, dispatch-critical state files
5
+ * (.gsd/ metadata) diverge between the worktree (where work happens)
6
+ * and the project root (where startAutoMode reads initial state on restart).
7
+ * Without syncing, restarting auto-mode reads stale state from the project
8
+ * root and re-dispatches already-completed units.
9
+ *
10
+ * Also contains resource staleness detection and stale worktree escape.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, unlinkSync, readdirSync } from "node:fs";
14
+ import { join, sep as pathSep } from "node:path";
15
+ import { homedir } from "node:os";
16
+
17
+ // ─── Project Root → Worktree Sync ─────────────────────────────────────────
18
+
19
+ /**
20
+ * Sync milestone artifacts from project root INTO worktree before deriveState.
21
+ * Covers the case where the LLM wrote artifacts to the main repo filesystem
22
+ * (e.g. via absolute paths) but the worktree has stale data. Also deletes
23
+ * gsd.db in the worktree so it rebuilds from fresh disk state (#853).
24
+ * Non-fatal — sync failure should never block dispatch.
25
+ */
26
+ export function syncProjectRootToWorktree(projectRoot: string, worktreePath: string, milestoneId: string | null): void {
27
+ if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
28
+ if (!milestoneId) return;
29
+
30
+ const prGsd = join(projectRoot, ".gsd");
31
+ const wtGsd = join(worktreePath, ".gsd");
32
+
33
+ // Copy milestone directory from project root to worktree if the project root
34
+ // has newer artifacts (e.g. slices that don't exist in the worktree yet)
35
+ try {
36
+ const srcMilestone = join(prGsd, "milestones", milestoneId);
37
+ const dstMilestone = join(wtGsd, "milestones", milestoneId);
38
+ if (existsSync(srcMilestone)) {
39
+ mkdirSync(dstMilestone, { recursive: true });
40
+ cpSync(srcMilestone, dstMilestone, { recursive: true, force: false });
41
+ }
42
+ } catch { /* non-fatal */ }
43
+
44
+ // Delete worktree gsd.db so it rebuilds from the freshly synced files.
45
+ // Stale DB rows are the root cause of the infinite skip loop (#853).
46
+ try {
47
+ const wtDb = join(wtGsd, "gsd.db");
48
+ if (existsSync(wtDb)) {
49
+ unlinkSync(wtDb);
50
+ }
51
+ } catch { /* non-fatal */ }
52
+ }
53
+
54
+ // ─── Worktree → Project Root Sync ─────────────────────────────────────────
55
+
56
+ /**
57
+ * Sync dispatch-critical .gsd/ state files from worktree to project root.
58
+ * Only runs when inside an auto-worktree (worktreePath differs from projectRoot).
59
+ * Copies: STATE.md + active milestone directory (roadmap, slice plans, task summaries).
60
+ * Non-fatal — sync failure should never block dispatch.
61
+ */
62
+ export function syncStateToProjectRoot(worktreePath: string, projectRoot: string, milestoneId: string | null): void {
63
+ if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
64
+ if (!milestoneId) return;
65
+
66
+ const wtGsd = join(worktreePath, ".gsd");
67
+ const prGsd = join(projectRoot, ".gsd");
68
+
69
+ // 1. STATE.md — the quick-glance status used by initial deriveState()
70
+ try {
71
+ const src = join(wtGsd, "STATE.md");
72
+ const dst = join(prGsd, "STATE.md");
73
+ if (existsSync(src)) cpSync(src, dst, { force: true });
74
+ } catch { /* non-fatal */ }
75
+
76
+ // 2. Milestone directory — ROADMAP, slice PLANs, task summaries
77
+ // Copy the entire milestone .gsd subtree so deriveState reads current checkboxes
78
+ try {
79
+ const srcMilestone = join(wtGsd, "milestones", milestoneId);
80
+ const dstMilestone = join(prGsd, "milestones", milestoneId);
81
+ if (existsSync(srcMilestone)) {
82
+ mkdirSync(dstMilestone, { recursive: true });
83
+ cpSync(srcMilestone, dstMilestone, { recursive: true, force: true });
84
+ }
85
+ } catch { /* non-fatal */ }
86
+
87
+ // 3. Merge completed-units.json (set-union of both locations)
88
+ // Prevents already-completed units from being re-dispatched after crash/restart.
89
+ const srcKeysFile = join(wtGsd, "completed-units.json");
90
+ const dstKeysFile = join(prGsd, "completed-units.json");
91
+ if (existsSync(srcKeysFile)) {
92
+ try {
93
+ const srcKeys: string[] = JSON.parse(readFileSync(srcKeysFile, "utf8"));
94
+ let dstKeys: string[] = [];
95
+ if (existsSync(dstKeysFile)) {
96
+ try { dstKeys = JSON.parse(readFileSync(dstKeysFile, "utf8")); } catch { /* ignore corrupt dst */ }
97
+ }
98
+ const merged = [...new Set([...dstKeys, ...srcKeys])];
99
+ writeFileSync(dstKeysFile, JSON.stringify(merged, null, 2));
100
+ } catch { /* non-fatal */ }
101
+ }
102
+
103
+ // 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords().
104
+ // Without this, a crash during a unit leaves the runtime record only in the
105
+ // worktree. If the next session resolves basePath before worktree re-entry,
106
+ // selfHeal can't find or clear the stale record (#769).
107
+ try {
108
+ const srcRuntime = join(wtGsd, "runtime", "units");
109
+ const dstRuntime = join(prGsd, "runtime", "units");
110
+ if (existsSync(srcRuntime)) {
111
+ mkdirSync(dstRuntime, { recursive: true });
112
+ cpSync(srcRuntime, dstRuntime, { recursive: true, force: true });
113
+ }
114
+ } catch { /* non-fatal */ }
115
+ }
116
+
117
+ // ─── Resource Staleness ───────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Read the resource version (semver) from the managed-resources manifest.
121
+ * Uses gsdVersion instead of syncedAt so that launching a second session
122
+ * doesn't falsely trigger staleness (#804).
123
+ */
124
+ export function readResourceVersion(): string | null {
125
+ const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
126
+ const manifestPath = join(agentDir, "managed-resources.json");
127
+ try {
128
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
129
+ return typeof manifest?.gsdVersion === "string" ? manifest.gsdVersion : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Check if managed resources have been updated since session start.
137
+ * Returns a warning message if stale, null otherwise.
138
+ */
139
+ export function checkResourcesStale(versionOnStart: string | null): string | null {
140
+ if (versionOnStart === null) return null;
141
+ const current = readResourceVersion();
142
+ if (current === null) return null;
143
+ if (current !== versionOnStart) {
144
+ return "GSD resources were updated since this session started. Restart gsd to load the new code.";
145
+ }
146
+ return null;
147
+ }
148
+
149
+ // ─── Stale Worktree Escape ────────────────────────────────────────────────
150
+
151
+ /**
152
+ * Detect and escape a stale worktree cwd (#608).
153
+ *
154
+ * After milestone completion + merge, the worktree directory is removed but
155
+ * the process cwd may still point inside `.gsd/worktrees/<MID>/`.
156
+ * When a new session starts, `process.cwd()` is passed as `base` to startAuto
157
+ * and all subsequent writes land in the wrong directory. This function detects
158
+ * that scenario and chdir back to the project root.
159
+ *
160
+ * Returns the corrected base path.
161
+ */
162
+ export function escapeStaleWorktree(base: string): string {
163
+ const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
164
+ const idx = base.indexOf(marker);
165
+ if (idx === -1) return base;
166
+
167
+ // base is inside .gsd/worktrees/<something> — extract the project root
168
+ const projectRoot = base.slice(0, idx);
169
+ try {
170
+ process.chdir(projectRoot);
171
+ } catch {
172
+ // If chdir fails, return the original — caller will handle errors downstream
173
+ return base;
174
+ }
175
+ return projectRoot;
176
+ }
177
+
178
+ /**
179
+ * Clean stale runtime unit files for completed milestones.
180
+ *
181
+ * After restart, stale runtime/units/*.json from prior milestones can
182
+ * cause deriveState to resume the wrong milestone (#887). Removes files
183
+ * for milestones that have a SUMMARY (fully complete).
184
+ */
185
+ export function cleanStaleRuntimeUnits(
186
+ gsdRootPath: string,
187
+ hasMilestoneSummary: (mid: string) => boolean,
188
+ ): number {
189
+ const runtimeUnitsDir = join(gsdRootPath, "runtime", "units");
190
+ if (!existsSync(runtimeUnitsDir)) return 0;
191
+
192
+ let cleaned = 0;
193
+ try {
194
+ for (const file of readdirSync(runtimeUnitsDir)) {
195
+ if (!file.endsWith(".json")) continue;
196
+ const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/);
197
+ if (!midMatch) continue;
198
+ if (hasMilestoneSummary(midMatch[1])) {
199
+ try {
200
+ unlinkSync(join(runtimeUnitsDir, file));
201
+ cleaned++;
202
+ } catch { /* non-fatal */ }
203
+ }
204
+ }
205
+ } catch { /* non-fatal */ }
206
+ return cleaned;
207
+ }