gsd-pi 2.38.0-dev.bc2e21e → 2.38.0-dev.d533afb

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 (99) hide show
  1. package/dist/resource-loader.js +34 -1
  2. package/dist/resources/extensions/github-sync/cli.js +284 -0
  3. package/dist/resources/extensions/github-sync/index.js +73 -0
  4. package/dist/resources/extensions/github-sync/mapping.js +67 -0
  5. package/dist/resources/extensions/github-sync/sync.js +424 -0
  6. package/dist/resources/extensions/github-sync/templates.js +118 -0
  7. package/dist/resources/extensions/github-sync/types.js +7 -0
  8. package/dist/resources/extensions/gsd/auto/session.js +3 -23
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +1 -1
  10. package/dist/resources/extensions/gsd/auto-loop.js +292 -263
  11. package/dist/resources/extensions/gsd/auto-post-unit.js +28 -3
  12. package/dist/resources/extensions/gsd/auto-prompts.js +23 -43
  13. package/dist/resources/extensions/gsd/auto-start.js +7 -1
  14. package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
  15. package/dist/resources/extensions/gsd/auto.js +143 -80
  16. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  17. package/dist/resources/extensions/gsd/commands.js +2 -1
  18. package/dist/resources/extensions/gsd/context-budget.js +2 -10
  19. package/dist/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  20. package/dist/resources/extensions/gsd/doctor-providers.js +27 -11
  21. package/dist/resources/extensions/gsd/doctor.js +20 -1
  22. package/dist/resources/extensions/gsd/exit-command.js +2 -1
  23. package/dist/resources/extensions/gsd/files.js +4 -0
  24. package/dist/resources/extensions/gsd/git-service.js +15 -12
  25. package/dist/resources/extensions/gsd/guided-flow.js +82 -32
  26. package/dist/resources/extensions/gsd/index.js +22 -19
  27. package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
  28. package/dist/resources/extensions/gsd/preferences-models.js +0 -12
  29. package/dist/resources/extensions/gsd/preferences-types.js +1 -1
  30. package/dist/resources/extensions/gsd/preferences-validation.js +58 -10
  31. package/dist/resources/extensions/gsd/preferences.js +4 -2
  32. package/dist/resources/extensions/gsd/prompts/run-uat.md +2 -0
  33. package/dist/resources/extensions/gsd/repo-identity.js +19 -3
  34. package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
  35. package/dist/resources/extensions/mcp-client/index.js +14 -1
  36. package/package.json +1 -1
  37. package/packages/pi-ai/dist/utils/oauth/anthropic.js +2 -2
  38. package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +1 -1
  39. package/packages/pi-ai/src/utils/oauth/anthropic.ts +2 -2
  40. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  41. package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
  42. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  43. package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
  44. package/src/resources/extensions/github-sync/cli.ts +364 -0
  45. package/src/resources/extensions/github-sync/index.ts +93 -0
  46. package/src/resources/extensions/github-sync/mapping.ts +81 -0
  47. package/src/resources/extensions/github-sync/sync.ts +556 -0
  48. package/src/resources/extensions/github-sync/templates.ts +183 -0
  49. package/src/resources/extensions/github-sync/tests/cli.test.ts +20 -0
  50. package/src/resources/extensions/github-sync/tests/commit-linking.test.ts +39 -0
  51. package/src/resources/extensions/github-sync/tests/mapping.test.ts +104 -0
  52. package/src/resources/extensions/github-sync/tests/templates.test.ts +110 -0
  53. package/src/resources/extensions/github-sync/types.ts +47 -0
  54. package/src/resources/extensions/gsd/auto/session.ts +3 -25
  55. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -1
  56. package/src/resources/extensions/gsd/auto-loop.ts +382 -360
  57. package/src/resources/extensions/gsd/auto-post-unit.ts +29 -3
  58. package/src/resources/extensions/gsd/auto-prompts.ts +25 -45
  59. package/src/resources/extensions/gsd/auto-start.ts +11 -1
  60. package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
  61. package/src/resources/extensions/gsd/auto.ts +139 -86
  62. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  63. package/src/resources/extensions/gsd/commands.ts +2 -2
  64. package/src/resources/extensions/gsd/context-budget.ts +2 -12
  65. package/src/resources/extensions/gsd/docs/preferences-reference.md +0 -2
  66. package/src/resources/extensions/gsd/doctor-providers.ts +26 -9
  67. package/src/resources/extensions/gsd/doctor.ts +22 -1
  68. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  69. package/src/resources/extensions/gsd/files.ts +3 -1
  70. package/src/resources/extensions/gsd/git-service.ts +20 -10
  71. package/src/resources/extensions/gsd/guided-flow.ts +110 -38
  72. package/src/resources/extensions/gsd/index.ts +21 -16
  73. package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
  74. package/src/resources/extensions/gsd/preferences-models.ts +0 -12
  75. package/src/resources/extensions/gsd/preferences-types.ts +4 -4
  76. package/src/resources/extensions/gsd/preferences-validation.ts +50 -10
  77. package/src/resources/extensions/gsd/preferences.ts +3 -2
  78. package/src/resources/extensions/gsd/prompts/run-uat.md +2 -0
  79. package/src/resources/extensions/gsd/repo-identity.ts +20 -3
  80. package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
  81. package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +21 -18
  82. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +122 -68
  83. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +86 -3
  84. package/src/resources/extensions/gsd/tests/preferences.test.ts +2 -7
  85. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +21 -1
  86. package/src/resources/extensions/gsd/types.ts +0 -1
  87. package/src/resources/extensions/mcp-client/index.ts +17 -1
  88. package/dist/resources/extensions/gsd/prompt-compressor.js +0 -393
  89. package/dist/resources/extensions/gsd/semantic-chunker.js +0 -254
  90. package/dist/resources/extensions/gsd/summary-distiller.js +0 -212
  91. package/src/resources/extensions/gsd/prompt-compressor.ts +0 -508
  92. package/src/resources/extensions/gsd/semantic-chunker.ts +0 -336
  93. package/src/resources/extensions/gsd/summary-distiller.ts +0 -258
  94. package/src/resources/extensions/gsd/tests/context-compression.test.ts +0 -193
  95. package/src/resources/extensions/gsd/tests/prompt-compressor.test.ts +0 -529
  96. package/src/resources/extensions/gsd/tests/semantic-chunker.test.ts +0 -426
  97. package/src/resources/extensions/gsd/tests/summary-distiller.test.ts +0 -323
  98. package/src/resources/extensions/gsd/tests/token-optimization-benchmark.test.ts +0 -1272
  99. package/src/resources/extensions/gsd/tests/token-optimization-prefs.test.ts +0 -164
