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.
- package/dist/resource-loader.js +34 -1
- package/dist/resources/extensions/github-sync/cli.js +284 -0
- package/dist/resources/extensions/github-sync/index.js +73 -0
- package/dist/resources/extensions/github-sync/mapping.js +67 -0
- package/dist/resources/extensions/github-sync/sync.js +424 -0
- package/dist/resources/extensions/github-sync/templates.js +118 -0
- package/dist/resources/extensions/github-sync/types.js +7 -0
- package/dist/resources/extensions/gsd/auto/session.js +3 -23
- package/dist/resources/extensions/gsd/auto-dispatch.js +1 -1
- package/dist/resources/extensions/gsd/auto-loop.js +292 -263
- package/dist/resources/extensions/gsd/auto-post-unit.js +28 -3
- package/dist/resources/extensions/gsd/auto-prompts.js +23 -43
- package/dist/resources/extensions/gsd/auto-start.js +7 -1
- package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
- package/dist/resources/extensions/gsd/auto.js +143 -80
- package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
- package/dist/resources/extensions/gsd/commands.js +2 -1
- package/dist/resources/extensions/gsd/context-budget.js +2 -10
- package/dist/resources/extensions/gsd/docs/preferences-reference.md +0 -2
- package/dist/resources/extensions/gsd/doctor-providers.js +27 -11
- package/dist/resources/extensions/gsd/doctor.js +20 -1
- package/dist/resources/extensions/gsd/exit-command.js +2 -1
- package/dist/resources/extensions/gsd/files.js +4 -0
- package/dist/resources/extensions/gsd/git-service.js +15 -12
- package/dist/resources/extensions/gsd/guided-flow.js +82 -32
- package/dist/resources/extensions/gsd/index.js +22 -19
- package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
- package/dist/resources/extensions/gsd/preferences-models.js +0 -12
- package/dist/resources/extensions/gsd/preferences-types.js +1 -1
- package/dist/resources/extensions/gsd/preferences-validation.js +58 -10
- package/dist/resources/extensions/gsd/preferences.js +4 -2
- package/dist/resources/extensions/gsd/prompts/run-uat.md +2 -0
- package/dist/resources/extensions/gsd/repo-identity.js +19 -3
- package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
- package/dist/resources/extensions/mcp-client/index.js +14 -1
- package/package.json +1 -1
- package/packages/pi-ai/dist/utils/oauth/anthropic.js +2 -2
- package/packages/pi-ai/dist/utils/oauth/anthropic.js.map +1 -1
- package/packages/pi-ai/src/utils/oauth/anthropic.ts +2 -2
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
- package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
- package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
- package/src/resources/extensions/github-sync/cli.ts +364 -0
- package/src/resources/extensions/github-sync/index.ts +93 -0
- package/src/resources/extensions/github-sync/mapping.ts +81 -0
- package/src/resources/extensions/github-sync/sync.ts +556 -0
- package/src/resources/extensions/github-sync/templates.ts +183 -0
- package/src/resources/extensions/github-sync/tests/cli.test.ts +20 -0
- package/src/resources/extensions/github-sync/tests/commit-linking.test.ts +39 -0
- package/src/resources/extensions/github-sync/tests/mapping.test.ts +104 -0
- package/src/resources/extensions/github-sync/tests/templates.test.ts +110 -0
- package/src/resources/extensions/github-sync/types.ts +47 -0
- package/src/resources/extensions/gsd/auto/session.ts +3 -25
- package/src/resources/extensions/gsd/auto-dispatch.ts +1 -1
- package/src/resources/extensions/gsd/auto-loop.ts +382 -360
- package/src/resources/extensions/gsd/auto-post-unit.ts +29 -3
- package/src/resources/extensions/gsd/auto-prompts.ts +25 -45
- package/src/resources/extensions/gsd/auto-start.ts +11 -1
- package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
- package/src/resources/extensions/gsd/auto.ts +139 -86
- package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
- package/src/resources/extensions/gsd/commands.ts +2 -2
- package/src/resources/extensions/gsd/context-budget.ts +2 -12
- package/src/resources/extensions/gsd/docs/preferences-reference.md +0 -2
- package/src/resources/extensions/gsd/doctor-providers.ts +26 -9
- package/src/resources/extensions/gsd/doctor.ts +22 -1
- package/src/resources/extensions/gsd/exit-command.ts +2 -2
- package/src/resources/extensions/gsd/files.ts +3 -1
- package/src/resources/extensions/gsd/git-service.ts +20 -10
- package/src/resources/extensions/gsd/guided-flow.ts +110 -38
- package/src/resources/extensions/gsd/index.ts +21 -16
- package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
- package/src/resources/extensions/gsd/preferences-models.ts +0 -12
- package/src/resources/extensions/gsd/preferences-types.ts +4 -4
- package/src/resources/extensions/gsd/preferences-validation.ts +50 -10
- package/src/resources/extensions/gsd/preferences.ts +3 -2
- package/src/resources/extensions/gsd/prompts/run-uat.md +2 -0
- package/src/resources/extensions/gsd/repo-identity.ts +20 -3
- package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
- package/src/resources/extensions/gsd/tests/agent-end-retry.test.ts +21 -18
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +122 -68
- package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +86 -3
- package/src/resources/extensions/gsd/tests/preferences.test.ts +2 -7
- package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +21 -1
- package/src/resources/extensions/gsd/types.ts +0 -1
- package/src/resources/extensions/mcp-client/index.ts +17 -1
- package/dist/resources/extensions/gsd/prompt-compressor.js +0 -393
- package/dist/resources/extensions/gsd/semantic-chunker.js +0 -254
- package/dist/resources/extensions/gsd/summary-distiller.js +0 -212
- package/src/resources/extensions/gsd/prompt-compressor.ts +0 -508
- package/src/resources/extensions/gsd/semantic-chunker.ts +0 -336
- package/src/resources/extensions/gsd/summary-distiller.ts +0 -258
- package/src/resources/extensions/gsd/tests/context-compression.test.ts +0 -193
- package/src/resources/extensions/gsd/tests/prompt-compressor.test.ts +0 -529
- package/src/resources/extensions/gsd/tests/semantic-chunker.test.ts +0 -426
- package/src/resources/extensions/gsd/tests/summary-distiller.test.ts +0 -323
- package/src/resources/extensions/gsd/tests/token-optimization-benchmark.test.ts +0 -1272
- 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:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
22
|
-
|
|
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
|
-
// ───
|
|
36
|
+
// ─── Per-unit one-shot promise state ────────────────────────────────────────
|
|
29
37
|
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
45
|
-
* the event is
|
|
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
|
-
|
|
49
|
-
|
|
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 (
|
|
58
|
+
if (_currentResolve) {
|
|
57
59
|
debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true });
|
|
58
|
-
const r =
|
|
59
|
-
|
|
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: "
|
|
66
|
-
|
|
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
|
|
72
|
+
return _sessionSwitchInFlight;
|
|
75
73
|
}
|
|
76
74
|
// ─── resetPendingResolve (test helper) ───────────────────────────────────────
|
|
77
75
|
/**
|
|
78
|
-
* Reset
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
_activeSession.pendingAgentEndQueue = [];
|
|
85
|
-
}
|
|
86
|
-
_activeSession = null;
|
|
80
|
+
_currentResolve = null;
|
|
81
|
+
_sessionSwitchInFlight = false;
|
|
87
82
|
}
|
|
88
83
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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(
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
149
|
+
_sessionSwitchInFlight = true;
|
|
157
150
|
try {
|
|
158
151
|
const sessionPromise = s.cmdCtx.newSession().finally(() => {
|
|
159
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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(
|
|
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(
|
|
318
|
-
const vizPrefs =
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
472
|
-
await
|
|
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
|
|
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(
|
|
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
|
-
|
|
504
|
-
|
|
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 (
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
605
|
+
// ── Sliding-window stuck detection with graduated recovery ──
|
|
606
606
|
const derivedKey = `${unitType}/${unitId}`;
|
|
607
|
-
if (
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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-
|
|
614
|
+
phase: "stuck-check",
|
|
635
615
|
unitType,
|
|
636
616
|
unitId,
|
|
637
|
-
|
|
617
|
+
reason: stuckSignal.reason,
|
|
618
|
+
recoveryAttempts: stuckRecoveryAttempts,
|
|
638
619
|
});
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1052
|
+
_currentResolve = null;
|
|
1024
1053
|
debugLog("autoLoop", { phase: "exit", totalIterations: iteration });
|
|
1025
1054
|
}
|