gsd-pi 2.38.0-dev.add4f78 → 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/discuss.md +11 -14
- package/dist/resources/extensions/gsd/prompts/execute-task.md +2 -2
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
- package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
- package/dist/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
- package/dist/resources/extensions/gsd/prompts/queue.md +4 -8
- package/dist/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
- package/dist/resources/extensions/gsd/prompts/run-uat.md +27 -10
- package/dist/resources/extensions/gsd/prompts/workflow-start.md +2 -2
- 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/discuss.md +11 -14
- package/src/resources/extensions/gsd/prompts/execute-task.md +2 -2
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +11 -12
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -10
- package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -1
- package/src/resources/extensions/gsd/prompts/queue.md +4 -8
- package/src/resources/extensions/gsd/prompts/reactive-execute.md +11 -8
- package/src/resources/extensions/gsd/prompts/run-uat.md +27 -10
- package/src/resources/extensions/gsd/prompts/workflow-start.md +2 -2
- 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/prompt-contracts.test.ts +59 -0
- package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +21 -1
- package/src/resources/extensions/gsd/tests/run-uat.test.ts +11 -3
- 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,12 @@
|
|
|
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
12
|
|
|
13
|
-
import type
|
|
13
|
+
import { importExtensionModule, type ExtensionAPI, type ExtensionContext } from "@gsd/pi-coding-agent";
|
|
14
14
|
|
|
15
15
|
import type { AutoSession } from "./auto/session.js";
|
|
16
16
|
import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js";
|
|
@@ -26,6 +26,9 @@ import type {
|
|
|
26
26
|
import type { DispatchAction } from "./auto-dispatch.js";
|
|
27
27
|
import type { WorktreeResolver } from "./worktree-resolver.js";
|
|
28
28
|
import { debugLog } from "./debug-logger.js";
|
|
29
|
+
import { gsdRoot } from "./paths.js";
|
|
30
|
+
import { atomicWriteSync } from "./atomic-write.js";
|
|
31
|
+
import { join } from "node:path";
|
|
29
32
|
import type { CmuxLogLevel } from "../cmux/index.js";
|
|
30
33
|
|
|
31
34
|
/**
|
|
@@ -35,15 +38,19 @@ import type { CmuxLogLevel } from "../cmux/index.js";
|
|
|
35
38
|
* generous headroom including retries and sidecar work.
|
|
36
39
|
*/
|
|
37
40
|
const MAX_LOOP_ITERATIONS = 500;
|
|
41
|
+
/** Maximum characters of failure/crash context included in recovery prompts. */
|
|
42
|
+
const MAX_RECOVERY_CHARS = 50_000;
|
|
38
43
|
|
|
39
|
-
/** Data-driven budget threshold notifications (
|
|
40
|
-
*
|
|
44
|
+
/** Data-driven budget threshold notifications (descending). The 100% entry
|
|
45
|
+
* triggers special enforcement logic (halt/pause/warn); sub-100 entries fire
|
|
46
|
+
* a simple notification. */
|
|
41
47
|
const BUDGET_THRESHOLDS: Array<{
|
|
42
48
|
pct: number;
|
|
43
49
|
label: string;
|
|
44
|
-
notifyLevel: "info" | "warning";
|
|
45
|
-
cmuxLevel: "progress" | "warning";
|
|
50
|
+
notifyLevel: "info" | "warning" | "error";
|
|
51
|
+
cmuxLevel: "progress" | "warning" | "error";
|
|
46
52
|
}> = [
|
|
53
|
+
{ pct: 100, label: "Budget ceiling reached", notifyLevel: "error", cmuxLevel: "error" },
|
|
47
54
|
{ pct: 90, label: "Budget 90%", notifyLevel: "warning", cmuxLevel: "warning" },
|
|
48
55
|
{ pct: 80, label: "Approaching budget ceiling — 80%", notifyLevel: "warning", cmuxLevel: "warning" },
|
|
49
56
|
{ pct: 75, label: "Budget 75%", notifyLevel: "info", cmuxLevel: "progress" },
|
|
@@ -67,17 +74,15 @@ export interface UnitResult {
|
|
|
67
74
|
event?: AgentEndEvent;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
// ───
|
|
77
|
+
// ─── Per-unit one-shot promise state ────────────────────────────────────────
|
|
71
78
|
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
79
|
+
// A single module-level resolve function scoped to the current unit execution.
|
|
80
|
+
// No queue — if an agent_end arrives with no pending resolver, it is dropped
|
|
81
|
+
// (logged as warning). This is simpler and safer than the previous session-
|
|
82
|
+
// scoped pendingResolve + pendingAgentEndQueue pattern.
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
* on entry so that the agent_end handler in index.ts can resolve the correct
|
|
78
|
-
* session's promise without needing a direct reference to `s`.
|
|
79
|
-
*/
|
|
80
|
-
let _activeSession: AutoSession | null = null;
|
|
84
|
+
let _currentResolve: ((result: UnitResult) => void) | null = null;
|
|
85
|
+
let _sessionSwitchInFlight = false;
|
|
81
86
|
|
|
82
87
|
// ─── resolveAgentEnd ─────────────────────────────────────────────────────────
|
|
83
88
|
|
|
@@ -86,61 +91,105 @@ let _activeSession: AutoSession | null = null;
|
|
|
86
91
|
* in-flight unit promise. One-shot: the resolver is nulled before calling
|
|
87
92
|
* to prevent double-resolution from model fallback retries.
|
|
88
93
|
*
|
|
89
|
-
* If no
|
|
90
|
-
* the event is
|
|
94
|
+
* If no resolver exists (event arrived between loop iterations or during
|
|
95
|
+
* session switch), the event is dropped with a debug warning.
|
|
91
96
|
*/
|
|
92
97
|
export function resolveAgentEnd(event: AgentEndEvent): void {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
debugLog("resolveAgentEnd", {
|
|
96
|
-
status: "no-active-session",
|
|
97
|
-
warning: "agent_end with no active loop session",
|
|
98
|
-
});
|
|
98
|
+
if (_sessionSwitchInFlight) {
|
|
99
|
+
debugLog("resolveAgentEnd", { status: "ignored-during-switch" });
|
|
99
100
|
return;
|
|
100
101
|
}
|
|
101
|
-
|
|
102
|
-
if (s.pendingResolve) {
|
|
102
|
+
if (_currentResolve) {
|
|
103
103
|
debugLog("resolveAgentEnd", { status: "resolving", hasEvent: true });
|
|
104
|
-
const r =
|
|
105
|
-
|
|
104
|
+
const r = _currentResolve;
|
|
105
|
+
_currentResolve = null;
|
|
106
106
|
r({ status: "completed", event });
|
|
107
107
|
} else {
|
|
108
|
-
// Queue the event so the next runUnit picks it up immediately
|
|
109
108
|
debugLog("resolveAgentEnd", {
|
|
110
|
-
status: "
|
|
111
|
-
|
|
112
|
-
unitId: s.currentUnit?.id,
|
|
113
|
-
warning:
|
|
114
|
-
"agent_end arrived between loop iterations — queued for next runUnit",
|
|
109
|
+
status: "no-pending-resolve",
|
|
110
|
+
warning: "agent_end with no pending unit",
|
|
115
111
|
});
|
|
116
|
-
s.pendingAgentEndQueue.push({ ...event, unitId: s.currentUnit?.id });
|
|
117
112
|
}
|
|
118
113
|
}
|
|
119
114
|
|
|
120
115
|
export function isSessionSwitchInFlight(): boolean {
|
|
121
|
-
return
|
|
116
|
+
return _sessionSwitchInFlight;
|
|
122
117
|
}
|
|
123
118
|
|
|
124
119
|
// ─── resetPendingResolve (test helper) ───────────────────────────────────────
|
|
125
120
|
|
|
126
121
|
/**
|
|
127
|
-
* Reset
|
|
128
|
-
* should never call this.
|
|
122
|
+
* Reset module-level promise state. Only exported for test cleanup —
|
|
123
|
+
* production code should never call this.
|
|
129
124
|
*/
|
|
130
125
|
export function _resetPendingResolve(): void {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
_activeSession.pendingAgentEndQueue = [];
|
|
134
|
-
}
|
|
135
|
-
_activeSession = null;
|
|
126
|
+
_currentResolve = null;
|
|
127
|
+
_sessionSwitchInFlight = false;
|
|
136
128
|
}
|
|
137
129
|
|
|
138
130
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
131
|
+
* No-op for backward compatibility with tests that previously set the
|
|
132
|
+
* active session. The module no longer holds a session reference.
|
|
141
133
|
*/
|
|
142
|
-
export function _setActiveSession(
|
|
143
|
-
|
|
134
|
+
export function _setActiveSession(_session: AutoSession | null): void {
|
|
135
|
+
// No-op — kept for test backward compatibility
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── detectStuck ─────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
type WindowEntry = { key: string; error?: string };
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Analyze a sliding window of recent unit dispatches for stuck patterns.
|
|
144
|
+
* Returns a signal with reason if stuck, null otherwise.
|
|
145
|
+
*
|
|
146
|
+
* Rule 1: Same error string twice in a row → stuck immediately.
|
|
147
|
+
* Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior).
|
|
148
|
+
* Rule 3: Oscillation A→B→A→B in last 4 entries → stuck.
|
|
149
|
+
*/
|
|
150
|
+
export function detectStuck(
|
|
151
|
+
window: readonly WindowEntry[],
|
|
152
|
+
): { stuck: true; reason: string } | null {
|
|
153
|
+
if (window.length < 2) return null;
|
|
154
|
+
|
|
155
|
+
const last = window[window.length - 1];
|
|
156
|
+
const prev = window[window.length - 2];
|
|
157
|
+
|
|
158
|
+
// Rule 1: Same error repeated consecutively
|
|
159
|
+
if (last.error && prev.error && last.error === prev.error) {
|
|
160
|
+
return {
|
|
161
|
+
stuck: true,
|
|
162
|
+
reason: `Same error repeated: ${last.error.slice(0, 200)}`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Rule 2: Same unit 3+ consecutive times
|
|
167
|
+
if (window.length >= 3) {
|
|
168
|
+
const lastThree = window.slice(-3);
|
|
169
|
+
if (lastThree.every((u) => u.key === last.key)) {
|
|
170
|
+
return {
|
|
171
|
+
stuck: true,
|
|
172
|
+
reason: `${last.key} derived 3 consecutive times without progress`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Rule 3: Oscillation (A→B→A→B in last 4)
|
|
178
|
+
if (window.length >= 4) {
|
|
179
|
+
const w = window.slice(-4);
|
|
180
|
+
if (
|
|
181
|
+
w[0].key === w[2].key &&
|
|
182
|
+
w[1].key === w[3].key &&
|
|
183
|
+
w[0].key !== w[1].key
|
|
184
|
+
) {
|
|
185
|
+
return {
|
|
186
|
+
stuck: true,
|
|
187
|
+
reason: `Oscillation detected: ${w[0].key} ↔ ${w[1].key}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return null;
|
|
144
193
|
}
|
|
145
194
|
|
|
146
195
|
// ─── runUnit ─────────────────────────────────────────────────────────────────
|
|
@@ -160,68 +209,18 @@ export async function runUnit(
|
|
|
160
209
|
unitType: string,
|
|
161
210
|
unitId: string,
|
|
162
211
|
prompt: string,
|
|
163
|
-
_prefs: GSDPreferences | undefined,
|
|
164
212
|
): Promise<UnitResult> {
|
|
165
213
|
debugLog("runUnit", { phase: "start", unitType, unitId });
|
|
166
214
|
|
|
167
|
-
// ── Drain queued events from error-recovery retries ──
|
|
168
|
-
// If an agent_end arrived between iterations (e.g. from a model fallback
|
|
169
|
-
// sendMessage retry), consume it immediately instead of creating a new promise.
|
|
170
|
-
// Cap queue to 3 entries to prevent unbounded growth from stale events.
|
|
171
|
-
if (s.pendingAgentEndQueue.length > 3) {
|
|
172
|
-
debugLog("runUnit", {
|
|
173
|
-
phase: "queue-overflow",
|
|
174
|
-
dropped: s.pendingAgentEndQueue.length - 1,
|
|
175
|
-
unitType,
|
|
176
|
-
unitId,
|
|
177
|
-
});
|
|
178
|
-
s.pendingAgentEndQueue = [
|
|
179
|
-
s.pendingAgentEndQueue[s.pendingAgentEndQueue.length - 1]!,
|
|
180
|
-
];
|
|
181
|
-
}
|
|
182
|
-
if (s.pendingAgentEndQueue.length > 0) {
|
|
183
|
-
// Find an event matching this unit; discard stale events from other units
|
|
184
|
-
const matchIdx = s.pendingAgentEndQueue.findIndex(
|
|
185
|
-
(e) => !e.unitId || e.unitId === unitId,
|
|
186
|
-
);
|
|
187
|
-
if (matchIdx >= 0) {
|
|
188
|
-
// Discard any stale events before the match
|
|
189
|
-
if (matchIdx > 0) {
|
|
190
|
-
debugLog("runUnit", {
|
|
191
|
-
phase: "discarded-stale-events",
|
|
192
|
-
count: matchIdx,
|
|
193
|
-
unitType,
|
|
194
|
-
unitId,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
const queued = s.pendingAgentEndQueue.splice(0, matchIdx + 1).pop()!;
|
|
198
|
-
debugLog("runUnit", {
|
|
199
|
-
phase: "drained-queued-event",
|
|
200
|
-
unitType,
|
|
201
|
-
unitId,
|
|
202
|
-
queueRemaining: s.pendingAgentEndQueue.length,
|
|
203
|
-
});
|
|
204
|
-
return { status: "completed", event: queued };
|
|
205
|
-
}
|
|
206
|
-
// No matching event — discard all stale events and proceed to new session
|
|
207
|
-
debugLog("runUnit", {
|
|
208
|
-
phase: "discarded-all-stale-events",
|
|
209
|
-
count: s.pendingAgentEndQueue.length,
|
|
210
|
-
unitType,
|
|
211
|
-
unitId,
|
|
212
|
-
});
|
|
213
|
-
s.pendingAgentEndQueue = [];
|
|
214
|
-
}
|
|
215
|
-
|
|
216
215
|
// ── Session creation with timeout ──
|
|
217
216
|
debugLog("runUnit", { phase: "session-create", unitType, unitId });
|
|
218
217
|
|
|
219
218
|
let sessionResult: { cancelled: boolean };
|
|
220
219
|
let sessionTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
221
|
-
|
|
220
|
+
_sessionSwitchInFlight = true;
|
|
222
221
|
try {
|
|
223
222
|
const sessionPromise = s.cmdCtx!.newSession().finally(() => {
|
|
224
|
-
|
|
223
|
+
_sessionSwitchInFlight = false;
|
|
225
224
|
});
|
|
226
225
|
const timeoutPromise = new Promise<{ cancelled: true }>((resolve) => {
|
|
227
226
|
sessionTimeoutHandle = setTimeout(
|
|
@@ -253,11 +252,12 @@ export async function runUnit(
|
|
|
253
252
|
return { status: "cancelled" };
|
|
254
253
|
}
|
|
255
254
|
|
|
256
|
-
// ── Create the agent_end promise (
|
|
255
|
+
// ── Create the agent_end promise (per-unit one-shot) ──
|
|
257
256
|
// This happens after newSession completes so session-switch agent_end events
|
|
258
257
|
// from the previous session cannot resolve the new unit.
|
|
258
|
+
_sessionSwitchInFlight = false;
|
|
259
259
|
const unitPromise = new Promise<UnitResult>((resolve) => {
|
|
260
|
-
|
|
260
|
+
_currentResolve = resolve;
|
|
261
261
|
});
|
|
262
262
|
|
|
263
263
|
// Ensure cwd matches basePath before dispatch (#1389).
|
|
@@ -552,6 +552,96 @@ export interface LoopDeps {
|
|
|
552
552
|
getSessionFile: (ctx: ExtensionContext) => string;
|
|
553
553
|
}
|
|
554
554
|
|
|
555
|
+
// ─── generateMilestoneReport ──────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Generate and write an HTML milestone report snapshot.
|
|
559
|
+
* Extracted from the milestone-transition block in autoLoop.
|
|
560
|
+
*/
|
|
561
|
+
async function generateMilestoneReport(
|
|
562
|
+
s: AutoSession,
|
|
563
|
+
ctx: ExtensionContext,
|
|
564
|
+
milestoneId: string,
|
|
565
|
+
): Promise<void> {
|
|
566
|
+
const { loadVisualizerData } = await importExtensionModule<typeof import("./visualizer-data.js")>(import.meta.url, "./visualizer-data.js");
|
|
567
|
+
const { generateHtmlReport } = await importExtensionModule<typeof import("./export-html.js")>(import.meta.url, "./export-html.js");
|
|
568
|
+
const { writeReportSnapshot } = await importExtensionModule<typeof import("./reports.js")>(import.meta.url, "./reports.js");
|
|
569
|
+
const { basename } = await import("node:path");
|
|
570
|
+
|
|
571
|
+
const snapData = await loadVisualizerData(s.basePath);
|
|
572
|
+
const completedMs = snapData.milestones.find(
|
|
573
|
+
(m: { id: string }) => m.id === milestoneId,
|
|
574
|
+
);
|
|
575
|
+
const msTitle = completedMs?.title ?? milestoneId;
|
|
576
|
+
const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
|
|
577
|
+
const projName = basename(s.basePath);
|
|
578
|
+
const doneSlices = snapData.milestones.reduce(
|
|
579
|
+
(acc: number, m: { slices: { done: boolean }[] }) =>
|
|
580
|
+
acc + m.slices.filter((sl: { done: boolean }) => sl.done).length,
|
|
581
|
+
0,
|
|
582
|
+
);
|
|
583
|
+
const totalSlices = snapData.milestones.reduce(
|
|
584
|
+
(acc: number, m: { slices: unknown[] }) => acc + m.slices.length,
|
|
585
|
+
0,
|
|
586
|
+
);
|
|
587
|
+
const outPath = writeReportSnapshot({
|
|
588
|
+
basePath: s.basePath,
|
|
589
|
+
html: generateHtmlReport(snapData, {
|
|
590
|
+
projectName: projName,
|
|
591
|
+
projectPath: s.basePath,
|
|
592
|
+
gsdVersion,
|
|
593
|
+
milestoneId,
|
|
594
|
+
indexRelPath: "index.html",
|
|
595
|
+
}),
|
|
596
|
+
milestoneId,
|
|
597
|
+
milestoneTitle: msTitle,
|
|
598
|
+
kind: "milestone",
|
|
599
|
+
projectName: projName,
|
|
600
|
+
projectPath: s.basePath,
|
|
601
|
+
gsdVersion,
|
|
602
|
+
totalCost: snapData.totals?.cost ?? 0,
|
|
603
|
+
totalTokens: snapData.totals?.tokens.total ?? 0,
|
|
604
|
+
totalDuration: snapData.totals?.duration ?? 0,
|
|
605
|
+
doneSlices,
|
|
606
|
+
totalSlices,
|
|
607
|
+
doneMilestones: snapData.milestones.filter(
|
|
608
|
+
(m: { status: string }) => m.status === "complete",
|
|
609
|
+
).length,
|
|
610
|
+
totalMilestones: snapData.milestones.length,
|
|
611
|
+
phase: snapData.phase,
|
|
612
|
+
});
|
|
613
|
+
ctx.ui.notify(
|
|
614
|
+
`Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`,
|
|
615
|
+
"info",
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ─── closeoutAndStop ──────────────────────────────────────────────────────────
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* If a unit is in-flight, close it out, then stop auto-mode.
|
|
623
|
+
* Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop.
|
|
624
|
+
*/
|
|
625
|
+
async function closeoutAndStop(
|
|
626
|
+
ctx: ExtensionContext,
|
|
627
|
+
pi: ExtensionAPI,
|
|
628
|
+
s: AutoSession,
|
|
629
|
+
deps: LoopDeps,
|
|
630
|
+
reason: string,
|
|
631
|
+
): Promise<void> {
|
|
632
|
+
if (s.currentUnit) {
|
|
633
|
+
await deps.closeoutUnit(
|
|
634
|
+
ctx,
|
|
635
|
+
s.basePath,
|
|
636
|
+
s.currentUnit.type,
|
|
637
|
+
s.currentUnit.id,
|
|
638
|
+
s.currentUnit.startedAt,
|
|
639
|
+
deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
await deps.stopAuto(ctx, pi, reason);
|
|
643
|
+
}
|
|
644
|
+
|
|
555
645
|
// ─── autoLoop ────────────────────────────────────────────────────────────────
|
|
556
646
|
|
|
557
647
|
/**
|
|
@@ -569,10 +659,11 @@ export async function autoLoop(
|
|
|
569
659
|
deps: LoopDeps,
|
|
570
660
|
): Promise<void> {
|
|
571
661
|
debugLog("autoLoop", { phase: "enter" });
|
|
572
|
-
_activeSession = s;
|
|
573
662
|
let iteration = 0;
|
|
574
|
-
|
|
575
|
-
|
|
663
|
+
// ── Sliding-window stuck detection ──
|
|
664
|
+
const recentUnits: Array<{ key: string; error?: string }> = [];
|
|
665
|
+
const STUCK_WINDOW_SIZE = 6;
|
|
666
|
+
let stuckRecoveryAttempts = 0;
|
|
576
667
|
|
|
577
668
|
let consecutiveErrors = 0;
|
|
578
669
|
|
|
@@ -601,6 +692,7 @@ export async function autoLoop(
|
|
|
601
692
|
|
|
602
693
|
try {
|
|
603
694
|
// ── Blanket try/catch: one bad iteration must not kill the session
|
|
695
|
+
const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
|
|
604
696
|
|
|
605
697
|
const sessionLockBase = deps.lockBase();
|
|
606
698
|
if (sessionLockBase) {
|
|
@@ -673,7 +765,7 @@ export async function autoLoop(
|
|
|
673
765
|
|
|
674
766
|
// Derive state
|
|
675
767
|
let state = await deps.deriveState(s.basePath);
|
|
676
|
-
deps.syncCmuxSidebar(
|
|
768
|
+
deps.syncCmuxSidebar(prefs, state);
|
|
677
769
|
let mid = state.activeMilestone?.id;
|
|
678
770
|
let midTitle = state.activeMilestone?.title;
|
|
679
771
|
debugLog("autoLoop", {
|
|
@@ -696,68 +788,18 @@ export async function autoLoop(
|
|
|
696
788
|
"milestone",
|
|
697
789
|
);
|
|
698
790
|
deps.logCmuxEvent(
|
|
699
|
-
|
|
791
|
+
prefs,
|
|
700
792
|
`Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`,
|
|
701
793
|
"success",
|
|
702
794
|
);
|
|
703
795
|
|
|
704
|
-
const vizPrefs =
|
|
796
|
+
const vizPrefs = prefs;
|
|
705
797
|
if (vizPrefs?.auto_visualize) {
|
|
706
798
|
ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
|
|
707
799
|
}
|
|
708
800
|
if (vizPrefs?.auto_report !== false) {
|
|
709
801
|
try {
|
|
710
|
-
|
|
711
|
-
const { generateHtmlReport } = await import("./export-html.js");
|
|
712
|
-
const { writeReportSnapshot } = await import("./reports.js");
|
|
713
|
-
const { basename } = await import("node:path");
|
|
714
|
-
const snapData = await loadVisualizerData(s.basePath);
|
|
715
|
-
const completedMs = snapData.milestones.find(
|
|
716
|
-
(m: { id: string }) => m.id === s.currentMilestoneId,
|
|
717
|
-
);
|
|
718
|
-
const msTitle = completedMs?.title ?? s.currentMilestoneId;
|
|
719
|
-
const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
|
|
720
|
-
const projName = basename(s.basePath);
|
|
721
|
-
const doneSlices = snapData.milestones.reduce(
|
|
722
|
-
(acc: number, m: { slices: { done: boolean }[] }) =>
|
|
723
|
-
acc +
|
|
724
|
-
m.slices.filter((sl: { done: boolean }) => sl.done).length,
|
|
725
|
-
0,
|
|
726
|
-
);
|
|
727
|
-
const totalSlices = snapData.milestones.reduce(
|
|
728
|
-
(acc: number, m: { slices: unknown[] }) => acc + m.slices.length,
|
|
729
|
-
0,
|
|
730
|
-
);
|
|
731
|
-
const outPath = writeReportSnapshot({
|
|
732
|
-
basePath: s.basePath,
|
|
733
|
-
html: generateHtmlReport(snapData, {
|
|
734
|
-
projectName: projName,
|
|
735
|
-
projectPath: s.basePath,
|
|
736
|
-
gsdVersion,
|
|
737
|
-
milestoneId: s.currentMilestoneId,
|
|
738
|
-
indexRelPath: "index.html",
|
|
739
|
-
}),
|
|
740
|
-
milestoneId: s.currentMilestoneId!,
|
|
741
|
-
milestoneTitle: msTitle,
|
|
742
|
-
kind: "milestone",
|
|
743
|
-
projectName: projName,
|
|
744
|
-
projectPath: s.basePath,
|
|
745
|
-
gsdVersion,
|
|
746
|
-
totalCost: snapData.totals?.cost ?? 0,
|
|
747
|
-
totalTokens: snapData.totals?.tokens.total ?? 0,
|
|
748
|
-
totalDuration: snapData.totals?.duration ?? 0,
|
|
749
|
-
doneSlices,
|
|
750
|
-
totalSlices,
|
|
751
|
-
doneMilestones: snapData.milestones.filter(
|
|
752
|
-
(m: { status: string }) => m.status === "complete",
|
|
753
|
-
).length,
|
|
754
|
-
totalMilestones: snapData.milestones.length,
|
|
755
|
-
phase: snapData.phase,
|
|
756
|
-
});
|
|
757
|
-
ctx.ui.notify(
|
|
758
|
-
`Report saved: .gsd/reports/${(await import("node:path")).basename(outPath)} — open index.html to browse progression.`,
|
|
759
|
-
"info",
|
|
760
|
-
);
|
|
802
|
+
await generateMilestoneReport(s, ctx, s.currentMilestoneId!);
|
|
761
803
|
} catch (err) {
|
|
762
804
|
ctx.ui.notify(
|
|
763
805
|
`Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -770,8 +812,8 @@ export async function autoLoop(
|
|
|
770
812
|
s.unitDispatchCount.clear();
|
|
771
813
|
s.unitRecoveryCount.clear();
|
|
772
814
|
s.unitLifetimeDispatches.clear();
|
|
773
|
-
|
|
774
|
-
|
|
815
|
+
recentUnits.length = 0;
|
|
816
|
+
stuckRecoveryAttempts = 0;
|
|
775
817
|
|
|
776
818
|
// Worktree lifecycle on milestone transition — merge current, enter next
|
|
777
819
|
deps.resolver.mergeAndExit(s.currentMilestoneId!, ctx.ui);
|
|
@@ -784,9 +826,7 @@ export async function autoLoop(
|
|
|
784
826
|
if (mid) {
|
|
785
827
|
if (deps.getIsolationMode() !== "none") {
|
|
786
828
|
deps.captureIntegrationBranch(s.basePath, mid, {
|
|
787
|
-
commitDocs:
|
|
788
|
-
deps.loadEffectiveGSDPreferences()?.preferences?.git
|
|
789
|
-
?.commit_docs,
|
|
829
|
+
commitDocs: prefs?.git?.commit_docs,
|
|
790
830
|
});
|
|
791
831
|
}
|
|
792
832
|
deps.resolver.enterMilestone(mid, ctx.ui);
|
|
@@ -838,7 +878,7 @@ export async function autoLoop(
|
|
|
838
878
|
"milestone",
|
|
839
879
|
);
|
|
840
880
|
deps.logCmuxEvent(
|
|
841
|
-
|
|
881
|
+
prefs,
|
|
842
882
|
"All milestones complete.",
|
|
843
883
|
"success",
|
|
844
884
|
);
|
|
@@ -860,7 +900,7 @@ export async function autoLoop(
|
|
|
860
900
|
await deps.stopAuto(ctx, pi, blockerMsg);
|
|
861
901
|
ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
|
|
862
902
|
deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
|
|
863
|
-
deps.logCmuxEvent(
|
|
903
|
+
deps.logCmuxEvent(prefs, blockerMsg, "error");
|
|
864
904
|
} else {
|
|
865
905
|
const ids = incomplete.map((m: { id: string }) => m.id).join(", ");
|
|
866
906
|
const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m: { id: string; status: string }) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
|
|
@@ -895,20 +935,10 @@ export async function autoLoop(
|
|
|
895
935
|
}
|
|
896
936
|
|
|
897
937
|
if (!mid || !midTitle) {
|
|
898
|
-
if (s.currentUnit) {
|
|
899
|
-
await deps.closeoutUnit(
|
|
900
|
-
ctx,
|
|
901
|
-
s.basePath,
|
|
902
|
-
s.currentUnit.type,
|
|
903
|
-
s.currentUnit.id,
|
|
904
|
-
s.currentUnit.startedAt,
|
|
905
|
-
deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
|
|
906
|
-
);
|
|
907
|
-
}
|
|
908
938
|
const noMilestoneReason = !mid
|
|
909
939
|
? "No active milestone after merge reconciliation"
|
|
910
940
|
: `Milestone ${mid} has no title after reconciliation`;
|
|
911
|
-
await
|
|
941
|
+
await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason);
|
|
912
942
|
debugLog("autoLoop", {
|
|
913
943
|
phase: "exit",
|
|
914
944
|
reason: "no-milestone-after-reconciliation",
|
|
@@ -918,17 +948,7 @@ export async function autoLoop(
|
|
|
918
948
|
|
|
919
949
|
// Terminal: complete
|
|
920
950
|
if (state.phase === "complete") {
|
|
921
|
-
|
|
922
|
-
await deps.closeoutUnit(
|
|
923
|
-
ctx,
|
|
924
|
-
s.basePath,
|
|
925
|
-
s.currentUnit.type,
|
|
926
|
-
s.currentUnit.id,
|
|
927
|
-
s.currentUnit.startedAt,
|
|
928
|
-
deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
|
|
929
|
-
);
|
|
930
|
-
}
|
|
931
|
-
// Milestone merge on complete
|
|
951
|
+
// Milestone merge on complete (before closeout so branch state is clean)
|
|
932
952
|
if (s.currentMilestoneId) {
|
|
933
953
|
deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui);
|
|
934
954
|
}
|
|
@@ -939,40 +959,28 @@ export async function autoLoop(
|
|
|
939
959
|
"milestone",
|
|
940
960
|
);
|
|
941
961
|
deps.logCmuxEvent(
|
|
942
|
-
|
|
962
|
+
prefs,
|
|
943
963
|
`Milestone ${mid} complete.`,
|
|
944
964
|
"success",
|
|
945
965
|
);
|
|
946
|
-
await
|
|
966
|
+
await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`);
|
|
947
967
|
debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" });
|
|
948
968
|
break;
|
|
949
969
|
}
|
|
950
970
|
|
|
951
971
|
// Terminal: blocked
|
|
952
972
|
if (state.phase === "blocked") {
|
|
953
|
-
if (s.currentUnit) {
|
|
954
|
-
await deps.closeoutUnit(
|
|
955
|
-
ctx,
|
|
956
|
-
s.basePath,
|
|
957
|
-
s.currentUnit.type,
|
|
958
|
-
s.currentUnit.id,
|
|
959
|
-
s.currentUnit.startedAt,
|
|
960
|
-
deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
|
|
961
|
-
);
|
|
962
|
-
}
|
|
963
973
|
const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
|
|
964
|
-
await
|
|
974
|
+
await closeoutAndStop(ctx, pi, s, deps, blockerMsg);
|
|
965
975
|
ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
|
|
966
976
|
deps.sendDesktopNotification("GSD", blockerMsg, "error", "attention");
|
|
967
|
-
deps.logCmuxEvent(
|
|
977
|
+
deps.logCmuxEvent(prefs, blockerMsg, "error");
|
|
968
978
|
debugLog("autoLoop", { phase: "exit", reason: "blocked" });
|
|
969
979
|
break;
|
|
970
980
|
}
|
|
971
981
|
|
|
972
982
|
// ── Phase 2: Guards ─────────────────────────────────────────────────
|
|
973
983
|
|
|
974
|
-
const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
|
|
975
|
-
|
|
976
984
|
// Budget ceiling guard
|
|
977
985
|
const budgetCeiling = prefs?.budget_ceiling;
|
|
978
986
|
if (budgetCeiling !== undefined && budgetCeiling > 0) {
|
|
@@ -992,38 +1000,39 @@ export async function autoLoop(
|
|
|
992
1000
|
budgetPct,
|
|
993
1001
|
);
|
|
994
1002
|
|
|
995
|
-
|
|
996
|
-
|
|
1003
|
+
// Data-driven threshold check — loop descending, fire first match
|
|
1004
|
+
const threshold = BUDGET_THRESHOLDS.find(
|
|
1005
|
+
(t) => newBudgetAlertLevel >= t.pct,
|
|
1006
|
+
);
|
|
1007
|
+
if (threshold) {
|
|
997
1008
|
s.lastBudgetAlertLevel =
|
|
998
1009
|
newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
)
|
|
1010
|
+
|
|
1011
|
+
if (threshold.pct === 100 && budgetEnforcementAction !== "none") {
|
|
1012
|
+
// 100% — special enforcement logic (halt/pause/warn)
|
|
1013
|
+
const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`;
|
|
1014
|
+
if (budgetEnforcementAction === "halt") {
|
|
1015
|
+
deps.sendDesktopNotification("GSD", msg, "error", "budget");
|
|
1016
|
+
await deps.stopAuto(ctx, pi, "Budget ceiling reached");
|
|
1017
|
+
debugLog("autoLoop", { phase: "exit", reason: "budget-halt" });
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
if (budgetEnforcementAction === "pause") {
|
|
1021
|
+
ctx.ui.notify(
|
|
1022
|
+
`${msg} Pausing auto-mode — /gsd auto to override and continue.`,
|
|
1023
|
+
"warning",
|
|
1024
|
+
);
|
|
1025
|
+
deps.sendDesktopNotification("GSD", msg, "warning", "budget");
|
|
1026
|
+
deps.logCmuxEvent(prefs, msg, "warning");
|
|
1027
|
+
await deps.pauseAuto(ctx, pi);
|
|
1028
|
+
debugLog("autoLoop", { phase: "exit", reason: "budget-pause" });
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
|
|
1010
1032
|
deps.sendDesktopNotification("GSD", msg, "warning", "budget");
|
|
1011
1033
|
deps.logCmuxEvent(prefs, msg, "warning");
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
break;
|
|
1015
|
-
}
|
|
1016
|
-
ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
|
|
1017
|
-
deps.sendDesktopNotification("GSD", msg, "warning", "budget");
|
|
1018
|
-
deps.logCmuxEvent(prefs, msg, "warning");
|
|
1019
|
-
} else {
|
|
1020
|
-
// Data-driven 75/80/90% threshold notifications
|
|
1021
|
-
const threshold = BUDGET_THRESHOLDS.find(
|
|
1022
|
-
(t) => newBudgetAlertLevel === t.pct,
|
|
1023
|
-
);
|
|
1024
|
-
if (threshold) {
|
|
1025
|
-
s.lastBudgetAlertLevel =
|
|
1026
|
-
newBudgetAlertLevel as AutoSession["lastBudgetAlertLevel"];
|
|
1034
|
+
} else if (threshold.pct < 100) {
|
|
1035
|
+
// Sub-100% — simple notification
|
|
1027
1036
|
const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`;
|
|
1028
1037
|
ctx.ui.notify(msg, threshold.notifyLevel);
|
|
1029
1038
|
deps.sendDesktopNotification(
|
|
@@ -1033,9 +1042,9 @@ export async function autoLoop(
|
|
|
1033
1042
|
"budget",
|
|
1034
1043
|
);
|
|
1035
1044
|
deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel);
|
|
1036
|
-
} else if (budgetAlertLevel === 0) {
|
|
1037
|
-
s.lastBudgetAlertLevel = 0;
|
|
1038
1045
|
}
|
|
1046
|
+
} else if (budgetAlertLevel === 0) {
|
|
1047
|
+
s.lastBudgetAlertLevel = 0;
|
|
1039
1048
|
}
|
|
1040
1049
|
} else {
|
|
1041
1050
|
s.lastBudgetAlertLevel = 0;
|
|
@@ -1110,17 +1119,7 @@ export async function autoLoop(
|
|
|
1110
1119
|
});
|
|
1111
1120
|
|
|
1112
1121
|
if (dispatchResult.action === "stop") {
|
|
1113
|
-
|
|
1114
|
-
await deps.closeoutUnit(
|
|
1115
|
-
ctx,
|
|
1116
|
-
s.basePath,
|
|
1117
|
-
s.currentUnit.type,
|
|
1118
|
-
s.currentUnit.id,
|
|
1119
|
-
s.currentUnit.startedAt,
|
|
1120
|
-
deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
|
|
1121
|
-
);
|
|
1122
|
-
}
|
|
1123
|
-
await deps.stopAuto(ctx, pi, dispatchResult.reason);
|
|
1122
|
+
await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason);
|
|
1124
1123
|
debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" });
|
|
1125
1124
|
break;
|
|
1126
1125
|
}
|
|
@@ -1136,71 +1135,79 @@ export async function autoLoop(
|
|
|
1136
1135
|
let prompt = dispatchResult.prompt;
|
|
1137
1136
|
const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
|
|
1138
1137
|
|
|
1139
|
-
// ──
|
|
1138
|
+
// ── Sliding-window stuck detection with graduated recovery ──
|
|
1140
1139
|
const derivedKey = `${unitType}/${unitId}`;
|
|
1141
|
-
if (derivedKey === lastDerivedUnit && !s.pendingVerificationRetry) {
|
|
1142
|
-
sameUnitCount++;
|
|
1143
|
-
debugLog("autoLoop", {
|
|
1144
|
-
phase: "stuck-check",
|
|
1145
|
-
unitType,
|
|
1146
|
-
unitId,
|
|
1147
|
-
sameUnitCount,
|
|
1148
|
-
});
|
|
1149
1140
|
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1141
|
+
if (!s.pendingVerificationRetry) {
|
|
1142
|
+
recentUnits.push({ key: derivedKey });
|
|
1143
|
+
if (recentUnits.length > STUCK_WINDOW_SIZE) recentUnits.shift();
|
|
1144
|
+
|
|
1145
|
+
const stuckSignal = detectStuck(recentUnits);
|
|
1146
|
+
if (stuckSignal) {
|
|
1147
|
+
debugLog("autoLoop", {
|
|
1148
|
+
phase: "stuck-check",
|
|
1153
1149
|
unitType,
|
|
1154
1150
|
unitId,
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1151
|
+
reason: stuckSignal.reason,
|
|
1152
|
+
recoveryAttempts: stuckRecoveryAttempts,
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
if (stuckRecoveryAttempts === 0) {
|
|
1156
|
+
// Level 1: try verifying the artifact, then cache invalidation + retry
|
|
1157
|
+
stuckRecoveryAttempts++;
|
|
1158
|
+
const artifactExists = deps.verifyExpectedArtifact(
|
|
1159
|
+
unitType,
|
|
1160
|
+
unitId,
|
|
1161
|
+
s.basePath,
|
|
1162
|
+
);
|
|
1163
|
+
if (artifactExists) {
|
|
1164
|
+
debugLog("autoLoop", {
|
|
1165
|
+
phase: "stuck-recovery",
|
|
1166
|
+
level: 1,
|
|
1167
|
+
action: "artifact-found",
|
|
1168
|
+
});
|
|
1169
|
+
ctx.ui.notify(
|
|
1170
|
+
`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`,
|
|
1171
|
+
"info",
|
|
1172
|
+
);
|
|
1173
|
+
deps.invalidateAllCaches();
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
ctx.ui.notify(
|
|
1177
|
+
`Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`,
|
|
1178
|
+
"warning",
|
|
1179
|
+
);
|
|
1180
|
+
deps.invalidateAllCaches();
|
|
1181
|
+
} else {
|
|
1182
|
+
// Level 2: hard stop — genuinely stuck
|
|
1158
1183
|
debugLog("autoLoop", {
|
|
1159
|
-
phase: "stuck-
|
|
1160
|
-
|
|
1161
|
-
|
|
1184
|
+
phase: "stuck-detected",
|
|
1185
|
+
unitType,
|
|
1186
|
+
unitId,
|
|
1187
|
+
reason: stuckSignal.reason,
|
|
1162
1188
|
});
|
|
1189
|
+
await deps.stopAuto(
|
|
1190
|
+
ctx,
|
|
1191
|
+
pi,
|
|
1192
|
+
`Stuck: ${stuckSignal.reason}`,
|
|
1193
|
+
);
|
|
1163
1194
|
ctx.ui.notify(
|
|
1164
|
-
`Stuck
|
|
1165
|
-
"
|
|
1195
|
+
`Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}. The expected artifact was not written.`,
|
|
1196
|
+
"error",
|
|
1166
1197
|
);
|
|
1167
|
-
|
|
1168
|
-
|
|
1198
|
+
break;
|
|
1199
|
+
}
|
|
1200
|
+
} else {
|
|
1201
|
+
// Progress detected — reset recovery counter
|
|
1202
|
+
if (stuckRecoveryAttempts > 0) {
|
|
1203
|
+
debugLog("autoLoop", {
|
|
1204
|
+
phase: "stuck-counter-reset",
|
|
1205
|
+
from: recentUnits[recentUnits.length - 2]?.key ?? "",
|
|
1206
|
+
to: derivedKey,
|
|
1207
|
+
});
|
|
1208
|
+
stuckRecoveryAttempts = 0;
|
|
1169
1209
|
}
|
|
1170
|
-
ctx.ui.notify(
|
|
1171
|
-
`Stuck on ${unitType} ${unitId} (attempt ${sameUnitCount}). Invalidating caches and retrying.`,
|
|
1172
|
-
"warning",
|
|
1173
|
-
);
|
|
1174
|
-
deps.invalidateAllCaches();
|
|
1175
|
-
} else if (sameUnitCount === 5) {
|
|
1176
|
-
// Level 2: hard stop — genuinely stuck
|
|
1177
|
-
debugLog("autoLoop", {
|
|
1178
|
-
phase: "stuck-detected",
|
|
1179
|
-
unitType,
|
|
1180
|
-
unitId,
|
|
1181
|
-
sameUnitCount,
|
|
1182
|
-
});
|
|
1183
|
-
await deps.stopAuto(
|
|
1184
|
-
ctx,
|
|
1185
|
-
pi,
|
|
1186
|
-
`Stuck: ${unitType} ${unitId} derived ${sameUnitCount} consecutive times without progress`,
|
|
1187
|
-
);
|
|
1188
|
-
ctx.ui.notify(
|
|
1189
|
-
`Stuck on ${unitType} ${unitId} — deriveState returns the same unit after ${sameUnitCount} attempts. The expected artifact was not written.`,
|
|
1190
|
-
"error",
|
|
1191
|
-
);
|
|
1192
|
-
break;
|
|
1193
|
-
}
|
|
1194
|
-
} else {
|
|
1195
|
-
if (derivedKey !== lastDerivedUnit) {
|
|
1196
|
-
debugLog("autoLoop", {
|
|
1197
|
-
phase: "stuck-counter-reset",
|
|
1198
|
-
from: lastDerivedUnit,
|
|
1199
|
-
to: derivedKey,
|
|
1200
|
-
});
|
|
1201
1210
|
}
|
|
1202
|
-
lastDerivedUnit = derivedKey;
|
|
1203
|
-
sameUnitCount = 0;
|
|
1204
1211
|
}
|
|
1205
1212
|
|
|
1206
1213
|
// Pre-dispatch hooks
|
|
@@ -1267,61 +1274,6 @@ export async function autoLoop(
|
|
|
1267
1274
|
);
|
|
1268
1275
|
const previousTier = s.currentUnitRouting?.tier;
|
|
1269
1276
|
|
|
1270
|
-
// Closeout previous unit
|
|
1271
|
-
if (s.currentUnit) {
|
|
1272
|
-
await deps.closeoutUnit(
|
|
1273
|
-
ctx,
|
|
1274
|
-
s.basePath,
|
|
1275
|
-
s.currentUnit.type,
|
|
1276
|
-
s.currentUnit.id,
|
|
1277
|
-
s.currentUnit.startedAt,
|
|
1278
|
-
deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
|
|
1279
|
-
);
|
|
1280
|
-
|
|
1281
|
-
if (s.currentUnitRouting) {
|
|
1282
|
-
const isRetry =
|
|
1283
|
-
s.currentUnit.type === unitType && s.currentUnit.id === unitId;
|
|
1284
|
-
deps.recordOutcome(
|
|
1285
|
-
s.currentUnit.type,
|
|
1286
|
-
s.currentUnitRouting.tier as "light" | "standard" | "heavy",
|
|
1287
|
-
!isRetry,
|
|
1288
|
-
);
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
|
|
1292
|
-
const incomingKey = `${unitType}/${unitId}`;
|
|
1293
|
-
const isHookUnit = s.currentUnit.type.startsWith("hook/");
|
|
1294
|
-
const artifactVerified =
|
|
1295
|
-
isHookUnit ||
|
|
1296
|
-
deps.verifyExpectedArtifact(
|
|
1297
|
-
s.currentUnit.type,
|
|
1298
|
-
s.currentUnit.id,
|
|
1299
|
-
s.basePath,
|
|
1300
|
-
);
|
|
1301
|
-
if (closeoutKey !== incomingKey && artifactVerified) {
|
|
1302
|
-
s.completedUnits.push({
|
|
1303
|
-
type: s.currentUnit.type,
|
|
1304
|
-
id: s.currentUnit.id,
|
|
1305
|
-
startedAt: s.currentUnit.startedAt,
|
|
1306
|
-
finishedAt: Date.now(),
|
|
1307
|
-
});
|
|
1308
|
-
if (s.completedUnits.length > 200) {
|
|
1309
|
-
s.completedUnits = s.completedUnits.slice(-200);
|
|
1310
|
-
}
|
|
1311
|
-
deps.clearUnitRuntimeRecord(
|
|
1312
|
-
s.basePath,
|
|
1313
|
-
s.currentUnit.type,
|
|
1314
|
-
s.currentUnit.id,
|
|
1315
|
-
);
|
|
1316
|
-
s.unitDispatchCount.delete(
|
|
1317
|
-
`${s.currentUnit.type}/${s.currentUnit.id}`,
|
|
1318
|
-
);
|
|
1319
|
-
s.unitRecoveryCount.delete(
|
|
1320
|
-
`${s.currentUnit.type}/${s.currentUnit.id}`,
|
|
1321
|
-
);
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
1277
|
s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
|
|
1326
1278
|
deps.captureAvailableSkills();
|
|
1327
1279
|
deps.writeUnitRuntimeRecord(
|
|
@@ -1348,7 +1300,6 @@ export async function autoLoop(
|
|
|
1348
1300
|
deps.ensurePreconditions(unitType, unitId, s.basePath, state);
|
|
1349
1301
|
|
|
1350
1302
|
// Prompt injection
|
|
1351
|
-
const MAX_RECOVERY_CHARS = 50_000;
|
|
1352
1303
|
let finalPrompt = prompt;
|
|
1353
1304
|
|
|
1354
1305
|
if (s.pendingVerificationRetry) {
|
|
@@ -1393,7 +1344,7 @@ export async function autoLoop(
|
|
|
1393
1344
|
s.lastBaselineCharCount = undefined;
|
|
1394
1345
|
if (deps.isDbAvailable()) {
|
|
1395
1346
|
try {
|
|
1396
|
-
const { inlineGsdRootFile } = await import("./auto-prompts.js");
|
|
1347
|
+
const { inlineGsdRootFile } = await importExtensionModule<typeof import("./auto-prompts.js")>(import.meta.url, "./auto-prompts.js");
|
|
1397
1348
|
const [decisionsContent, requirementsContent, projectContent] =
|
|
1398
1349
|
await Promise.all([
|
|
1399
1350
|
inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
|
|
@@ -1479,7 +1430,6 @@ export async function autoLoop(
|
|
|
1479
1430
|
unitType,
|
|
1480
1431
|
unitId,
|
|
1481
1432
|
finalPrompt,
|
|
1482
|
-
prefs,
|
|
1483
1433
|
);
|
|
1484
1434
|
debugLog("autoLoop", {
|
|
1485
1435
|
phase: "runUnit-end",
|
|
@@ -1489,6 +1439,23 @@ export async function autoLoop(
|
|
|
1489
1439
|
status: unitResult.status,
|
|
1490
1440
|
});
|
|
1491
1441
|
|
|
1442
|
+
// Tag the most recent window entry with error info for stuck detection
|
|
1443
|
+
if (unitResult.status === "error" || unitResult.status === "cancelled") {
|
|
1444
|
+
const lastEntry = recentUnits[recentUnits.length - 1];
|
|
1445
|
+
if (lastEntry) {
|
|
1446
|
+
lastEntry.error = `${unitResult.status}:${unitType}/${unitId}`;
|
|
1447
|
+
}
|
|
1448
|
+
} else if (unitResult.event?.messages?.length) {
|
|
1449
|
+
const lastMsg = unitResult.event.messages[unitResult.event.messages.length - 1];
|
|
1450
|
+
const msgStr = typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
|
|
1451
|
+
if (/error|fail|exception/i.test(msgStr)) {
|
|
1452
|
+
const lastEntry = recentUnits[recentUnits.length - 1];
|
|
1453
|
+
if (lastEntry) {
|
|
1454
|
+
lastEntry.error = msgStr.slice(0, 200);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1492
1459
|
if (unitResult.status === "cancelled") {
|
|
1493
1460
|
ctx.ui.notify(
|
|
1494
1461
|
`Session creation timed out or was cancelled for ${unitType} ${unitId}. Will retry.`,
|
|
@@ -1499,6 +1466,52 @@ export async function autoLoop(
|
|
|
1499
1466
|
break;
|
|
1500
1467
|
}
|
|
1501
1468
|
|
|
1469
|
+
// ── Immediate unit closeout (metrics, activity log, memory) ────────
|
|
1470
|
+
// Run right after runUnit() returns so telemetry is never lost to a
|
|
1471
|
+
// crash between iterations.
|
|
1472
|
+
await deps.closeoutUnit(
|
|
1473
|
+
ctx,
|
|
1474
|
+
s.basePath,
|
|
1475
|
+
unitType,
|
|
1476
|
+
unitId,
|
|
1477
|
+
s.currentUnit.startedAt,
|
|
1478
|
+
deps.buildSnapshotOpts(unitType, unitId),
|
|
1479
|
+
);
|
|
1480
|
+
|
|
1481
|
+
if (s.currentUnitRouting) {
|
|
1482
|
+
deps.recordOutcome(
|
|
1483
|
+
unitType,
|
|
1484
|
+
s.currentUnitRouting.tier as "light" | "standard" | "heavy",
|
|
1485
|
+
true, // success assumed; dispatch will re-dispatch if artifact missing
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
const isHookUnit = unitType.startsWith("hook/");
|
|
1490
|
+
const artifactVerified =
|
|
1491
|
+
isHookUnit ||
|
|
1492
|
+
deps.verifyExpectedArtifact(unitType, unitId, s.basePath);
|
|
1493
|
+
if (artifactVerified) {
|
|
1494
|
+
s.completedUnits.push({
|
|
1495
|
+
type: unitType,
|
|
1496
|
+
id: unitId,
|
|
1497
|
+
startedAt: s.currentUnit.startedAt,
|
|
1498
|
+
finishedAt: Date.now(),
|
|
1499
|
+
});
|
|
1500
|
+
if (s.completedUnits.length > 200) {
|
|
1501
|
+
s.completedUnits = s.completedUnits.slice(-200);
|
|
1502
|
+
}
|
|
1503
|
+
// Flush completed-units to disk so the record survives crashes
|
|
1504
|
+
try {
|
|
1505
|
+
const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json");
|
|
1506
|
+
const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`);
|
|
1507
|
+
atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2));
|
|
1508
|
+
} catch { /* non-fatal: disk flush failure */ }
|
|
1509
|
+
|
|
1510
|
+
deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId);
|
|
1511
|
+
s.unitDispatchCount.delete(`${unitType}/${unitId}`);
|
|
1512
|
+
s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1502
1515
|
// ── Phase 5: Finalize ───────────────────────────────────────────────
|
|
1503
1516
|
|
|
1504
1517
|
debugLog("autoLoop", { phase: "finalize", iteration });
|
|
@@ -1651,7 +1664,6 @@ export async function autoLoop(
|
|
|
1651
1664
|
item.unitType,
|
|
1652
1665
|
item.unitId,
|
|
1653
1666
|
item.prompt,
|
|
1654
|
-
prefs,
|
|
1655
1667
|
);
|
|
1656
1668
|
deps.clearUnitTimeout();
|
|
1657
1669
|
|
|
@@ -1665,6 +1677,16 @@ export async function autoLoop(
|
|
|
1665
1677
|
break;
|
|
1666
1678
|
}
|
|
1667
1679
|
|
|
1680
|
+
// Immediate closeout for sidecar unit
|
|
1681
|
+
await deps.closeoutUnit(
|
|
1682
|
+
ctx,
|
|
1683
|
+
s.basePath,
|
|
1684
|
+
item.unitType,
|
|
1685
|
+
item.unitId,
|
|
1686
|
+
sidecarStartedAt,
|
|
1687
|
+
deps.buildSnapshotOpts(item.unitType, item.unitId),
|
|
1688
|
+
);
|
|
1689
|
+
|
|
1668
1690
|
// Run pre-verification for the sidecar unit (lightweight path)
|
|
1669
1691
|
const sidecarPreOpts: PreVerificationOpts = item.kind === "hook"
|
|
1670
1692
|
? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true }
|
|
@@ -1759,6 +1781,6 @@ export async function autoLoop(
|
|
|
1759
1781
|
}
|
|
1760
1782
|
}
|
|
1761
1783
|
|
|
1762
|
-
|
|
1784
|
+
_currentResolve = null;
|
|
1763
1785
|
debugLog("autoLoop", { phase: "exit", totalIterations: iteration });
|
|
1764
1786
|
}
|