@@ -5,12 +5,16 @@
5
5
  * pattern with a while loop. The agent_end event resolves a promise instead
6
6
  * of recursing.
7
7
  *
8
- * MAINTENANCE RULE: The only module-level mutable state here is `_activeSession`,
9
- * used by the agent_end bridge. Promise state itself lives on AutoSession so
10
- * concurrent auto sessions cannot corrupt each other.
8
+ * MAINTENANCE RULE: Module-level mutable state is limited to `_currentResolve`
9
+ * (per-unit one-shot resolver) and `_sessionSwitchInFlight` (guard for
10
+ * session rotation). No queue stale agent_end events are dropped.
11
11
  */
12
+ import { importExtensionModule } from "@gsd/pi-coding-agent";
12
13
  import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js";
13
14
  import { debugLog } from "./debug-logger.js";
15
+ import { gsdRoot } from "./paths.js";
16
+ import { atomicWriteSync } from "./atomic-write.js";
17
+ import { join } from "node:path";
14
18
  /**
15
19
  * Maximum total loop iterations before forced stop. Prevents runaway loops
16
20
  * when units alternate IDs (bypassing the same-unit stuck detector).
@@ -18,79 +22,114 @@ import { debugLog } from "./debug-logger.js";
18
22
  * generous headroom including retries and sidecar work.
19
23
  */
20
24
  const MAX_LOOP_ITERATIONS = 500;
21
- /** Data-driven budget threshold notifications (75/80/90%). The 100% case is
22
- * handled inline because it requires break/pause/stop control flow. */
25
+ /** Maximum characters of failure/crash context included in recovery prompts. */
26
+ const MAX_RECOVERY_CHARS = 50_000;
27
+ /** Data-driven budget threshold notifications (descending). The 100% entry
28
+ * triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
29
+ * a simple notification. */
23
30
  const BUDGET_THRESHOLDS = [
31
+ { pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
24
32
  { pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
25
33
  { pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
26
34
  { pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
27
35
  ];
28
- // ─── Session-scoped promise state ───────────────────────────────────────────
36
+ // ─── Per-unit one-shot promise state ────────────────────────────────────────
29
37
  //
30
- // pendingResolve and pendingAgentEndQueue live on AutoSession (not module-level)
31
- // so concurrent sessions cannot corrupt each other's promises.
32
- /**
33
- * The singleton session reference used by resolveAgentEnd. Set by autoLoop
34
- * on entry so that the agent_end handler in index.ts can resolve the correct
35
- * session's promise without needing a direct reference to `s`.
36
- */
37
- let _activeSession = null;
38
+ // A single module-level resolve function scoped to the current unit execution.
39
+ // No queue if an agent_end arrives with no pending resolver, it is dropped
40
+ // (logged as warning). This is simpler and safer than the previous session-
41
+ // scoped pendingResolve + pendingAgentEndQueue pattern.
42
+ let _currentResolve = null;
43
+ let _sessionSwitchInFlight = false;
38
44
  // ─── resolveAgentEnd ─────────────────────────────────────────────────────────
39
45
  /**
40
46
  * Called from the agent_end event handler in index.ts to resolve the
41
47
  * in-flight unit promise. One-shot: the resolver is nulled before calling
42
48
  * to prevent double-resolution from model fallback retries.
43
49
  *
44
- * If no pendingResolve exists (event arrived between loop iterations),
45
- * the event is queued on the session so the next runUnit can drain it.
50
+ * If no resolver exists (event arrived between loop iterations or during
51
+ * session switch), the event is dropped with a debug warning.
46
52
  */
47
53
  export function resolveAgentEnd(event) {
48
- const s = _activeSession;
49
- if (!s) {
50
- debugLog("resolveAgentEnd", {
51
- status: "no-active-session",
52
- warning: "agent_end with no active loop session",
53
- });
54
+ if (_sessionSwitchInFlight) {
55
+ debugLog("resolveAgentEnd", { status: "ignored-during-switch" });
54
56
  return;
55
57
  }
56
- if (s.pendingResolve) {
58
+ if (_currentResolve) {
57
59
  debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true });
58
- const r = s.pendingResolve;
59
- s.pendingResolve = null;
60
+ const r = _currentResolve;
61
+ _currentResolve = null;
60
62
  r({ status: "completed", event });
61
63
  }
62
64
  else {
63
- // Queue the event so the next runUnit picks it up immediately
64
65
  debugLog("resolveAgentEnd", {
65
- status: "queued",
66
- queueLength: s.pendingAgentEndQueue.length + 1,
67
- unitId: s.currentUnit?.id,
68
- warning: "agent_end arrived between loop iterations — queued for next runUnit",
66
+ status: "no-pending-resolve",
67
+ warning: "agent_end with no pending unit",
69
68
  });
70
- s.pendingAgentEndQueue.push({ ...event, unitId: s.currentUnit?.id });
71
69
  }
72
70
  }
73
71
  export function isSessionSwitchInFlight() {
74
- return _activeSession?.sessionSwitchInFlight ?? false;
72
+ return _sessionSwitchInFlight;
75
73
  }
76
74
  // ─── resetPendingResolve (test helper) ───────────────────────────────────────
77
75
  /**
78
- * Reset session promise state. Only exported for test cleanup — production code
79
- * should never call this.
76
+ * Reset module-level promise state. Only exported for test cleanup —
77
+ * production code should never call this.
80
78
  */
81
79
  export function _resetPendingResolve() {
82
- if (_activeSession) {
83
- _activeSession.pendingResolve = null;
84
- _activeSession.pendingAgentEndQueue = [];
85
- }
86
- _activeSession = null;
80
+ _currentResolve = null;
81
+ _sessionSwitchInFlight = false;
87
82
  }
88
83
  /**
89
- * Set the active session for resolveAgentEnd. Only exported for test setup —
90
- * production code sets this via autoLoop entry.
84
+ * No-op for backward compatibility with tests that previously set the
85
+ * active session. The module no longer holds a session reference.
91
86
  */
92
- export function _setActiveSession(session) {
93
- _activeSession = session;
87
+ export function _setActiveSession(_session) {
88
+ // No-op — kept for test backward compatibility
89
+ }
90
+ /**
91
+ * Analyze a sliding window of recent unit dispatches for stuck patterns.
92
+ * Returns a signal with reason if stuck, null otherwise.
93
+ *
94
+ * Rule 1: Same error string twice in a row → stuck immediately.
95
+ * Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior).
96
+ * Rule 3: Oscillation A→B→A→B in last 4 entries → stuck.
97
+ */
98
+ export function detectStuck(window) {
99
+ if (window.length < 2)
100
+ return null;
101
+ const last = window[window.length - 1];
102
+ const prev = window[window.length - 2];
103
+ // Rule 1: Same error repeated consecutively
104
+ if (last.error && prev.error && last.error === prev.error) {
105
+ return {
106
+ stuck: true,
107
+ reason: `Same error repeated: ${last.error.slice(0, 200)}`,
108
+ };
109
+ }
110
+ // Rule 2: Same unit 3+ consecutive times
111
+ if (window.length >= 3) {
112
+ const lastThree = window.slice(-3);
113
+ if (lastThree.every((u) => u.key === last.key)) {
114
+ return {
115
+ stuck: true,
116
+ reason: `${last.key} derived 3 consecutive times without progress`,
117
+ };
118
+ }
119
+ }
120
+ // Rule 3: Oscillation (A→B→A→B in last 4)
121
+ if (window.length >= 4) {
122
+ const w = window.slice(-4);
123
+ if (w[0].key === w[2].key &&
124
+ w[1].key === w[3].key &&
125
+ w[0].key !== w[1].key) {
126
+ return {
127
+ stuck: true,
128
+ reason: `Oscillation detected: ${w[0].key} ↔ ${w[1].key}`,
129
+ };
130
+ }
131
+ }
132
+ return null;
94
133
  }
95
134
  // ─── runUnit ─────────────────────────────────────────────────────────────────
96
135
  /**
@@ -101,62 +140,16 @@ export function _setActiveSession(session) {
101
140
  * On session creation failure or timeout, returns { status: 'cancelled' }
102
141
  * without awaiting the promise.
103
142
  */
104
- export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
143
+ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
105
144
  debugLog("runUnit", { phase: "start", unitType, unitId });
106
- // ── Drain queued events from error-recovery retries ──
107
- // If an agent_end arrived between iterations (e.g. from a model fallback
108
- // sendMessage retry), consume it immediately instead of creating a new promise.
109
- // Cap queue to 3 entries to prevent unbounded growth from stale events.
110
- if (s.pendingAgentEndQueue.length > 3) {
111
- debugLog("runUnit", {
112
- phase: "queue-overflow",
113
- dropped: s.pendingAgentEndQueue.length - 1,
114
- unitType,
115
- unitId,
116
- });
117
- s.pendingAgentEndQueue = [
118
- s.pendingAgentEndQueue[s.pendingAgentEndQueue.length - 1],
119
- ];
120
- }
121
- if (s.pendingAgentEndQueue.length > 0) {
122
- // Find an event matching this unit; discard stale events from other units
123
- const matchIdx = s.pendingAgentEndQueue.findIndex((e) => !e.unitId || e.unitId === unitId);
124
- if (matchIdx >= 0) {
125
- // Discard any stale events before the match
126
- if (matchIdx > 0) {
127
- debugLog("runUnit", {
128
- phase: "discarded-stale-events",
129
- count: matchIdx,
130
- unitType,
131
- unitId,
132
- });
133
- }
134
- const queued = s.pendingAgentEndQueue.splice(0, matchIdx + 1).pop();
135
- debugLog("runUnit", {
136
- phase: "drained-queued-event",
137
- unitType,
138
- unitId,
139
- queueRemaining: s.pendingAgentEndQueue.length,
140
- });
141
- return { status: "completed", event: queued };
142
- }
143
- // No matching event — discard all stale events and proceed to new session
144
- debugLog("runUnit", {
145
- phase: "discarded-all-stale-events",
146
- count: s.pendingAgentEndQueue.length,
147
- unitType,
148
- unitId,
149
- });
150
- s.pendingAgentEndQueue = [];
151
- }
152
145
  // ── Session creation with timeout ──
153
146
  debugLog("runUnit", { phase: "session-create", unitType, unitId });
154
147
  let sessionResult;
155
148
  let sessionTimeoutHandle;
156
- s.sessionSwitchInFlight = true;
149
+ _sessionSwitchInFlight = true;
157
150
  try {
158
151
  const sessionPromise = s.cmdCtx.newSession().finally(() => {
159
- s.sessionSwitchInFlight = false;
152
+ _sessionSwitchInFlight = false;
160
153
  });
161
154
  const timeoutPromise = new Promise((resolve) => {
162
155
  sessionTimeoutHandle = setTimeout(() => resolve({ cancelled: true }), NEW_SESSION_TIMEOUT_MS);
@@ -184,11 +177,12 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
184
177
  if (!s.active) {
185
178
  return { status: "cancelled" };
186
179
  }
187
- // ── Create the agent_end promise (session-scoped) ──
180
+ // ── Create the agent_end promise (per-unit one-shot) ──
188
181
  // This happens after newSession completes so session-switch agent_end events
189
182
  // from the previous session cannot resolve the new unit.
183
+ _sessionSwitchInFlight = false;
190
184
  const unitPromise = new Promise((resolve) => {
191
- s.pendingResolve = resolve;
185
+ _currentResolve = resolve;
192
186
  });
193
187
  // Ensure cwd matches basePath before dispatch (#1389).
194
188
  // async_bash and background jobs can drift cwd away from the worktree.
@@ -213,6 +207,60 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
213
207
  });
214
208
  return result;
215
209
  }
210
+ // ─── generateMilestoneReport ──────────────────────────────────────────────────
211
+ /**
212
+ * Generate and write an HTML milestone report snapshot.
213
+ * Extracted from the milestone-transition block in autoLoop.
214
+ */
215
+ async function generateMilestoneReport(s, ctx, milestoneId) {
216
+ const { loadVisualizerData } = await importExtensionModule(import.meta.url, "./visualizer-data.js");
217
+ const { generateHtmlReport } = await importExtensionModule(import.meta.url, "./export-html.js");
218
+ const { writeReportSnapshot } = await importExtensionModule(import.meta.url, "./reports.js");
219
+ const { basename } = await import("node:path");
220
+ const snapData = await loadVisualizerData(s.basePath);
221
+ const completedMs = snapData.milestones.find((m) => m.id === milestoneId);
222
+ const msTitle = completedMs?.title ?? milestoneId;
223
+ const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
224
+ const projName = basename(s.basePath);
225
+ const doneSlices = snapData.milestones.reduce((acc, m) => acc + m.slices.filter((sl) => sl.done).length, 0);
226
+ const totalSlices = snapData.milestones.reduce((acc, m) => acc + m.slices.length, 0);
227
+ const outPath = writeReportSnapshot({
228
+ basePath: s.basePath,
229
+ html: generateHtmlReport(snapData, {
230
+ projectName: projName,
231
+ projectPath: s.basePath,
232
+ gsdVersion,
233
+ milestoneId,
234
+ indexRelPath: "index.html",
235
+ }),
236
+ milestoneId,
237
+ milestoneTitle: msTitle,
238
+ kind: "milestone",
239
+ projectName: projName,
240
+ projectPath: s.basePath,
241
+ gsdVersion,
242
+ totalCost: snapData.totals?.cost ?? 0,
243
+ totalTokens: snapData.totals?.tokens.total ?? 0,
244
+ totalDuration: snapData.totals?.duration ?? 0,
245
+ doneSlices,
246
+ totalSlices,
247
+ doneMilestones: snapData.milestones.filter((m) => m.status === "complete").length,
248
+ totalMilestones: snapData.milestones.length,
249
+ phase: snapData.phase,
250
+ });
251
+ ctx.ui.notify(`Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`, "info");
252
+ }
253
+ // ─── closeoutAndStop ──────────────────────────────────────────────────────────
254
+ /**
255
+ * If a unit is in-flight, close it out, then stop auto-mode.
256
+ * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop.
257
+ */
258
+ async function closeoutAndStop(ctx, pi, s, deps, reason) {
259
+ if (s.currentUnit) {
260
+ await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
261
+ }
262
+ await deps.stopAuto(ctx, pi, reason);
263
+ }
216
264
  // ─── autoLoop ────────────────────────────────────────────────────────────────
217
265
  /**
218
266
  * Main auto-mode execution loop. Iterates: derive → dispatch → guards →
@@ -224,10 +272,11 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, _prefs) {
224
272
  */
225
273
  export async function autoLoop(ctx, pi, s, deps) {
226
274
  debugLog("autoLoop", { phase: "enter" });
227
- _activeSession = s;
228
275
  let iteration = 0;
229
- let lastDerivedUnit = "";
230
- let sameUnitCount = 0;
276
+ // ── Sliding-window stuck detection ──
277
+ const recentUnits = [];
278
+ const STUCK_WINDOW_SIZE = 6;
279
+ let stuckRecoveryAttempts = 0;
231
280
  let consecutiveErrors = 0;
232
281
  while (s.active) {
233
282
  iteration++;
@@ -247,6 +296,7 @@ export async function autoLoop(ctx, pi, s, deps) {
247
296
  }
248
297
  try {
249
298
  // ── Blanket try/catch: one bad iteration must not kill the session
299
+ const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
250
300
  const sessionLockBase = deps.lockBase();
251
301
  if (sessionLockBase) {
252
302
  const lockStatus = deps.validateSessionLock(sessionLockBase);
@@ -301,7 +351,7 @@ export async function autoLoop(ctx, pi, s, deps) {
301
351
  }
302
352
  // Derive state
303
353
  let state = await deps.deriveState(s.basePath);
304
- deps.syncCmuxSidebar(deps.loadEffectiveGSDPreferences()?.preferences, state);
354
+ deps.syncCmuxSidebar(prefs, state);
305
355
  let mid = state.activeMilestone?.id;
306
356
  let midTitle = state.activeMilestone?.title;
307
357
  debugLog("autoLoop", {
@@ -314,50 +364,14 @@ export async function autoLoop(ctx, pi, s, deps) {
314
364
  if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
315
365
  ctx.ui.notify(`Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, "info");
316
366
  deps.sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone");
317
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, "success");
318
- const vizPrefs = deps.loadEffectiveGSDPreferences()?.preferences;
367
+ deps.logCmuxEvent(prefs, `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, "success");
368
+ const vizPrefs = prefs;
319
369
  if (vizPrefs?.auto_visualize) {
320
370
  ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
321
371
  }
322
372
  if (vizPrefs?.auto_report !== false) {
323
373
  try {
324
- const { loadVisualizerData } = await import("./visualizer-data.js");
325
- const { generateHtmlReport } = await import("./export-html.js");
326
- const { writeReportSnapshot } = await import("./reports.js");
327
- const { basename } = await import("node:path");
328
- const snapData = await loadVisualizerData(s.basePath);
329
- const completedMs = snapData.milestones.find((m) => m.id === s.currentMilestoneId);
330
- const msTitle = completedMs?.title ?? s.currentMilestoneId;
331
- const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
332
- const projName = basename(s.basePath);
333
- const doneSlices = snapData.milestones.reduce((acc, m) => acc +
334
- m.slices.filter((sl) => sl.done).length, 0);
335
- const totalSlices = snapData.milestones.reduce((acc, m) => acc + m.slices.length, 0);
336
- const outPath = writeReportSnapshot({
337
- basePath: s.basePath,
338
- html: generateHtmlReport(snapData, {
339
- projectName: projName,
340
- projectPath: s.basePath,
341
- gsdVersion,
342
- milestoneId: s.currentMilestoneId,
343
- indexRelPath: "index.html",
344
- }),
345
- milestoneId: s.currentMilestoneId,
346
- milestoneTitle: msTitle,
347
- kind: "milestone",
348
- projectName: projName,
349
- projectPath: s.basePath,
350
- gsdVersion,
351
- totalCost: snapData.totals?.cost ?? 0,
352
- totalTokens: snapData.totals?.tokens.total ?? 0,
353
- totalDuration: snapData.totals?.duration ?? 0,
354
- doneSlices,
355
- totalSlices,
356
- doneMilestones: snapData.milestones.filter((m) => m.status === "complete").length,
357
- totalMilestones: snapData.milestones.length,
358
- phase: snapData.phase,
359
- });
360
- ctx.ui.notify(`Report saved: .gsd/reports/${(await import("node:path")).basename(outPath)} — open index.html to browse progression.`, "info");
374
+ await generateMilestoneReport(s, ctx, s.currentMilestoneId);
361
375
  }
362
376
  catch (err) {
363
377
  ctx.ui.notify(`Report generation failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
@@ -367,8 +381,8 @@ export async function autoLoop(ctx, pi, s, deps) {
367
381
  s.unitDispatchCount.clear();
368
382
  s.unitRecoveryCount.clear();
369
383
  s.unitLifetimeDispatches.clear();
370
- lastDerivedUnit = "";
371
- sameUnitCount = 0;
384
+ recentUnits.length = 0;
385
+ stuckRecoveryAttempts = 0;
372
386
  // Worktree lifecycle on milestone transition — merge current, enter next
373
387
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
374
388
  deps.invalidateAllCaches();
@@ -378,8 +392,7 @@ export async function autoLoop(ctx, pi, s, deps) {
378
392
  if (mid) {
379
393
  if (deps.getIsolationMode() !== "none") {
380
394
  deps.captureIntegrationBranch(s.basePath, mid, {
381
- commitDocs: deps.loadEffectiveGSDPreferences()?.preferences?.git
382
- ?.commit_docs,
395
+ commitDocs: prefs?.git?.commit_docs,
383
396
  });
384
397
  }
385
398
  deps.resolver.enterMilestone(mid, ctx.ui);
@@ -408,7 +421,7 @@ export async function autoLoop(ctx, pi, s, deps) {
408
421
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
409
422
  }
410
423
  deps.sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
411
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, "All milestones complete.", "success");
424
+ deps.logCmuxEvent(prefs, "All milestones complete.", "success");
412
425
  await deps.stopAuto(ctx, pi, "All milestones complete");
413
426
  }
414
427
  else if (incomplete.length === 0 && state.registry.length === 0) {
@@ -422,7 +435,7 @@ export async function autoLoop(ctx, pi, s, deps) {
422
435
  await deps.stopAuto(ctx, pi, blockerMsg);
423
436
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
424
437
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
425
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
438
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
426
439
  }
427
440
  else {
428
441
  const ids = incomplete.map((m) => m.id).join(", ");
@@ -445,13 +458,10 @@ export async function autoLoop(ctx, pi, s, deps) {
445
458
  midTitle = state.activeMilestone?.title;
446
459
  }
447
460
  if (!mid || !midTitle) {
448
- if (s.currentUnit) {
449
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
450
- }
451
461
  const noMilestoneReason = !mid
452
462
  ? "No active milestone after merge reconciliation"
453
463
  : `Milestone ${mid} has no title after reconciliation`;
454
- await deps.stopAuto(ctx, pi, noMilestoneReason);
464
+ await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
455
465
  debugLog("autoLoop", {
456
466
  phase: "exit",
457
467
  reason: "no-milestone-after-reconciliation",
@@ -460,34 +470,27 @@ export async function autoLoop(ctx, pi, s, deps) {
460
470
  }
461
471
  // Terminal: complete
462
472
  if (state.phase === "complete") {
463
- if (s.currentUnit) {
464
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
465
- }
466
- // Milestone merge on complete
473
+ // Milestone merge on complete (before closeout so branch state is clean)
467
474
  if (s.currentMilestoneId) {
468
475
  deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
469
476
  }
470
477
  deps.sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
471
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, `Milestone ${mid} complete.`, "success");
472
- await deps.stopAuto(ctx, pi, `Milestone ${mid} complete`);
478
+ deps.logCmuxEvent(prefs, `Milestone ${mid} complete.`, "success");
479
+ await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
473
480
  debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
474
481
  break;
475
482
  }
476
483
  // Terminal: blocked
477
484
  if (state.phase === "blocked") {
478
- if (s.currentUnit) {
479
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
480
- }
481
485
  const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
482
- await deps.stopAuto(ctx, pi, blockerMsg);
486
+ await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
483
487
  ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
484
488
  deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
485
- deps.logCmuxEvent(deps.loadEffectiveGSDPreferences()?.preferences, blockerMsg, "error");
489
+ deps.logCmuxEvent(prefs, blockerMsg, "error");
486
490
  debugLog("autoLoop", { phase: "exit", reason: "blocked" });
487
491
  break;
488
492
  }
489
493
  // ── Phase 2: Guards ─────────────────────────────────────────────────
490
- const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
491
494
  // Budget ceiling guard
492
495
  const budgetCeiling = prefs?.budget_ceiling;
493
496
  if (budgetCeiling !== undefined && budgetCeiling > 0) {
@@ -500,42 +503,42 @@ export async function autoLoop(ctx, pi, s, deps) {
500
503
  const newBudgetAlertLevel = deps.getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct);
501
504
  const enforcement = prefs?.budget_enforcement ?? "pause";
502
505
  const budgetEnforcementAction = deps.getBudgetEnforcementAction(enforcement, budgetPct);
503
- if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
504
- const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
506
+ // Data-driven threshold check loop descending, fire first match
507
+ const threshold = BUDGET_THRESHOLDS.find((t) => newBudgetAlertLevel >= t.pct);
508
+ if (threshold) {
505
509
  s.lastBudgetAlertLevel =
506
510
  newBudgetAlertLevel;
507
- if (budgetEnforcementAction === "halt") {
508
- deps.sendDesktopNotification("GSD", msg, "error", "budget");
509
- await deps.stopAuto(ctx, pi, "Budget ceiling reached");
510
- debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
511
- break;
512
- }
513
- if (budgetEnforcementAction === "pause") {
514
- ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning");
511
+ if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
512
+ // 100% special enforcement logic (halt/pause/warn)
513
+ const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
514
+ if (budgetEnforcementAction === "halt") {
515
+ deps.sendDesktopNotification("GSD", msg, "error", "budget");
516
+ await deps.stopAuto(ctx, pi, "Budget ceiling reached");
517
+ debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
518
+ break;
519
+ }
520
+ if (budgetEnforcementAction === "pause") {
521
+ ctx.ui.notify(`${msg} Pausing auto-mode — /gsd auto to override and continue.`, "warning");
522
+ deps.sendDesktopNotification("GSD", msg, "warning", "budget");
523
+ deps.logCmuxEvent(prefs, msg, "warning");
524
+ await deps.pauseAuto(ctx, pi);
525
+ debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
526
+ break;
527
+ }
528
+ ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
515
529
  deps.sendDesktopNotification("GSD", msg, "warning", "budget");
516
530
  deps.logCmuxEvent(prefs, msg, "warning");
517
- await deps.pauseAuto(ctx, pi);
518
- debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
519
- break;
520
531
  }
521
- ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
522
- deps.sendDesktopNotification("GSD", msg, "warning", "budget");
523
- deps.logCmuxEvent(prefs, msg, "warning");
524
- }
525
- else {
526
- // Data-driven 75/80/90% threshold notifications
527
- const threshold = BUDGET_THRESHOLDS.find((t) => newBudgetAlertLevel === t.pct);
528
- if (threshold) {
529
- s.lastBudgetAlertLevel =
530
- newBudgetAlertLevel;
532
+ else if (threshold.pct < 100) {
533
+ // Sub-100% simple notification
531
534
  const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
532
535
  ctx.ui.notify(msg, threshold.notifyLevel);
533
536
  deps.sendDesktopNotification("GSD", msg, threshold.notifyLevel, "budget");
534
537
  deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
535
538
  }
536
- else if (budgetAlertLevel === 0) {
537
- s.lastBudgetAlertLevel = 0;
538
- }
539
+ }
540
+ else if (budgetAlertLevel === 0) {
541
+ s.lastBudgetAlertLevel = 0;
539
542
  }
540
543
  }
541
544
  else {
@@ -586,10 +589,7 @@ export async function autoLoop(ctx, pi, s, deps) {
586
589
  session: s,
587
590
  });
588
591
  if (dispatchResult.action === "stop") {
589
- if (s.currentUnit) {
590
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
591
- }
592
- await deps.stopAuto(ctx, pi, dispatchResult.reason);
592
+ await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
593
593
  debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
594
594
  break;
595
595
  }
@@ -602,55 +602,62 @@ export async function autoLoop(ctx, pi, s, deps) {
602
602
  let unitId = dispatchResult.unitId;
603
603
  let prompt = dispatchResult.prompt;
604
604
  const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
605
- // ── Same-unit stuck counter with graduated recovery ──
605
+ // ── Sliding-window stuck detection with graduated recovery ──
606
606
  const derivedKey = `${unitType}/${unitId}`;
607
- if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) {
608
- sameUnitCount++;
609
- debugLog("autoLoop", {
610
- phase: "stuck-check",
611
- unitType,
612
- unitId,
613
- sameUnitCount,
614
- });
615
- if (sameUnitCount === 3) {
616
- // Level 1: try verifying the artifact — maybe it was written but not detected
617
- const artifactExists = deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
618
- if (artifactExists) {
619
- debugLog("autoLoop", {
620
- phase: "stuck-recovery",
621
- level: 1,
622
- action: "artifact-found",
623
- });
624
- ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
625
- deps.invalidateAllCaches();
626
- continue;
627
- }
628
- ctx.ui.notify(`Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`, "warning");
629
- deps.invalidateAllCaches();
630
- }
631
- else if (sameUnitCount === 5) {
632
- // Level 2: hard stop — genuinely stuck
607
+ if (!s.pendingVerificationRetry) {
608
+ recentUnits.push({ key: derivedKey });
609
+ if (recentUnits.length > STUCK_WINDOW_SIZE)
610
+ recentUnits.shift();
611
+ const stuckSignal = detectStuck(recentUnits);
612
+ if (stuckSignal) {
633
613
  debugLog("autoLoop", {
634
- phase: "stuck-detected",
614
+ phase: "stuck-check",
635
615
  unitType,
636
616
  unitId,
637
- sameUnitCount,
617
+ reason: stuckSignal.reason,
618
+ recoveryAttempts: stuckRecoveryAttempts,
638
619
  });
639
- await deps.stopAuto(ctx, pi, `Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`);
640
- ctx.ui.notify(`Stuck on ${unitType} ${unitId} deriveState returns the same unit after ${sameUnitCount} attempts. The expected artifact was not written.`, "error");
641
- break;
620
+ if (stuckRecoveryAttempts === 0) {
621
+ // Level 1: try verifying the artifact, then cache invalidation + retry
622
+ stuckRecoveryAttempts++;
623
+ const artifactExists = deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
624
+ if (artifactExists) {
625
+ debugLog("autoLoop", {
626
+ phase: "stuck-recovery",
627
+ level: 1,
628
+ action: "artifact-found",
629
+ });
630
+ ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
631
+ deps.invalidateAllCaches();
632
+ continue;
633
+ }
634
+ ctx.ui.notify(`Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`, "warning");
635
+ deps.invalidateAllCaches();
636
+ }
637
+ else {
638
+ // Level 2: hard stop — genuinely stuck
639
+ debugLog("autoLoop", {
640
+ phase: "stuck-detected",
641
+ unitType,
642
+ unitId,
643
+ reason: stuckSignal.reason,
644
+ });
645
+ await deps.stopAuto(ctx, pi, `Stuck: ${stuckSignal.reason}`);
646
+ ctx.ui.notify(`Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}. The expected artifact was not written.`, "error");
647
+ break;
648
+ }
642
649
  }
643
- }
644
- else {
645
- if (derivedKey !== lastDerivedUnit) {
646
- debugLog("autoLoop", {
647
- phase: "stuck-counter-reset",
648
- from: lastDerivedUnit,
649
- to: derivedKey,
650
- });
650
+ else {
651
+ // Progress detected — reset recovery counter
652
+ if (stuckRecoveryAttempts > 0) {
653
+ debugLog("autoLoop", {
654
+ phase: "stuck-counter-reset",
655
+ from: recentUnits[recentUnits.length - 2]?.key ?? "",
656
+ to: derivedKey,
657
+ });
658
+ stuckRecoveryAttempts = 0;
659
+ }
651
660
  }
652
- lastDerivedUnit = derivedKey;
653
- sameUnitCount = 0;
654
661
  }
655
662
  // Pre-dispatch hooks
656
663
  const preDispatchResult = deps.runPreDispatchHooks(unitType, unitId, prompt, s.basePath);
@@ -689,33 +696,6 @@ export async function autoLoop(ctx, pi, s, deps) {
689
696
  s.currentUnit.type === unitType &&
690
697
  s.currentUnit.id === unitId);
691
698
  const previousTier = s.currentUnitRouting?.tier;
692
- // Closeout previous unit
693
- if (s.currentUnit) {
694
- await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
695
- if (s.currentUnitRouting) {
696
- const isRetry = s.currentUnit.type === unitType && s.currentUnit.id === unitId;
697
- deps.recordOutcome(s.currentUnit.type, s.currentUnitRouting.tier, !isRetry);
698
- }
699
- const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
700
- const incomingKey = `${unitType}/${unitId}`;
701
- const isHookUnit = s.currentUnit.type.startsWith("hook/");
702
- const artifactVerified = isHookUnit ||
703
- deps.verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath);
704
- if (closeoutKey !== incomingKey && artifactVerified) {
705
- s.completedUnits.push({
706
- type: s.currentUnit.type,
707
- id: s.currentUnit.id,
708
- startedAt: s.currentUnit.startedAt,
709
- finishedAt: Date.now(),
710
- });
711
- if (s.completedUnits.length > 200) {
712
- s.completedUnits = s.completedUnits.slice(-200);
713
- }
714
- deps.clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id);
715
- s.unitDispatchCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
716
- s.unitRecoveryCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
717
- }
718
- }
719
699
  s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
720
700
  deps.captureAvailableSkills();
721
701
  deps.writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
@@ -733,7 +713,6 @@ export async function autoLoop(ctx, pi, s, deps) {
733
713
  deps.updateProgressWidget(ctx, unitType, unitId, state);
734
714
  deps.ensurePreconditions(unitType, unitId, s.basePath, state);
735
715
  // Prompt injection
736
- const MAX_RECOVERY_CHARS = 50_000;
737
716
  let finalPrompt = prompt;
738
717
  if (s.pendingVerificationRetry) {
739
718
  const retryCtx = s.pendingVerificationRetry;
@@ -771,7 +750,7 @@ export async function autoLoop(ctx, pi, s, deps) {
771
750
  s.lastBaselineCharCount = undefined;
772
751
  if (deps.isDbAvailable()) {
773
752
  try {
774
- const { inlineGsdRootFile } = await import("./auto-prompts.js");
753
+ const { inlineGsdRootFile } = await importExtensionModule(import.meta.url, "./auto-prompts.js");
775
754
  const [decisionsContent, requirementsContent, projectContent] = await Promise.all([
776
755
  inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
777
756
  inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
@@ -821,7 +800,7 @@ export async function autoLoop(ctx, pi, s, deps) {
821
800
  unitType,
822
801
  unitId,
823
802
  });
824
- const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt, prefs);
803
+ const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt);
825
804
  debugLog("autoLoop", {
826
805
  phase: "runUnit-end",
827
806
  iteration,
@@ -829,12 +808,60 @@ export async function autoLoop(ctx, pi, s, deps) {
829
808
  unitId,
830
809
  status: unitResult.status,
831
810
  });
811
+ // Tag the most recent window entry with error info for stuck detection
812
+ if (unitResult.status === "error" || unitResult.status === "cancelled") {
813
+ const lastEntry = recentUnits[recentUnits.length - 1];
814
+ if (lastEntry) {
815
+ lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`;
816
+ }
817
+ }
818
+ else if (unitResult.event?.messages?.length) {
819
+ const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1];
820
+ const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
821
+ if (/error|fail|exception/i.test(msgStr)) {
822
+ const lastEntry = recentUnits[recentUnits.length - 1];
823
+ if (lastEntry) {
824
+ lastEntry.error = msgStr.slice(0, 200);
825
+ }
826
+ }
827
+ }
832
828
  if (unitResult.status === "cancelled") {
833
829
  ctx.ui.notify(`Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`, "warning");
834
830
  await deps.stopAuto(ctx, pi, "Session creation failed");
835
831
  debugLog("autoLoop", { phase: "exit", reason: "session-failed" });
836
832
  break;
837
833
  }
834
+ // ── Immediate unit closeout (metrics, activity log, memory) ────────
835
+ // Run right after runUnit() returns so telemetry is never lost to a
836
+ // crash between iterations.
837
+ await deps.closeoutUnit(ctx, s.basePath, unitType, unitId, s.currentUnit.startedAt, deps.buildSnapshotOpts(unitType, unitId));
838
+ if (s.currentUnitRouting) {
839
+ deps.recordOutcome(unitType, s.currentUnitRouting.tier, true);
840
+ }
841
+ const isHookUnit = unitType.startsWith("hook/");
842
+ const artifactVerified = isHookUnit ||
843
+ deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
844
+ if (artifactVerified) {
845
+ s.completedUnits.push({
846
+ type: unitType,
847
+ id: unitId,
848
+ startedAt: s.currentUnit.startedAt,
849
+ finishedAt: Date.now(),
850
+ });
851
+ if (s.completedUnits.length > 200) {
852
+ s.completedUnits = s.completedUnits.slice(-200);
853
+ }
854
+ // Flush completed-units to disk so the record survives crashes
855
+ try {
856
+ const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
857
+ const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`);
858
+ atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
859
+ }
860
+ catch { /* non-fatal: disk flush failure */ }
861
+ deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId);
862
+ s.unitDispatchCount.delete(`${unitType}/${unitId}`);
863
+ s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
864
+ }
838
865
  // ── Phase 5: Finalize ───────────────────────────────────────────────
839
866
  debugLog("autoLoop", { phase: "finalize", iteration });
840
867
  // Clear unit timeout (unit completed)
@@ -935,7 +962,7 @@ export async function autoLoop(ctx, pi, s, deps) {
935
962
  const sidecarSessionFile = deps.getSessionFile(ctx);
936
963
  deps.writeLock(deps.lockBase(), item.unitType, item.unitId, s.completedUnits.length, sidecarSessionFile);
937
964
  // Execute via standard runUnit
938
- const sidecarResult = await runUnit(ctx, pi, s, item.unitType, item.unitId, item.prompt, prefs);
965
+ const sidecarResult = await runUnit(ctx, pi, s, item.unitType, item.unitId, item.prompt);
939
966
  deps.clearUnitTimeout();
940
967
  if (sidecarResult.status === "cancelled") {
941
968
  ctx.ui.notify(`Sidecar unit ${item.unitType} ${item.unitId} session cancelled. Stopping.`, "warning");
@@ -943,6 +970,8 @@ export async function autoLoop(ctx, pi, s, deps) {
943
970
  sidecarBroke = true;
944
971
  break;
945
972
  }
973
+ // Immediate closeout for sidecar unit
974
+ await deps.closeoutUnit(ctx, s.basePath, item.unitType, item.unitId, sidecarStartedAt, deps.buildSnapshotOpts(item.unitType, item.unitId));
946
975
  // Run pre-verification for the sidecar unit (lightweight path)
947
976
  const sidecarPreOpts = item.kind === "hook"
948
977
  ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
@@ -1020,6 +1049,6 @@ export async function autoLoop(ctx, pi, s, deps) {
1020
1049
  }
1021
1050
  }
1022
1051
  }
1023
- _activeSession = null;
1052
+ _currentResolve = null;
1024
1053
  debugLog("autoLoop", { phase: "exit", totalIterations: iteration });
1025
1054
  }