gsd-pi 2.26.0 → 2.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -6
- package/dist/cli.js +4 -2
- package/dist/headless.d.ts +3 -0
- package/dist/headless.js +136 -8
- package/dist/help-text.js +3 -0
- package/dist/loader.js +33 -4
- package/dist/resources/extensions/bg-shell/index.ts +19 -2
- package/dist/resources/extensions/bg-shell/process-manager.ts +45 -0
- package/dist/resources/extensions/bg-shell/types.ts +21 -1
- package/dist/resources/extensions/gsd/auto/session.ts +224 -0
- package/dist/resources/extensions/gsd/auto-budget.ts +32 -0
- package/dist/resources/extensions/gsd/auto-dashboard.ts +63 -10
- package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
- package/dist/resources/extensions/gsd/auto-dispatch.ts +23 -10
- package/dist/resources/extensions/gsd/auto-model-selection.ts +179 -0
- package/dist/resources/extensions/gsd/auto-observability.ts +74 -0
- package/dist/resources/extensions/gsd/auto-prompts.ts +0 -1
- package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
- package/dist/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
- package/dist/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
- package/dist/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
- package/dist/resources/extensions/gsd/auto.ts +977 -1551
- package/dist/resources/extensions/gsd/commands.ts +3 -3
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +47 -72
- package/dist/resources/extensions/gsd/doctor-proactive.ts +9 -4
- package/dist/resources/extensions/gsd/export-html.ts +1001 -0
- package/dist/resources/extensions/gsd/export.ts +49 -1
- package/dist/resources/extensions/gsd/git-service.ts +6 -0
- package/dist/resources/extensions/gsd/gitignore.ts +4 -1
- package/dist/resources/extensions/gsd/guided-flow.ts +24 -5
- package/dist/resources/extensions/gsd/index.ts +54 -1
- package/dist/resources/extensions/gsd/native-git-bridge.ts +30 -2
- package/dist/resources/extensions/gsd/observability-validator.ts +21 -0
- package/dist/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
- package/dist/resources/extensions/gsd/preferences.ts +62 -1
- package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -3
- package/dist/resources/extensions/gsd/prompts/system.md +1 -1
- package/dist/resources/extensions/gsd/reports.ts +510 -0
- package/dist/resources/extensions/gsd/roadmap-slices.ts +1 -1
- package/dist/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
- package/dist/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
- package/dist/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
- package/dist/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
- package/dist/resources/extensions/gsd/state.ts +30 -0
- package/dist/resources/extensions/gsd/templates/task-summary.md +9 -0
- package/dist/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
- package/dist/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
- package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
- package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
- package/dist/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
- package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
- package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
- package/dist/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
- package/dist/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
- package/dist/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
- package/dist/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
- package/dist/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
- package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
- package/dist/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
- package/dist/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
- package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
- package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
- package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
- package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
- package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
- package/dist/resources/extensions/gsd/tests/worktree.test.ts +3 -1
- package/dist/resources/extensions/gsd/types.ts +38 -0
- package/dist/resources/extensions/gsd/verification-evidence.ts +183 -0
- package/dist/resources/extensions/gsd/verification-gate.ts +567 -0
- package/dist/resources/extensions/gsd/visualizer-data.ts +25 -3
- package/dist/resources/extensions/gsd/visualizer-overlay.ts +31 -21
- package/dist/resources/extensions/gsd/visualizer-views.ts +15 -66
- package/dist/resources/extensions/search-the-web/tool-search.ts +26 -0
- package/dist/resources/extensions/shared/format-utils.ts +85 -0
- package/dist/resources/extensions/shared/tests/format-utils.test.ts +153 -0
- package/dist/resources/extensions/subagent/index.ts +46 -1
- package/dist/resources/extensions/subagent/isolation.ts +9 -6
- package/package.json +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.js +7 -4
- package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
- package/packages/pi-ai/src/providers/openai-completions.ts +7 -4
- package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/client.js +7 -0
- package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.js +9 -2
- package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
- package/packages/pi-coding-agent/src/core/lsp/client.ts +8 -0
- package/packages/pi-coding-agent/src/core/lsp/config.ts +9 -2
- package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/editor.js +1 -1
- package/packages/pi-tui/dist/components/editor.js.map +1 -1
- package/packages/pi-tui/src/components/editor.ts +3 -1
- package/scripts/link-workspace-packages.cjs +22 -6
- package/src/resources/extensions/bg-shell/index.ts +19 -2
- package/src/resources/extensions/bg-shell/process-manager.ts +45 -0
- package/src/resources/extensions/bg-shell/types.ts +21 -1
- package/src/resources/extensions/gsd/auto/session.ts +224 -0
- package/src/resources/extensions/gsd/auto-budget.ts +32 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +63 -10
- package/src/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +23 -10
- package/src/resources/extensions/gsd/auto-model-selection.ts +179 -0
- package/src/resources/extensions/gsd/auto-observability.ts +74 -0
- package/src/resources/extensions/gsd/auto-prompts.ts +0 -1
- package/src/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
- package/src/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
- package/src/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
- package/src/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
- package/src/resources/extensions/gsd/auto.ts +977 -1551
- package/src/resources/extensions/gsd/commands.ts +3 -3
- package/src/resources/extensions/gsd/dashboard-overlay.ts +47 -72
- package/src/resources/extensions/gsd/doctor-proactive.ts +9 -4
- package/src/resources/extensions/gsd/export-html.ts +1001 -0
- package/src/resources/extensions/gsd/export.ts +49 -1
- package/src/resources/extensions/gsd/git-service.ts +6 -0
- package/src/resources/extensions/gsd/gitignore.ts +4 -1
- package/src/resources/extensions/gsd/guided-flow.ts +24 -5
- package/src/resources/extensions/gsd/index.ts +54 -1
- package/src/resources/extensions/gsd/native-git-bridge.ts +30 -2
- package/src/resources/extensions/gsd/observability-validator.ts +21 -0
- package/src/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
- package/src/resources/extensions/gsd/preferences.ts +62 -1
- package/src/resources/extensions/gsd/prompts/execute-task.md +4 -3
- package/src/resources/extensions/gsd/prompts/system.md +1 -1
- package/src/resources/extensions/gsd/reports.ts +510 -0
- package/src/resources/extensions/gsd/roadmap-slices.ts +1 -1
- package/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
- package/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
- package/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
- package/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
- package/src/resources/extensions/gsd/state.ts +30 -0
- package/src/resources/extensions/gsd/templates/task-summary.md +9 -0
- package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
- package/src/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
- package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
- package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
- package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
- package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
- package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
- package/src/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
- package/src/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
- package/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
- package/src/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
- package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
- package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
- package/src/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
- package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
- package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
- package/src/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
- package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
- package/src/resources/extensions/gsd/tests/worktree.test.ts +3 -1
- package/src/resources/extensions/gsd/types.ts +38 -0
- package/src/resources/extensions/gsd/verification-evidence.ts +183 -0
- package/src/resources/extensions/gsd/verification-gate.ts +567 -0
- package/src/resources/extensions/gsd/visualizer-data.ts +25 -3
- package/src/resources/extensions/gsd/visualizer-overlay.ts +31 -21
- package/src/resources/extensions/gsd/visualizer-views.ts +15 -66
- package/src/resources/extensions/search-the-web/tool-search.ts +26 -0
- package/src/resources/extensions/shared/format-utils.ts +85 -0
- package/src/resources/extensions/shared/tests/format-utils.test.ts +153 -0
- package/src/resources/extensions/subagent/index.ts +46 -1
- package/src/resources/extensions/subagent/isolation.ts +9 -6
|
@@ -17,17 +17,17 @@ import type {
|
|
|
17
17
|
} from "@gsd/pi-coding-agent";
|
|
18
18
|
|
|
19
19
|
import { deriveState } from "./state.js";
|
|
20
|
-
import type {
|
|
21
|
-
import { loadFile,
|
|
20
|
+
import type { GSDState } from "./types.js";
|
|
21
|
+
import { loadFile, getManifestStatus, resolveAllOverrides, parsePlan, parseSummary } from "./files.js";
|
|
22
22
|
import { loadPrompt } from "./prompt-loader.js";
|
|
23
|
+
import { runVerificationGate, formatFailureContext, captureRuntimeErrors, runDependencyAudit } from "./verification-gate.js";
|
|
24
|
+
import { writeVerificationJSON } from "./verification-evidence.js";
|
|
23
25
|
export { inlinePriorMilestoneSummary } from "./files.js";
|
|
24
26
|
import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
|
|
25
27
|
import {
|
|
26
28
|
gsdRoot, resolveMilestoneFile, resolveSliceFile, resolveSlicePath,
|
|
27
29
|
resolveMilestonePath, resolveDir, resolveTasksDir, resolveTaskFile,
|
|
28
|
-
|
|
29
|
-
milestonesDir,
|
|
30
|
-
buildMilestoneFileName, buildSliceFileName, buildTaskFileName,
|
|
30
|
+
milestonesDir, buildTaskFileName,
|
|
31
31
|
} from "./paths.js";
|
|
32
32
|
import { invalidateAllCaches } from "./cache.js";
|
|
33
33
|
import { saveActivityLog, clearActivityLogState } from "./activity-log.js";
|
|
@@ -35,16 +35,42 @@ import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.
|
|
|
35
35
|
import { writeLock, clearLock, readCrashLock, formatCrashInfo, isLockProcessAlive } from "./crash-recovery.js";
|
|
36
36
|
import {
|
|
37
37
|
clearUnitRuntimeRecord,
|
|
38
|
-
formatExecuteTaskRecoveryStatus,
|
|
39
38
|
inspectExecuteTaskDurability,
|
|
40
39
|
readUnitRuntimeRecord,
|
|
41
40
|
writeUnitRuntimeRecord,
|
|
42
41
|
} from "./unit-runtime.js";
|
|
43
|
-
import { resolveAutoSupervisorConfig,
|
|
42
|
+
import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, getIsolationMode } from "./preferences.js";
|
|
44
43
|
import { sendDesktopNotification } from "./notifications.js";
|
|
45
44
|
import type { GSDPreferences } from "./preferences.js";
|
|
46
|
-
import {
|
|
47
|
-
|
|
45
|
+
import {
|
|
46
|
+
type BudgetAlertLevel,
|
|
47
|
+
getBudgetAlertLevel,
|
|
48
|
+
getNewBudgetAlertLevel,
|
|
49
|
+
getBudgetEnforcementAction,
|
|
50
|
+
} from "./auto-budget.js";
|
|
51
|
+
import {
|
|
52
|
+
markToolStart as _markToolStart,
|
|
53
|
+
markToolEnd as _markToolEnd,
|
|
54
|
+
getOldestInFlightToolAgeMs as _getOldestInFlightToolAgeMs,
|
|
55
|
+
getInFlightToolCount,
|
|
56
|
+
getOldestInFlightToolStart,
|
|
57
|
+
clearInFlightTools,
|
|
58
|
+
} from "./auto-tool-tracking.js";
|
|
59
|
+
import {
|
|
60
|
+
collectObservabilityWarnings as _collectObservabilityWarnings,
|
|
61
|
+
buildObservabilityRepairBlock,
|
|
62
|
+
} from "./auto-observability.js";
|
|
63
|
+
import { closeoutUnit } from "./auto-unit-closeout.js";
|
|
64
|
+
import { recoverTimedOutUnit } from "./auto-timeout-recovery.js";
|
|
65
|
+
import { selectAndApplyModel } from "./auto-model-selection.js";
|
|
66
|
+
import {
|
|
67
|
+
syncProjectRootToWorktree,
|
|
68
|
+
syncStateToProjectRoot,
|
|
69
|
+
readResourceVersion,
|
|
70
|
+
checkResourcesStale,
|
|
71
|
+
escapeStaleWorktree,
|
|
72
|
+
} from "./auto-worktree-sync.js";
|
|
73
|
+
// complexity-classifier + model-router imports moved to auto-model-selection.ts
|
|
48
74
|
import { initRoutingHistory, resetRoutingHistory, recordOutcome } from "./routing-history.js";
|
|
49
75
|
import {
|
|
50
76
|
checkPostUnitHooks,
|
|
@@ -57,12 +83,7 @@ import {
|
|
|
57
83
|
restoreHookState,
|
|
58
84
|
clearPersistedHookState,
|
|
59
85
|
} from "./post-unit-hooks.js";
|
|
60
|
-
|
|
61
|
-
validatePlanBoundary,
|
|
62
|
-
validateExecuteBoundary,
|
|
63
|
-
validateCompleteBoundary,
|
|
64
|
-
formatValidationIssues,
|
|
65
|
-
} from "./observability-validator.js";
|
|
86
|
+
// observability-validator imports moved to auto-observability.ts
|
|
66
87
|
import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js";
|
|
67
88
|
import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js";
|
|
68
89
|
import {
|
|
@@ -76,13 +97,13 @@ import {
|
|
|
76
97
|
import { snapshotSkills, clearSkillSnapshot } from "./skill-discovery.js";
|
|
77
98
|
import { captureAvailableSkills, getAndClearSkills, resetSkillTelemetry } from "./skill-telemetry.js";
|
|
78
99
|
import {
|
|
79
|
-
initMetrics, resetMetrics,
|
|
100
|
+
initMetrics, resetMetrics, getLedger,
|
|
80
101
|
getProjectTotals, formatCost, formatTokenCount,
|
|
81
102
|
} from "./metrics.js";
|
|
103
|
+
import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.js";
|
|
82
104
|
import { join } from "node:path";
|
|
83
105
|
import { sep as pathSep } from "node:path";
|
|
84
|
-
import {
|
|
85
|
-
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync, cpSync } from "node:fs";
|
|
106
|
+
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs";
|
|
86
107
|
import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js";
|
|
87
108
|
import {
|
|
88
109
|
autoCommitCurrentBranch,
|
|
@@ -126,18 +147,7 @@ import {
|
|
|
126
147
|
reconcileMergeState,
|
|
127
148
|
} from "./auto-recovery.js";
|
|
128
149
|
import { resolveDispatch, resetRewriteCircuitBreaker } from "./auto-dispatch.js";
|
|
129
|
-
|
|
130
|
-
buildResearchSlicePrompt,
|
|
131
|
-
buildResearchMilestonePrompt,
|
|
132
|
-
buildPlanSlicePrompt,
|
|
133
|
-
buildPlanMilestonePrompt,
|
|
134
|
-
buildExecuteTaskPrompt,
|
|
135
|
-
buildCompleteSlicePrompt,
|
|
136
|
-
buildCompleteMilestonePrompt,
|
|
137
|
-
buildReassessRoadmapPrompt,
|
|
138
|
-
buildRunUatPrompt,
|
|
139
|
-
buildReplanSlicePrompt,
|
|
140
|
-
} from "./auto-prompts.js";
|
|
150
|
+
// Prompt builders moved to auto-direct-dispatch.ts (only used there now)
|
|
141
151
|
import {
|
|
142
152
|
type AutoDashboardData,
|
|
143
153
|
updateProgressWidget as _updateProgressWidget,
|
|
@@ -145,7 +155,6 @@ import {
|
|
|
145
155
|
clearSliceProgressCache,
|
|
146
156
|
describeNextUnit as _describeNextUnit,
|
|
147
157
|
unitVerb,
|
|
148
|
-
unitPhaseLabel,
|
|
149
158
|
formatAutoElapsed as _formatAutoElapsed,
|
|
150
159
|
formatWidgetTokens,
|
|
151
160
|
hideFooter,
|
|
@@ -159,180 +168,27 @@ import {
|
|
|
159
168
|
import { isDbAvailable } from "./gsd-db.js";
|
|
160
169
|
import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js";
|
|
161
170
|
|
|
162
|
-
//
|
|
163
|
-
// When running in an auto-worktree, dispatch state (.gsd/ metadata) diverges
|
|
164
|
-
// between the worktree (where work happens) and the project root (where
|
|
165
|
-
// startAutoMode reads initial state on restart). Without syncing, restarting
|
|
166
|
-
// auto-mode reads stale state from the project root and re-dispatches
|
|
167
|
-
// already-completed units.
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Sync milestone artifacts from project root INTO worktree before deriveState.
|
|
171
|
-
* Covers the case where the LLM wrote artifacts to the main repo filesystem
|
|
172
|
-
* (e.g. via absolute paths) but the worktree has stale data. Also deletes
|
|
173
|
-
* gsd.db in the worktree so it rebuilds from fresh disk state (#853).
|
|
174
|
-
* Non-fatal — sync failure should never block dispatch.
|
|
175
|
-
*/
|
|
176
|
-
function syncProjectRootToWorktree(projectRoot: string, worktreePath: string, milestoneId: string | null): void {
|
|
177
|
-
if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
|
|
178
|
-
if (!milestoneId) return;
|
|
179
|
-
|
|
180
|
-
const prGsd = join(projectRoot, ".gsd");
|
|
181
|
-
const wtGsd = join(worktreePath, ".gsd");
|
|
182
|
-
|
|
183
|
-
// Copy milestone directory from project root to worktree if the project root
|
|
184
|
-
// has newer artifacts (e.g. slices that don't exist in the worktree yet)
|
|
185
|
-
try {
|
|
186
|
-
const srcMilestone = join(prGsd, "milestones", milestoneId);
|
|
187
|
-
const dstMilestone = join(wtGsd, "milestones", milestoneId);
|
|
188
|
-
if (existsSync(srcMilestone)) {
|
|
189
|
-
mkdirSync(dstMilestone, { recursive: true });
|
|
190
|
-
cpSync(srcMilestone, dstMilestone, { recursive: true, force: false });
|
|
191
|
-
}
|
|
192
|
-
} catch { /* non-fatal */ }
|
|
193
|
-
|
|
194
|
-
// Delete worktree gsd.db so it rebuilds from the freshly synced files.
|
|
195
|
-
// Stale DB rows are the root cause of the infinite skip loop (#853).
|
|
196
|
-
try {
|
|
197
|
-
const wtDb = join(wtGsd, "gsd.db");
|
|
198
|
-
if (existsSync(wtDb)) {
|
|
199
|
-
unlinkSync(wtDb);
|
|
200
|
-
}
|
|
201
|
-
} catch { /* non-fatal */ }
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Sync dispatch-critical .gsd/ state files from worktree to project root.
|
|
206
|
-
* Only runs when inside an auto-worktree (worktreePath differs from projectRoot).
|
|
207
|
-
* Copies: STATE.md + active milestone directory (roadmap, slice plans, task summaries).
|
|
208
|
-
* Non-fatal — sync failure should never block dispatch.
|
|
209
|
-
*/
|
|
210
|
-
function syncStateToProjectRoot(worktreePath: string, projectRoot: string, milestoneId: string | null): void {
|
|
211
|
-
if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
|
|
212
|
-
if (!milestoneId) return;
|
|
213
|
-
|
|
214
|
-
const wtGsd = join(worktreePath, ".gsd");
|
|
215
|
-
const prGsd = join(projectRoot, ".gsd");
|
|
216
|
-
|
|
217
|
-
// 1. STATE.md — the quick-glance status used by initial deriveState()
|
|
218
|
-
try {
|
|
219
|
-
const src = join(wtGsd, "STATE.md");
|
|
220
|
-
const dst = join(prGsd, "STATE.md");
|
|
221
|
-
if (existsSync(src)) cpSync(src, dst, { force: true });
|
|
222
|
-
} catch { /* non-fatal */ }
|
|
223
|
-
|
|
224
|
-
// 2. Milestone directory — ROADMAP, slice PLANs, task summaries
|
|
225
|
-
// Copy the entire milestone .gsd subtree so deriveState reads current checkboxes
|
|
226
|
-
try {
|
|
227
|
-
const srcMilestone = join(wtGsd, "milestones", milestoneId);
|
|
228
|
-
const dstMilestone = join(prGsd, "milestones", milestoneId);
|
|
229
|
-
if (existsSync(srcMilestone)) {
|
|
230
|
-
mkdirSync(dstMilestone, { recursive: true });
|
|
231
|
-
cpSync(srcMilestone, dstMilestone, { recursive: true, force: true });
|
|
232
|
-
}
|
|
233
|
-
} catch { /* non-fatal */ }
|
|
171
|
+
// Worktree sync, resource staleness, stale worktree escape → auto-worktree-sync.ts
|
|
234
172
|
|
|
235
|
-
|
|
236
|
-
// Prevents already-completed units from being re-dispatched after crash/restart.
|
|
237
|
-
const srcKeysFile = join(wtGsd, "completed-units.json");
|
|
238
|
-
const dstKeysFile = join(prGsd, "completed-units.json");
|
|
239
|
-
if (existsSync(srcKeysFile)) {
|
|
240
|
-
try {
|
|
241
|
-
const srcKeys: string[] = JSON.parse(readFileSync(srcKeysFile, "utf8"));
|
|
242
|
-
let dstKeys: string[] = [];
|
|
243
|
-
if (existsSync(dstKeysFile)) {
|
|
244
|
-
try { dstKeys = JSON.parse(readFileSync(dstKeysFile, "utf8")); } catch { /* ignore corrupt dst */ }
|
|
245
|
-
}
|
|
246
|
-
const merged = [...new Set([...dstKeys, ...srcKeys])];
|
|
247
|
-
writeFileSync(dstKeysFile, JSON.stringify(merged, null, 2));
|
|
248
|
-
} catch { /* non-fatal */ }
|
|
249
|
-
}
|
|
173
|
+
// ─── Session State ─────────────────────────────────────────────────────────
|
|
250
174
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
} catch { /* non-fatal */ }
|
|
263
|
-
}
|
|
175
|
+
import {
|
|
176
|
+
AutoSession,
|
|
177
|
+
MAX_UNIT_DISPATCHES, STUB_RECOVERY_THRESHOLD, MAX_LIFETIME_DISPATCHES,
|
|
178
|
+
MAX_CONSECUTIVE_SKIPS, DISPATCH_GAP_TIMEOUT_MS, MAX_SKIP_DEPTH,
|
|
179
|
+
} from "./auto/session.js";
|
|
180
|
+
import type { CompletedUnit, CurrentUnit, UnitRouting, StartModel, PendingVerificationRetry } from "./auto/session.js";
|
|
181
|
+
export {
|
|
182
|
+
MAX_UNIT_DISPATCHES, STUB_RECOVERY_THRESHOLD, MAX_LIFETIME_DISPATCHES,
|
|
183
|
+
MAX_CONSECUTIVE_SKIPS, DISPATCH_GAP_TIMEOUT_MS, MAX_SKIP_DEPTH,
|
|
184
|
+
} from "./auto/session.js";
|
|
185
|
+
export type { CompletedUnit, CurrentUnit, UnitRouting, StartModel } from "./auto/session.js";
|
|
264
186
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
let active = false;
|
|
268
|
-
let paused = false;
|
|
269
|
-
let stepMode = false;
|
|
270
|
-
let verbose = false;
|
|
271
|
-
let cmdCtx: ExtensionCommandContext | null = null;
|
|
272
|
-
let basePath = "";
|
|
273
|
-
let originalBasePath = "";
|
|
274
|
-
let gitService: GitServiceImpl | null = null;
|
|
275
|
-
|
|
276
|
-
/** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */
|
|
277
|
-
const unitDispatchCount = new Map<string, number>();
|
|
278
|
-
const MAX_UNIT_DISPATCHES = 3;
|
|
279
|
-
/** Retry index at which a stub summary placeholder is written when the summary is still absent. */
|
|
280
|
-
const STUB_RECOVERY_THRESHOLD = 2;
|
|
281
|
-
/** Hard cap on total dispatches per unit across ALL reconciliation cycles.
|
|
282
|
-
* unitDispatchCount can be reset by loop-recovery/self-repair paths, but this
|
|
283
|
-
* counter is never reset — it catches infinite reconciliation loops where
|
|
284
|
-
* artifacts exist but deriveState keeps returning the same unit. */
|
|
285
|
-
const unitLifetimeDispatches = new Map<string, number>();
|
|
286
|
-
const MAX_LIFETIME_DISPATCHES = 6;
|
|
287
|
-
|
|
288
|
-
/** Tracks recovery attempt count per unit for backoff and diagnostics. */
|
|
289
|
-
const unitRecoveryCount = new Map<string, number>();
|
|
290
|
-
|
|
291
|
-
/** Track consecutive skips per unit — catches infinite skip loops where deriveState
|
|
292
|
-
* keeps returning the same already-completed unit. Reset on any real dispatch. */
|
|
293
|
-
const unitConsecutiveSkips = new Map<string, number>();
|
|
294
|
-
const MAX_CONSECUTIVE_SKIPS = 3;
|
|
295
|
-
|
|
296
|
-
/** Persisted completed-unit keys — survives restarts. Loaded from .gsd/completed-units.json. */
|
|
297
|
-
const completedKeySet = new Set<string>();
|
|
298
|
-
|
|
299
|
-
/** Resource version captured at auto-mode start. If the managed-resources
|
|
300
|
-
* manifest version changes mid-session (e.g. npm update -g gsd-pi),
|
|
301
|
-
* templates on disk may expect variables the in-memory code doesn't provide.
|
|
302
|
-
* Detect this and stop gracefully instead of crashing.
|
|
303
|
-
* Uses gsdVersion (semver) instead of syncedAt (timestamp) so that
|
|
304
|
-
* launching a second session doesn't falsely trigger staleness (#804). */
|
|
305
|
-
let resourceVersionOnStart: string | null = null;
|
|
306
|
-
|
|
307
|
-
function readResourceVersion(): string | null {
|
|
308
|
-
const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
|
|
309
|
-
const manifestPath = join(agentDir, "managed-resources.json");
|
|
310
|
-
try {
|
|
311
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
312
|
-
return typeof manifest?.gsdVersion === "string" ? manifest.gsdVersion : null;
|
|
313
|
-
} catch {
|
|
314
|
-
return null;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
187
|
+
const s = new AutoSession();
|
|
317
188
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const current = readResourceVersion();
|
|
321
|
-
if (current === null) return null;
|
|
322
|
-
if (current !== resourceVersionOnStart) {
|
|
323
|
-
return "GSD resources were updated since this session started. Restart gsd to load the new code.";
|
|
324
|
-
}
|
|
325
|
-
return null;
|
|
326
|
-
}
|
|
189
|
+
/** Throttle STATE.md rebuilds — at most once per 30 seconds */
|
|
190
|
+
const STATE_REBUILD_MIN_INTERVAL_MS = 30_000;
|
|
327
191
|
|
|
328
|
-
/**
|
|
329
|
-
* Resolve whether auto-mode should use worktree isolation.
|
|
330
|
-
* Returns true for worktree mode (default), false for branch and none modes.
|
|
331
|
-
* Branch mode works directly in the project root — useful for repos
|
|
332
|
-
* with git submodules where worktrees don't work well (#531).
|
|
333
|
-
* None mode skips all worktree and milestone-branch logic — commits
|
|
334
|
-
* land on the current branch with no isolation (#M001-S02).
|
|
335
|
-
*/
|
|
336
192
|
export function shouldUseWorktreeIsolation(): boolean {
|
|
337
193
|
const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
|
|
338
194
|
if (prefs?.isolation === "none") return false;
|
|
@@ -340,49 +196,19 @@ export function shouldUseWorktreeIsolation(): boolean {
|
|
|
340
196
|
return true; // default: worktree
|
|
341
197
|
}
|
|
342
198
|
|
|
343
|
-
/**
|
|
344
|
-
* Detect and escape a stale worktree cwd (#608).
|
|
345
|
-
*
|
|
346
|
-
* After milestone completion + merge, the worktree directory is removed but
|
|
347
|
-
* the process cwd may still point inside `.gsd/worktrees/<MID>/`.
|
|
348
|
-
* When a new session starts, `process.cwd()` is passed as `base` to startAuto
|
|
349
|
-
* and all subsequent writes land in the wrong directory. This function detects
|
|
350
|
-
* that scenario and chdir back to the project root.
|
|
351
|
-
*
|
|
352
|
-
* Returns the corrected base path.
|
|
353
|
-
*/
|
|
354
|
-
function escapeStaleWorktree(base: string): string {
|
|
355
|
-
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
|
|
356
|
-
const idx = base.indexOf(marker);
|
|
357
|
-
if (idx === -1) return base;
|
|
199
|
+
/** Crash recovery prompt — set by startAuto, consumed by first dispatchNextUnit */
|
|
358
200
|
|
|
359
|
-
|
|
360
|
-
const projectRoot = base.slice(0, idx);
|
|
361
|
-
try {
|
|
362
|
-
process.chdir(projectRoot);
|
|
363
|
-
} catch {
|
|
364
|
-
// If chdir fails, return the original — caller will handle errors downstream
|
|
365
|
-
return base;
|
|
366
|
-
}
|
|
367
|
-
return projectRoot;
|
|
368
|
-
}
|
|
201
|
+
/** Pending verification retry — set when gate fails with retries remaining, consumed by dispatchNextUnit */
|
|
369
202
|
|
|
370
|
-
/**
|
|
371
|
-
let pendingCrashRecovery: string | null = null;
|
|
203
|
+
/** Verification retry count per unitId — separate from s.unitDispatchCount which tracks artifact-missing retries */
|
|
372
204
|
|
|
373
205
|
/** Session file path captured at pause — used to synthesize recovery briefing on resume */
|
|
374
|
-
let pausedSessionFile: string | null = null;
|
|
375
206
|
|
|
376
207
|
/** Dashboard tracking */
|
|
377
|
-
let autoStartTime: number = 0;
|
|
378
|
-
let completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[] = [];
|
|
379
|
-
let currentUnit: { type: string; id: string; startedAt: number } | null = null;
|
|
380
208
|
|
|
381
209
|
/** Track dynamic routing decision for the current unit (for metrics) */
|
|
382
|
-
let currentUnitRouting: { tier: string; modelDowngraded: boolean } | null = null;
|
|
383
210
|
|
|
384
211
|
/** Queue of quick-task captures awaiting dispatch after triage resolution */
|
|
385
|
-
let pendingQuickTasks: import("./captures.js").CaptureEntry[] = [];
|
|
386
212
|
|
|
387
213
|
/**
|
|
388
214
|
* Model captured at auto-mode start. Used to prevent model bleed between
|
|
@@ -391,78 +217,42 @@ let pendingQuickTasks: import("./captures.js").CaptureEntry[] = [];
|
|
|
391
217
|
* the session's original model is re-applied instead of reading from
|
|
392
218
|
* the shared global settings (which another instance may have overwritten).
|
|
393
219
|
*/
|
|
394
|
-
let autoModeStartModel: { provider: string; id: string } | null = null;
|
|
395
220
|
|
|
396
221
|
/** Track current milestone to detect transitions */
|
|
397
|
-
let currentMilestoneId: string | null = null;
|
|
398
|
-
let lastBudgetAlertLevel: BudgetAlertLevel = 0;
|
|
399
222
|
|
|
400
223
|
/** Model the user had selected before auto-mode started */
|
|
401
|
-
let originalModelId: string | null = null;
|
|
402
|
-
let originalModelProvider: string | null = null;
|
|
403
224
|
|
|
404
225
|
/** Progress-aware timeout supervision */
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
let idleWatchdogHandle: ReturnType<typeof setInterval> | null = null;
|
|
226
|
+
|
|
227
|
+
/** Context-pressure continue-here monitor — fires once when context usage >= 70% */
|
|
408
228
|
|
|
409
229
|
/** Dispatch gap watchdog — detects when the state machine stalls between units.
|
|
410
230
|
* After handleAgentEnd completes, if auto-mode is still active but no new unit
|
|
411
231
|
* has been dispatched (sendMessage not called), this timer fires to force a
|
|
412
232
|
* re-evaluation. Covers the case where dispatchNextUnit silently fails or
|
|
413
233
|
* an unhandled error kills the dispatch chain. */
|
|
414
|
-
let dispatchGapHandle: ReturnType<typeof setTimeout> | null = null;
|
|
415
|
-
const DISPATCH_GAP_TIMEOUT_MS = 5_000; // 5 seconds
|
|
416
234
|
|
|
417
235
|
/** Prompt character measurement for token savings analysis (R051). */
|
|
418
|
-
let lastPromptCharCount: number | undefined;
|
|
419
|
-
let lastBaselineCharCount: number | undefined;
|
|
420
236
|
|
|
421
237
|
/** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */
|
|
422
|
-
let _sigtermHandler: (() => void) | null = null;
|
|
423
238
|
|
|
424
239
|
/**
|
|
425
240
|
* Tool calls currently being executed — prevents false idle detection during long-running tools.
|
|
426
241
|
* Maps toolCallId → start timestamp (ms) so the idle watchdog can detect tools that have been
|
|
427
242
|
* running suspiciously long (e.g., a Bash command hung because `&` kept stdout open).
|
|
428
243
|
*/
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
type BudgetAlertLevel = 0 | 75 | 80 | 90 | 100;
|
|
432
|
-
|
|
433
|
-
export function getBudgetAlertLevel(budgetPct: number): BudgetAlertLevel {
|
|
434
|
-
if (budgetPct >= 1.0) return 100;
|
|
435
|
-
if (budgetPct >= 0.90) return 90;
|
|
436
|
-
if (budgetPct >= 0.80) return 80;
|
|
437
|
-
if (budgetPct >= 0.75) return 75;
|
|
438
|
-
return 0;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
export function getNewBudgetAlertLevel(previousLevel: BudgetAlertLevel, budgetPct: number): BudgetAlertLevel | null {
|
|
442
|
-
const currentLevel = getBudgetAlertLevel(budgetPct);
|
|
443
|
-
if (currentLevel === 0 || currentLevel <= previousLevel) return null;
|
|
444
|
-
return currentLevel;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
export function getBudgetEnforcementAction(
|
|
448
|
-
enforcement: BudgetEnforcementMode,
|
|
449
|
-
budgetPct: number,
|
|
450
|
-
): "none" | "warn" | "pause" | "halt" {
|
|
451
|
-
if (budgetPct < 1.0) return "none";
|
|
452
|
-
if (enforcement === "halt") return "halt";
|
|
453
|
-
if (enforcement === "pause") return "pause";
|
|
454
|
-
return "warn";
|
|
455
|
-
}
|
|
244
|
+
// Re-export budget utilities for external consumers
|
|
245
|
+
export { getBudgetAlertLevel, getNewBudgetAlertLevel, getBudgetEnforcementAction } from "./auto-budget.js";
|
|
456
246
|
|
|
457
247
|
/** Wrapper: register SIGTERM handler and store reference. */
|
|
458
248
|
function registerSigtermHandler(currentBasePath: string): void {
|
|
459
|
-
|
|
249
|
+
s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler);
|
|
460
250
|
}
|
|
461
251
|
|
|
462
252
|
/** Wrapper: deregister SIGTERM handler and clear reference. */
|
|
463
253
|
function deregisterSigtermHandler(): void {
|
|
464
|
-
_deregisterSigtermHandler(
|
|
465
|
-
|
|
254
|
+
_deregisterSigtermHandler(s.sigtermHandler);
|
|
255
|
+
s.sigtermHandler = null;
|
|
466
256
|
}
|
|
467
257
|
|
|
468
258
|
export { type AutoDashboardData } from "./auto-dashboard.js";
|
|
@@ -473,21 +263,18 @@ export function getAutoDashboardData(): AutoDashboardData {
|
|
|
473
263
|
// Pending capture count — lazy check, non-fatal
|
|
474
264
|
let pendingCaptureCount = 0;
|
|
475
265
|
try {
|
|
476
|
-
if (basePath) {
|
|
477
|
-
pendingCaptureCount = countPendingCaptures(basePath);
|
|
266
|
+
if (s.basePath) {
|
|
267
|
+
pendingCaptureCount = countPendingCaptures(s.basePath);
|
|
478
268
|
}
|
|
479
269
|
} catch {
|
|
480
270
|
// Non-fatal — captures module may not be loaded
|
|
481
271
|
}
|
|
482
|
-
return {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
currentUnit: currentUnit ? { ...currentUnit } : null,
|
|
489
|
-
completedUnits: [...completedUnits],
|
|
490
|
-
basePath,
|
|
272
|
+
return { active: s.active, paused: s.paused,
|
|
273
|
+
stepMode: s.stepMode,
|
|
274
|
+
startTime: s.autoStartTime,
|
|
275
|
+
elapsed: (s.active || s.paused) ? Date.now() - s.autoStartTime : 0,
|
|
276
|
+
currentUnit: s.currentUnit ? { ...s.currentUnit } : null,
|
|
277
|
+
completedUnits: [...s.completedUnits], basePath: s.basePath,
|
|
491
278
|
totalCost: totals?.cost ?? 0,
|
|
492
279
|
totalTokens: totals?.tokens.total ?? 0,
|
|
493
280
|
pendingCaptureCount,
|
|
@@ -497,37 +284,24 @@ export function getAutoDashboardData(): AutoDashboardData {
|
|
|
497
284
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
498
285
|
|
|
499
286
|
export function isAutoActive(): boolean {
|
|
500
|
-
return active;
|
|
287
|
+
return s.active;
|
|
501
288
|
}
|
|
502
289
|
|
|
503
290
|
export function isAutoPaused(): boolean {
|
|
504
|
-
return paused;
|
|
291
|
+
return s.paused;
|
|
505
292
|
}
|
|
506
293
|
|
|
507
|
-
|
|
508
|
-
* Mark a tool execution as in-flight. Called from index.ts on tool_execution_start.
|
|
509
|
-
* Records start time so the idle watchdog can detect tools hung longer than the idle timeout.
|
|
510
|
-
*/
|
|
294
|
+
// Tool tracking — delegates to auto-tool-tracking.ts
|
|
511
295
|
export function markToolStart(toolCallId: string): void {
|
|
512
|
-
|
|
513
|
-
inFlightTools.set(toolCallId, Date.now());
|
|
296
|
+
_markToolStart(toolCallId, s.active);
|
|
514
297
|
}
|
|
515
298
|
|
|
516
|
-
/**
|
|
517
|
-
* Mark a tool execution as completed. Called from index.ts on tool_execution_end.
|
|
518
|
-
*/
|
|
519
299
|
export function markToolEnd(toolCallId: string): void {
|
|
520
|
-
|
|
300
|
+
_markToolEnd(toolCallId);
|
|
521
301
|
}
|
|
522
302
|
|
|
523
|
-
/**
|
|
524
|
-
* Returns the age (ms) of the oldest currently in-flight tool, or 0 if none.
|
|
525
|
-
* Exported for testing.
|
|
526
|
-
*/
|
|
527
303
|
export function getOldestInFlightToolAgeMs(): number {
|
|
528
|
-
|
|
529
|
-
const oldestStart = Math.min(...inFlightTools.values());
|
|
530
|
-
return Date.now() - oldestStart;
|
|
304
|
+
return _getOldestInFlightToolAgeMs();
|
|
531
305
|
}
|
|
532
306
|
|
|
533
307
|
/**
|
|
@@ -536,7 +310,7 @@ export function getOldestInFlightToolAgeMs(): number {
|
|
|
536
310
|
* a second terminal can discover and stop a running auto-mode session.
|
|
537
311
|
*/
|
|
538
312
|
function lockBase(): string {
|
|
539
|
-
return originalBasePath || basePath;
|
|
313
|
+
return s.originalBasePath || s.basePath;
|
|
540
314
|
}
|
|
541
315
|
|
|
542
316
|
/**
|
|
@@ -566,50 +340,65 @@ export function stopAutoRemote(projectRoot: string): { found: boolean; pid?: num
|
|
|
566
340
|
}
|
|
567
341
|
|
|
568
342
|
export function isStepMode(): boolean {
|
|
569
|
-
return stepMode;
|
|
343
|
+
return s.stepMode;
|
|
570
344
|
}
|
|
571
345
|
|
|
572
346
|
function clearUnitTimeout(): void {
|
|
573
|
-
if (unitTimeoutHandle) {
|
|
574
|
-
clearTimeout(unitTimeoutHandle);
|
|
575
|
-
unitTimeoutHandle = null;
|
|
347
|
+
if (s.unitTimeoutHandle) {
|
|
348
|
+
clearTimeout(s.unitTimeoutHandle);
|
|
349
|
+
s.unitTimeoutHandle = null;
|
|
350
|
+
}
|
|
351
|
+
if (s.wrapupWarningHandle) {
|
|
352
|
+
clearTimeout(s.wrapupWarningHandle);
|
|
353
|
+
s.wrapupWarningHandle = null;
|
|
576
354
|
}
|
|
577
|
-
if (
|
|
578
|
-
|
|
579
|
-
|
|
355
|
+
if (s.idleWatchdogHandle) {
|
|
356
|
+
clearInterval(s.idleWatchdogHandle);
|
|
357
|
+
s.idleWatchdogHandle = null;
|
|
580
358
|
}
|
|
581
|
-
if (
|
|
582
|
-
clearInterval(
|
|
583
|
-
|
|
359
|
+
if (s.continueHereHandle) {
|
|
360
|
+
clearInterval(s.continueHereHandle);
|
|
361
|
+
s.continueHereHandle = null;
|
|
584
362
|
}
|
|
585
|
-
|
|
363
|
+
clearInFlightTools();
|
|
586
364
|
clearDispatchGapWatchdog();
|
|
587
365
|
}
|
|
588
366
|
|
|
589
367
|
function clearDispatchGapWatchdog(): void {
|
|
590
|
-
if (dispatchGapHandle) {
|
|
591
|
-
clearTimeout(dispatchGapHandle);
|
|
592
|
-
dispatchGapHandle = null;
|
|
368
|
+
if (s.dispatchGapHandle) {
|
|
369
|
+
clearTimeout(s.dispatchGapHandle);
|
|
370
|
+
s.dispatchGapHandle = null;
|
|
593
371
|
}
|
|
594
372
|
}
|
|
595
373
|
|
|
374
|
+
/** Build snapshot metric opts, enriching with continueHereFired from the runtime record. */
|
|
375
|
+
function buildSnapshotOpts(unitType: string, unitId: string): { continueHereFired?: boolean; promptCharCount?: number; baselineCharCount?: number } & Record<string, unknown> {
|
|
376
|
+
const runtime = s.currentUnit ? readUnitRuntimeRecord(s.basePath, unitType, unitId) : null;
|
|
377
|
+
return {
|
|
378
|
+
promptCharCount: s.lastPromptCharCount,
|
|
379
|
+
baselineCharCount: s.lastBaselineCharCount,
|
|
380
|
+
...(s.currentUnitRouting ?? {}),
|
|
381
|
+
...(runtime?.continueHereFired ? { continueHereFired: true } : {}),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
596
385
|
/**
|
|
597
386
|
* Start a watchdog that fires if no new unit is dispatched within DISPATCH_GAP_TIMEOUT_MS
|
|
598
387
|
* after handleAgentEnd completes. This catches the case where the dispatch chain silently
|
|
599
|
-
* breaks (e.g., unhandled exception in dispatchNextUnit) and auto-mode is left active but idle.
|
|
388
|
+
* breaks (e.g., unhandled exception in dispatchNextUnit) and auto-mode is left s.active but idle.
|
|
600
389
|
*
|
|
601
390
|
* The watchdog is cleared on the next successful unit dispatch (clearUnitTimeout is called
|
|
602
391
|
* at the start of handleAgentEnd, which calls clearDispatchGapWatchdog).
|
|
603
392
|
*/
|
|
604
393
|
function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void {
|
|
605
394
|
clearDispatchGapWatchdog();
|
|
606
|
-
dispatchGapHandle = setTimeout(async () => {
|
|
607
|
-
dispatchGapHandle = null;
|
|
608
|
-
if (!active || !cmdCtx) return;
|
|
395
|
+
s.dispatchGapHandle = setTimeout(async () => {
|
|
396
|
+
s.dispatchGapHandle = null;
|
|
397
|
+
if (!s.active || !s.cmdCtx) return;
|
|
609
398
|
|
|
610
399
|
// Auto-mode is active but no unit was dispatched — the state machine stalled.
|
|
611
400
|
// Re-derive state and attempt a fresh dispatch.
|
|
612
|
-
if (verbose) {
|
|
401
|
+
if (s.verbose) {
|
|
613
402
|
ctx.ui.notify(
|
|
614
403
|
"Dispatch gap detected — re-evaluating state.",
|
|
615
404
|
"info",
|
|
@@ -626,37 +415,37 @@ function startDispatchGapWatchdog(ctx: ExtensionContext, pi: ExtensionAPI): void
|
|
|
626
415
|
|
|
627
416
|
// If dispatchNextUnit returned normally but still didn't dispatch a unit
|
|
628
417
|
// (no sendMessage called → no timeout set), auto-mode is permanently
|
|
629
|
-
// stalled. Stop cleanly instead of leaving it active but idle (#537).
|
|
630
|
-
if (active && !unitTimeoutHandle && !wrapupWarningHandle) {
|
|
418
|
+
// stalled. Stop cleanly instead of leaving it s.active but idle (#537).
|
|
419
|
+
if (s.active && !s.unitTimeoutHandle && !s.wrapupWarningHandle) {
|
|
631
420
|
await stopAuto(ctx, pi, "Stalled — no dispatchable unit after retry");
|
|
632
421
|
}
|
|
633
422
|
}, DISPATCH_GAP_TIMEOUT_MS);
|
|
634
423
|
}
|
|
635
424
|
|
|
636
425
|
export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason?: string): Promise<void> {
|
|
637
|
-
if (!active && !paused) return;
|
|
426
|
+
if (!s.active && !s.paused) return;
|
|
638
427
|
const reasonSuffix = reason ? ` — ${reason}` : "";
|
|
639
428
|
clearUnitTimeout();
|
|
640
429
|
if (lockBase()) clearLock(lockBase());
|
|
641
430
|
clearSkillSnapshot();
|
|
642
431
|
resetSkillTelemetry();
|
|
643
|
-
|
|
644
|
-
|
|
432
|
+
s.dispatching = false;
|
|
433
|
+
s.skipDepth = 0;
|
|
645
434
|
|
|
646
435
|
// Remove SIGTERM handler registered at auto-mode start
|
|
647
436
|
deregisterSigtermHandler();
|
|
648
437
|
|
|
649
|
-
// ── Auto-worktree: exit worktree and reset basePath on stop ──
|
|
438
|
+
// ── Auto-worktree: exit worktree and reset s.basePath on stop ──
|
|
650
439
|
// Preserve the milestone branch so the next /gsd auto can re-enter
|
|
651
440
|
// where it left off. The branch is only deleted during milestone
|
|
652
441
|
// completion (mergeMilestoneToMain) after the work has been squash-merged.
|
|
653
|
-
if (currentMilestoneId && isInAutoWorktree(basePath)) {
|
|
442
|
+
if (s.currentMilestoneId && isInAutoWorktree(s.basePath)) {
|
|
654
443
|
try {
|
|
655
444
|
// Auto-commit any dirty state before leaving so work isn't lost
|
|
656
|
-
try { autoCommitCurrentBranch(basePath, "stop", currentMilestoneId); } catch { /* non-fatal */ }
|
|
657
|
-
teardownAutoWorktree(originalBasePath, currentMilestoneId, { preserveBranch: true });
|
|
658
|
-
basePath = originalBasePath;
|
|
659
|
-
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
445
|
+
try { autoCommitCurrentBranch(s.basePath, "stop", s.currentMilestoneId); } catch { /* non-fatal */ }
|
|
446
|
+
teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId, { preserveBranch: true });
|
|
447
|
+
s.basePath = s.originalBasePath;
|
|
448
|
+
s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
660
449
|
ctx?.ui.notify("Exited auto-worktree (branch preserved for resume).", "info");
|
|
661
450
|
} catch (err) {
|
|
662
451
|
ctx?.ui.notify(
|
|
@@ -677,10 +466,10 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
|
|
|
677
466
|
// Always restore cwd to project root on stop (#608).
|
|
678
467
|
// Even if isInAutoWorktree returned false (e.g., module state was already
|
|
679
468
|
// cleared by mergeMilestoneToMain), the process cwd may still be inside
|
|
680
|
-
// the worktree directory. Force it back to originalBasePath.
|
|
681
|
-
if (originalBasePath) {
|
|
682
|
-
basePath = originalBasePath;
|
|
683
|
-
try { process.chdir(basePath); } catch { /* best-effort */ }
|
|
469
|
+
// the worktree directory. Force it back to s.originalBasePath.
|
|
470
|
+
if (s.originalBasePath) {
|
|
471
|
+
s.basePath = s.originalBasePath;
|
|
472
|
+
try { process.chdir(s.basePath); } catch { /* best-effort */ }
|
|
684
473
|
}
|
|
685
474
|
|
|
686
475
|
const ledger = getLedger();
|
|
@@ -695,8 +484,8 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
|
|
|
695
484
|
}
|
|
696
485
|
|
|
697
486
|
// Sync disk state so next resume starts from accurate state
|
|
698
|
-
if (basePath) {
|
|
699
|
-
try { await rebuildState(basePath); } catch { /* non-fatal */ }
|
|
487
|
+
if (s.basePath) {
|
|
488
|
+
try { await rebuildState(s.basePath); } catch { /* non-fatal */ }
|
|
700
489
|
}
|
|
701
490
|
|
|
702
491
|
// Write debug summary before resetting state
|
|
@@ -710,41 +499,45 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
|
|
|
710
499
|
resetMetrics();
|
|
711
500
|
resetRoutingHistory();
|
|
712
501
|
resetHookState();
|
|
713
|
-
if (basePath) clearPersistedHookState(basePath);
|
|
714
|
-
active = false;
|
|
715
|
-
paused = false;
|
|
716
|
-
stepMode = false;
|
|
717
|
-
unitDispatchCount.clear();
|
|
718
|
-
unitRecoveryCount.clear();
|
|
719
|
-
unitConsecutiveSkips.clear();
|
|
720
|
-
|
|
721
|
-
lastBudgetAlertLevel = 0;
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
502
|
+
if (s.basePath) clearPersistedHookState(s.basePath);
|
|
503
|
+
s.active = false;
|
|
504
|
+
s.paused = false;
|
|
505
|
+
s.stepMode = false;
|
|
506
|
+
s.unitDispatchCount.clear();
|
|
507
|
+
s.unitRecoveryCount.clear();
|
|
508
|
+
s.unitConsecutiveSkips.clear();
|
|
509
|
+
clearInFlightTools();
|
|
510
|
+
s.lastBudgetAlertLevel = 0;
|
|
511
|
+
s.lastStateRebuildAt = 0;
|
|
512
|
+
s.unitLifetimeDispatches.clear();
|
|
513
|
+
s.currentUnit = null;
|
|
514
|
+
s.autoModeStartModel = null;
|
|
515
|
+
s.currentMilestoneId = null;
|
|
516
|
+
s.originalBasePath = "";
|
|
517
|
+
s.completedUnits = [];
|
|
518
|
+
s.pendingQuickTasks = [];
|
|
729
519
|
clearSliceProgressCache();
|
|
730
520
|
clearActivityLogState();
|
|
731
521
|
resetProactiveHealing();
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
522
|
+
s.recentlyEvictedKeys.clear();
|
|
523
|
+
s.pendingCrashRecovery = null;
|
|
524
|
+
s.pendingVerificationRetry = null;
|
|
525
|
+
s.verificationRetryCount.clear();
|
|
526
|
+
s.pausedSessionFile = null;
|
|
527
|
+
s.handlingAgentEnd = false;
|
|
735
528
|
ctx?.ui.setStatus("gsd-auto", undefined);
|
|
736
529
|
ctx?.ui.setWidget("gsd-progress", undefined);
|
|
737
530
|
ctx?.ui.setFooter(undefined);
|
|
738
531
|
|
|
739
532
|
// Restore the user's original model
|
|
740
|
-
if (pi && ctx && originalModelId && originalModelProvider) {
|
|
741
|
-
const original = ctx.modelRegistry.find(originalModelProvider, originalModelId);
|
|
533
|
+
if (pi && ctx && s.originalModelId && s.originalModelProvider) {
|
|
534
|
+
const original = ctx.modelRegistry.find(s.originalModelProvider, s.originalModelId);
|
|
742
535
|
if (original) await pi.setModel(original);
|
|
743
|
-
originalModelId = null;
|
|
744
|
-
originalModelProvider = null;
|
|
536
|
+
s.originalModelId = null;
|
|
537
|
+
s.originalModelProvider = null;
|
|
745
538
|
}
|
|
746
539
|
|
|
747
|
-
cmdCtx = null;
|
|
540
|
+
s.cmdCtx = null;
|
|
748
541
|
}
|
|
749
542
|
|
|
750
543
|
/**
|
|
@@ -753,29 +546,31 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
|
|
|
753
546
|
* from disk state. Called when the user presses Escape during auto-mode.
|
|
754
547
|
*/
|
|
755
548
|
export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Promise<void> {
|
|
756
|
-
if (!active) return;
|
|
549
|
+
if (!s.active) return;
|
|
757
550
|
clearUnitTimeout();
|
|
758
551
|
|
|
759
552
|
// Capture the current session file before clearing state — used for
|
|
760
553
|
// recovery briefing on resume so the next agent knows what already happened.
|
|
761
|
-
pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null;
|
|
554
|
+
s.pausedSessionFile = ctx?.sessionManager?.getSessionFile() ?? null;
|
|
762
555
|
|
|
763
556
|
if (lockBase()) clearLock(lockBase());
|
|
764
557
|
|
|
765
558
|
// Remove SIGTERM handler registered at auto-mode start
|
|
766
559
|
deregisterSigtermHandler();
|
|
767
560
|
|
|
768
|
-
active = false;
|
|
769
|
-
paused = true;
|
|
770
|
-
|
|
771
|
-
|
|
561
|
+
s.active = false;
|
|
562
|
+
s.paused = true;
|
|
563
|
+
s.pendingVerificationRetry = null;
|
|
564
|
+
s.verificationRetryCount.clear();
|
|
565
|
+
// Preserve: s.unitDispatchCount, s.currentUnit, s.basePath, s.verbose, s.cmdCtx,
|
|
566
|
+
// s.completedUnits, s.autoStartTime, s.currentMilestoneId, s.originalModelId
|
|
772
567
|
// — all needed for resume and dashboard display
|
|
773
568
|
ctx?.ui.setStatus("gsd-auto", "paused");
|
|
774
569
|
ctx?.ui.setWidget("gsd-progress", undefined);
|
|
775
570
|
ctx?.ui.setFooter(undefined);
|
|
776
|
-
const resumeCmd = stepMode ? "/gsd next" : "/gsd auto";
|
|
571
|
+
const resumeCmd = s.stepMode ? "/gsd next" : "/gsd auto";
|
|
777
572
|
ctx?.ui.notify(
|
|
778
|
-
`${stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,
|
|
573
|
+
`${s.stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,
|
|
779
574
|
"info",
|
|
780
575
|
);
|
|
781
576
|
}
|
|
@@ -797,38 +592,38 @@ export async function startAuto(
|
|
|
797
592
|
|
|
798
593
|
// If resuming from paused state, just re-activate and dispatch next unit.
|
|
799
594
|
// The conversation is still intact — no need to reinitialize everything.
|
|
800
|
-
if (paused) {
|
|
801
|
-
paused = false;
|
|
802
|
-
active = true;
|
|
803
|
-
verbose = verboseMode;
|
|
595
|
+
if (s.paused) {
|
|
596
|
+
s.paused = false;
|
|
597
|
+
s.active = true;
|
|
598
|
+
s.verbose = verboseMode;
|
|
804
599
|
// Allow switching between step/auto on resume
|
|
805
|
-
stepMode = requestedStepMode;
|
|
806
|
-
cmdCtx = ctx;
|
|
807
|
-
basePath = base;
|
|
808
|
-
unitDispatchCount.clear();
|
|
809
|
-
unitLifetimeDispatches.clear();
|
|
810
|
-
unitConsecutiveSkips.clear();
|
|
600
|
+
s.stepMode = requestedStepMode;
|
|
601
|
+
s.cmdCtx = ctx;
|
|
602
|
+
s.basePath = base;
|
|
603
|
+
s.unitDispatchCount.clear();
|
|
604
|
+
s.unitLifetimeDispatches.clear();
|
|
605
|
+
s.unitConsecutiveSkips.clear();
|
|
811
606
|
// Re-initialize metrics in case ledger was lost during pause
|
|
812
607
|
if (!getLedger()) initMetrics(base);
|
|
813
608
|
// Ensure milestone ID is set on git service for integration branch resolution
|
|
814
|
-
if (currentMilestoneId) setActiveMilestoneId(base, currentMilestoneId);
|
|
609
|
+
if (s.currentMilestoneId) setActiveMilestoneId(base, s.currentMilestoneId);
|
|
815
610
|
|
|
816
611
|
// ── Auto-worktree: re-enter worktree on resume if not already inside ──
|
|
817
612
|
// Skip if already inside a worktree (manual /worktree) to prevent nesting.
|
|
818
613
|
// Skip entirely in branch or none isolation mode (#531).
|
|
819
|
-
if (currentMilestoneId && shouldUseWorktreeIsolation() && originalBasePath && !isInAutoWorktree(basePath) && !detectWorktreeName(basePath) && !detectWorktreeName(originalBasePath)) {
|
|
614
|
+
if (s.currentMilestoneId && shouldUseWorktreeIsolation() && s.originalBasePath && !isInAutoWorktree(s.basePath) && !detectWorktreeName(s.basePath) && !detectWorktreeName(s.originalBasePath)) {
|
|
820
615
|
try {
|
|
821
|
-
const existingWtPath = getAutoWorktreePath(originalBasePath, currentMilestoneId);
|
|
616
|
+
const existingWtPath = getAutoWorktreePath(s.originalBasePath, s.currentMilestoneId);
|
|
822
617
|
if (existingWtPath) {
|
|
823
|
-
const wtPath = enterAutoWorktree(originalBasePath, currentMilestoneId);
|
|
824
|
-
basePath = wtPath;
|
|
825
|
-
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
618
|
+
const wtPath = enterAutoWorktree(s.originalBasePath, s.currentMilestoneId);
|
|
619
|
+
s.basePath = wtPath;
|
|
620
|
+
s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
826
621
|
ctx.ui.notify(`Re-entered auto-worktree at ${wtPath}`, "info");
|
|
827
622
|
} else {
|
|
828
623
|
// Worktree was deleted while paused — recreate it.
|
|
829
|
-
const wtPath = createAutoWorktree(originalBasePath, currentMilestoneId);
|
|
830
|
-
basePath = wtPath;
|
|
831
|
-
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
624
|
+
const wtPath = createAutoWorktree(s.originalBasePath, s.currentMilestoneId);
|
|
625
|
+
s.basePath = wtPath;
|
|
626
|
+
s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
832
627
|
ctx.ui.notify(`Recreated auto-worktree at ${wtPath}`, "info");
|
|
833
628
|
}
|
|
834
629
|
} catch (err) {
|
|
@@ -842,46 +637,45 @@ export async function startAuto(
|
|
|
842
637
|
// Re-register SIGTERM handler for the resumed session (use original base for lock)
|
|
843
638
|
registerSigtermHandler(lockBase());
|
|
844
639
|
|
|
845
|
-
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
|
|
640
|
+
ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
|
|
846
641
|
ctx.ui.setFooter(hideFooter);
|
|
847
|
-
ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
|
|
642
|
+
ctx.ui.notify(s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
|
|
848
643
|
// Restore hook state from disk in case session was interrupted
|
|
849
|
-
restoreHookState(basePath);
|
|
644
|
+
restoreHookState(s.basePath);
|
|
850
645
|
// Rebuild disk state before resuming — user interaction during pause may have changed files
|
|
851
|
-
try { await rebuildState(basePath); } catch { /* non-fatal */ }
|
|
646
|
+
try { await rebuildState(s.basePath); } catch { /* non-fatal */ }
|
|
852
647
|
try {
|
|
853
|
-
const report = await runGSDDoctor(basePath, { fix: true });
|
|
648
|
+
const report = await runGSDDoctor(s.basePath, { fix: true });
|
|
854
649
|
if (report.fixesApplied.length > 0) {
|
|
855
650
|
ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info");
|
|
856
651
|
}
|
|
857
652
|
} catch { /* non-fatal */ }
|
|
858
653
|
// Self-heal: clear stale runtime records where artifacts already exist
|
|
859
|
-
await selfHealRuntimeRecords(basePath, ctx, completedKeySet);
|
|
654
|
+
await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet);
|
|
860
655
|
invalidateAllCaches();
|
|
861
656
|
|
|
862
657
|
// Synthesize recovery briefing from the paused session so the next agent
|
|
863
658
|
// knows what already happened (reuses crash recovery infrastructure).
|
|
864
|
-
if (pausedSessionFile) {
|
|
865
|
-
const activityDir = join(gsdRoot(basePath), "activity");
|
|
659
|
+
if (s.pausedSessionFile) {
|
|
660
|
+
const activityDir = join(gsdRoot(s.basePath), "activity");
|
|
866
661
|
const recovery = synthesizeCrashRecovery(
|
|
867
|
-
basePath,
|
|
868
|
-
currentUnit?.type ?? "unknown",
|
|
869
|
-
currentUnit?.id ?? "unknown",
|
|
870
|
-
pausedSessionFile,
|
|
662
|
+
s.basePath,
|
|
663
|
+
s.currentUnit?.type ?? "unknown",
|
|
664
|
+
s.currentUnit?.id ?? "unknown", s.pausedSessionFile ?? undefined,
|
|
871
665
|
activityDir,
|
|
872
666
|
);
|
|
873
667
|
if (recovery && recovery.trace.toolCallCount > 0) {
|
|
874
|
-
pendingCrashRecovery = recovery.prompt;
|
|
668
|
+
s.pendingCrashRecovery = recovery.prompt;
|
|
875
669
|
ctx.ui.notify(
|
|
876
670
|
`Recovered ${recovery.trace.toolCallCount} tool calls from paused session. Resuming with context.`,
|
|
877
671
|
"info",
|
|
878
672
|
);
|
|
879
673
|
}
|
|
880
|
-
pausedSessionFile = null;
|
|
674
|
+
s.pausedSessionFile = null;
|
|
881
675
|
}
|
|
882
676
|
|
|
883
677
|
// Write lock on resume so cross-process status detection works (#723).
|
|
884
|
-
writeLock(lockBase(), "resuming", currentMilestoneId ?? "unknown", completedUnits.length);
|
|
678
|
+
writeLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown", s.completedUnits.length);
|
|
885
679
|
|
|
886
680
|
await dispatchNextUnit(ctx, pi);
|
|
887
681
|
return;
|
|
@@ -894,9 +688,11 @@ export async function startAuto(
|
|
|
894
688
|
}
|
|
895
689
|
|
|
896
690
|
// Ensure .gitignore has baseline patterns
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
691
|
+
const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git;
|
|
692
|
+
const commitDocs = gitPrefs?.commit_docs;
|
|
693
|
+
const manageGitignore = gitPrefs?.manage_gitignore;
|
|
694
|
+
ensureGitignore(base, { commitDocs, manageGitignore });
|
|
695
|
+
if (manageGitignore !== false) untrackRuntimeFiles(base);
|
|
900
696
|
|
|
901
697
|
// Bootstrap .gsd/ if it doesn't exist
|
|
902
698
|
const gsdDir = join(base, ".gsd");
|
|
@@ -911,8 +707,8 @@ export async function startAuto(
|
|
|
911
707
|
}
|
|
912
708
|
}
|
|
913
709
|
|
|
914
|
-
// Initialize GitServiceImpl — basePath is set and git repo confirmed
|
|
915
|
-
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
710
|
+
// Initialize GitServiceImpl — s.basePath is set and git repo confirmed
|
|
711
|
+
s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
916
712
|
|
|
917
713
|
// Check for crash from previous session
|
|
918
714
|
const crashLock = readCrashLock(base);
|
|
@@ -946,7 +742,7 @@ export async function startAuto(
|
|
|
946
742
|
crashLock.sessionFile, activityDir,
|
|
947
743
|
);
|
|
948
744
|
if (recovery && recovery.trace.toolCallCount > 0) {
|
|
949
|
-
pendingCrashRecovery = recovery.prompt;
|
|
745
|
+
s.pendingCrashRecovery = recovery.prompt;
|
|
950
746
|
ctx.ui.notify(
|
|
951
747
|
`${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`,
|
|
952
748
|
"warning",
|
|
@@ -984,6 +780,26 @@ export async function startAuto(
|
|
|
984
780
|
// after a discussion that wrote new artifacts) may cause deriveState to
|
|
985
781
|
// return pre-planning when the roadmap already exists (#800).
|
|
986
782
|
invalidateAllCaches();
|
|
783
|
+
|
|
784
|
+
// ── Clean stale runtime unit files for completed milestones (#887) ───────
|
|
785
|
+
// After resource-update restart, stale runtime/units/*.json files from
|
|
786
|
+
// previously completed milestones can cause deriveState to resume the wrong
|
|
787
|
+
// milestone. If a milestone has a SUMMARY file, its unit files are stale.
|
|
788
|
+
try {
|
|
789
|
+
const runtimeUnitsDir = join(gsdRoot(base), "runtime", "units");
|
|
790
|
+
if (existsSync(runtimeUnitsDir)) {
|
|
791
|
+
for (const file of readdirSync(runtimeUnitsDir)) {
|
|
792
|
+
if (!file.endsWith(".json")) continue;
|
|
793
|
+
const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/);
|
|
794
|
+
if (!midMatch) continue;
|
|
795
|
+
const mid = midMatch[1];
|
|
796
|
+
if (resolveMilestoneFile(base, mid, "SUMMARY")) {
|
|
797
|
+
try { unlinkSync(join(runtimeUnitsDir, file)); } catch { /* non-fatal */ }
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
} catch { /* non-fatal — don't block startup */ }
|
|
802
|
+
|
|
987
803
|
let state = await deriveState(base);
|
|
988
804
|
|
|
989
805
|
// ── Stale worktree state recovery (#654) ─────────────────────────────────
|
|
@@ -1101,29 +917,29 @@ export async function startAuto(
|
|
|
1101
917
|
return;
|
|
1102
918
|
}
|
|
1103
919
|
|
|
1104
|
-
active = true;
|
|
1105
|
-
stepMode = requestedStepMode;
|
|
1106
|
-
verbose = verboseMode;
|
|
1107
|
-
cmdCtx = ctx;
|
|
1108
|
-
basePath = base;
|
|
1109
|
-
unitDispatchCount.clear();
|
|
1110
|
-
unitRecoveryCount.clear();
|
|
1111
|
-
unitConsecutiveSkips.clear();
|
|
1112
|
-
lastBudgetAlertLevel = 0;
|
|
1113
|
-
unitLifetimeDispatches.clear();
|
|
1114
|
-
completedKeySet.clear();
|
|
1115
|
-
loadPersistedKeys(base, completedKeySet);
|
|
920
|
+
s.active = true;
|
|
921
|
+
s.stepMode = requestedStepMode;
|
|
922
|
+
s.verbose = verboseMode;
|
|
923
|
+
s.cmdCtx = ctx;
|
|
924
|
+
s.basePath = base;
|
|
925
|
+
s.unitDispatchCount.clear();
|
|
926
|
+
s.unitRecoveryCount.clear();
|
|
927
|
+
s.unitConsecutiveSkips.clear();
|
|
928
|
+
s.lastBudgetAlertLevel = 0;
|
|
929
|
+
s.unitLifetimeDispatches.clear();
|
|
930
|
+
s.completedKeySet.clear();
|
|
931
|
+
loadPersistedKeys(base, s.completedKeySet);
|
|
1116
932
|
resetHookState();
|
|
1117
933
|
restoreHookState(base);
|
|
1118
934
|
resetProactiveHealing();
|
|
1119
|
-
autoStartTime = Date.now();
|
|
1120
|
-
resourceVersionOnStart = readResourceVersion();
|
|
1121
|
-
completedUnits = [];
|
|
1122
|
-
pendingQuickTasks = [];
|
|
1123
|
-
currentUnit = null;
|
|
1124
|
-
currentMilestoneId = state.activeMilestone?.id ?? null;
|
|
1125
|
-
originalModelId = ctx.model?.id ?? null;
|
|
1126
|
-
originalModelProvider = ctx.model?.provider ?? null;
|
|
935
|
+
s.autoStartTime = Date.now();
|
|
936
|
+
s.resourceVersionOnStart = readResourceVersion();
|
|
937
|
+
s.completedUnits = [];
|
|
938
|
+
s.pendingQuickTasks = [];
|
|
939
|
+
s.currentUnit = null;
|
|
940
|
+
s.currentMilestoneId = state.activeMilestone?.id ?? null;
|
|
941
|
+
s.originalModelId = ctx.model?.id ?? null;
|
|
942
|
+
s.originalModelProvider = ctx.model?.provider ?? null;
|
|
1127
943
|
|
|
1128
944
|
// Register a SIGTERM handler so `kill <pid>` cleans up the lock and exits.
|
|
1129
945
|
registerSigtermHandler(base);
|
|
@@ -1132,18 +948,18 @@ export async function startAuto(
|
|
|
1132
948
|
// auto-mode started. Slice branches will merge back to this branch instead
|
|
1133
949
|
// of the repo's default (main/master). Idempotent when the branch is the
|
|
1134
950
|
// same; updates the record when started from a different branch (#300).
|
|
1135
|
-
if (currentMilestoneId) {
|
|
951
|
+
if (s.currentMilestoneId) {
|
|
1136
952
|
if (getIsolationMode() !== "none") {
|
|
1137
|
-
captureIntegrationBranch(base, currentMilestoneId, { commitDocs });
|
|
953
|
+
captureIntegrationBranch(base, s.currentMilestoneId, { commitDocs });
|
|
1138
954
|
}
|
|
1139
|
-
setActiveMilestoneId(base, currentMilestoneId);
|
|
955
|
+
setActiveMilestoneId(base, s.currentMilestoneId);
|
|
1140
956
|
}
|
|
1141
957
|
|
|
1142
958
|
// ── Auto-worktree: create or enter worktree for the active milestone ──
|
|
1143
959
|
// Store the original project root before any chdir so we can restore on stop.
|
|
1144
960
|
// Skip if already inside a worktree (manual /worktree or another auto-worktree)
|
|
1145
961
|
// to prevent nested worktree creation.
|
|
1146
|
-
originalBasePath = base;
|
|
962
|
+
s.originalBasePath = base;
|
|
1147
963
|
|
|
1148
964
|
const isUnderGsdWorktrees = (p: string): boolean => {
|
|
1149
965
|
// Prevent creating nested auto-worktrees when running from within any
|
|
@@ -1156,30 +972,30 @@ export async function startAuto(
|
|
|
1156
972
|
return p.endsWith(worktreesSuffix);
|
|
1157
973
|
};
|
|
1158
974
|
|
|
1159
|
-
if (currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) {
|
|
975
|
+
if (s.currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) {
|
|
1160
976
|
try {
|
|
1161
|
-
const existingWtPath = getAutoWorktreePath(base, currentMilestoneId);
|
|
977
|
+
const existingWtPath = getAutoWorktreePath(base, s.currentMilestoneId);
|
|
1162
978
|
if (existingWtPath) {
|
|
1163
979
|
// Worktree already exists (e.g., previous session created it) — enter it.
|
|
1164
|
-
const wtPath = enterAutoWorktree(base, currentMilestoneId);
|
|
1165
|
-
basePath = wtPath;
|
|
1166
|
-
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
980
|
+
const wtPath = enterAutoWorktree(base, s.currentMilestoneId);
|
|
981
|
+
s.basePath = wtPath;
|
|
982
|
+
s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
1167
983
|
ctx.ui.notify(`Entered auto-worktree at ${wtPath}`, "info");
|
|
1168
984
|
} else {
|
|
1169
985
|
// Fresh start — create worktree and enter it.
|
|
1170
|
-
const wtPath = createAutoWorktree(base, currentMilestoneId);
|
|
1171
|
-
basePath = wtPath;
|
|
1172
|
-
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
986
|
+
const wtPath = createAutoWorktree(base, s.currentMilestoneId);
|
|
987
|
+
s.basePath = wtPath;
|
|
988
|
+
s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
1173
989
|
ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info");
|
|
1174
990
|
}
|
|
1175
|
-
// Re-register SIGTERM handler with the original basePath (lock lives there)
|
|
1176
|
-
registerSigtermHandler(originalBasePath);
|
|
991
|
+
// Re-register SIGTERM handler with the original s.basePath (lock lives there)
|
|
992
|
+
registerSigtermHandler(s.originalBasePath);
|
|
1177
993
|
|
|
1178
994
|
// After worktree entry, load completed keys from BOTH locations (project root
|
|
1179
995
|
// + worktree) so the in-memory set is the union. Prevents re-dispatch of units
|
|
1180
996
|
// completed in either location after crash/restart (#769).
|
|
1181
|
-
if (basePath !== originalBasePath) {
|
|
1182
|
-
loadPersistedKeys(basePath, completedKeySet);
|
|
997
|
+
if (s.basePath !== s.originalBasePath) {
|
|
998
|
+
loadPersistedKeys(s.basePath, s.completedKeySet);
|
|
1183
999
|
}
|
|
1184
1000
|
} catch (err) {
|
|
1185
1001
|
// Worktree creation is non-fatal — continue in the project root.
|
|
@@ -1191,8 +1007,8 @@ export async function startAuto(
|
|
|
1191
1007
|
}
|
|
1192
1008
|
|
|
1193
1009
|
// ── DB lifecycle: auto-migrate or open existing database ──
|
|
1194
|
-
const gsdDbPath = join(basePath, ".gsd", "gsd.db");
|
|
1195
|
-
const gsdDirPath = join(basePath, ".gsd");
|
|
1010
|
+
const gsdDbPath = join(s.basePath, ".gsd", "gsd.db");
|
|
1011
|
+
const gsdDirPath = join(s.basePath, ".gsd");
|
|
1196
1012
|
if (existsSync(gsdDirPath) && !existsSync(gsdDbPath)) {
|
|
1197
1013
|
const hasDecisions = existsSync(join(gsdDirPath, "DECISIONS.md"));
|
|
1198
1014
|
const hasRequirements = existsSync(join(gsdDirPath, "REQUIREMENTS.md"));
|
|
@@ -1202,7 +1018,7 @@ export async function startAuto(
|
|
|
1202
1018
|
const { openDatabase: openDb } = await import("./gsd-db.js");
|
|
1203
1019
|
const { migrateFromMarkdown } = await import("./md-importer.js");
|
|
1204
1020
|
openDb(gsdDbPath);
|
|
1205
|
-
migrateFromMarkdown(basePath);
|
|
1021
|
+
migrateFromMarkdown(s.basePath);
|
|
1206
1022
|
} catch (err) {
|
|
1207
1023
|
process.stderr.write(`gsd-migrate: auto-migration failed: ${(err as Error).message}\n`);
|
|
1208
1024
|
}
|
|
@@ -1218,18 +1034,18 @@ export async function startAuto(
|
|
|
1218
1034
|
}
|
|
1219
1035
|
|
|
1220
1036
|
// Initialize metrics — loads existing ledger from disk.
|
|
1221
|
-
// Use basePath (not base) so worktree-mode reads the worktree ledger (#769).
|
|
1222
|
-
initMetrics(basePath);
|
|
1037
|
+
// Use s.basePath (not base) so worktree-mode reads the worktree ledger (#769).
|
|
1038
|
+
initMetrics(s.basePath);
|
|
1223
1039
|
|
|
1224
1040
|
// Initialize routing history for adaptive learning
|
|
1225
|
-
initRoutingHistory(basePath);
|
|
1041
|
+
initRoutingHistory(s.basePath);
|
|
1226
1042
|
|
|
1227
1043
|
// Capture the session's current model at auto-mode start (#650).
|
|
1228
1044
|
// This prevents model bleed when multiple GSD instances share the
|
|
1229
1045
|
// same global settings.json — each instance remembers its own model.
|
|
1230
1046
|
const currentModel = ctx.model;
|
|
1231
1047
|
if (currentModel) {
|
|
1232
|
-
autoModeStartModel = { provider: currentModel.provider, id: currentModel.id };
|
|
1048
|
+
s.autoModeStartModel = { provider: currentModel.provider, id: currentModel.id };
|
|
1233
1049
|
}
|
|
1234
1050
|
|
|
1235
1051
|
// Snapshot installed skills so we can detect new ones after research
|
|
@@ -1237,9 +1053,9 @@ export async function startAuto(
|
|
|
1237
1053
|
snapshotSkills();
|
|
1238
1054
|
}
|
|
1239
1055
|
|
|
1240
|
-
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
|
|
1056
|
+
ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
|
|
1241
1057
|
ctx.ui.setFooter(hideFooter);
|
|
1242
|
-
const modeLabel = stepMode ? "Step-mode" : "Auto-mode";
|
|
1058
|
+
const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
|
|
1243
1059
|
const pendingCount = state.registry.filter(m => m.status !== 'complete').length;
|
|
1244
1060
|
const scopeMsg = pendingCount > 1
|
|
1245
1061
|
? `Will loop through ${pendingCount} milestones.`
|
|
@@ -1249,7 +1065,7 @@ export async function startAuto(
|
|
|
1249
1065
|
// Write initial lock file immediately so cross-process status detection
|
|
1250
1066
|
// works even before the first unit is dispatched (#723).
|
|
1251
1067
|
// The lock is updated with unit-specific info on each dispatch and cleared on stop.
|
|
1252
|
-
writeLock(lockBase(), "starting", currentMilestoneId ?? "unknown", 0);
|
|
1068
|
+
writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0);
|
|
1253
1069
|
|
|
1254
1070
|
// Secrets collection gate — collect pending secrets before first dispatch
|
|
1255
1071
|
const mid = state.activeMilestone!.id;
|
|
@@ -1274,9 +1090,9 @@ export async function startAuto(
|
|
|
1274
1090
|
}
|
|
1275
1091
|
|
|
1276
1092
|
// Self-heal: clear stale runtime records where artifacts already exist.
|
|
1277
|
-
// Use basePath (not base) — in worktree mode, basePath points to the worktree
|
|
1093
|
+
// Use s.basePath (not base) — in worktree mode, s.basePath points to the worktree
|
|
1278
1094
|
// where runtime records and artifacts actually live (#769).
|
|
1279
|
-
await selfHealRuntimeRecords(basePath, ctx, completedKeySet);
|
|
1095
|
+
await selfHealRuntimeRecords(s.basePath, ctx, s.completedKeySet);
|
|
1280
1096
|
|
|
1281
1097
|
// Self-heal: remove stale .git/index.lock from prior crash.
|
|
1282
1098
|
// A stale lock file blocks all git operations (commit, merge, checkout).
|
|
@@ -1327,15 +1143,14 @@ export async function startAuto(
|
|
|
1327
1143
|
* await). Without this guard, concurrent dispatchNextUnit calls race on
|
|
1328
1144
|
* newSession(), causing one to cancel the other and silently stopping
|
|
1329
1145
|
* auto-mode. */
|
|
1330
|
-
let _handlingAgentEnd = false;
|
|
1331
1146
|
|
|
1332
1147
|
export async function handleAgentEnd(
|
|
1333
1148
|
ctx: ExtensionContext,
|
|
1334
1149
|
pi: ExtensionAPI,
|
|
1335
1150
|
): Promise<void> {
|
|
1336
|
-
if (!active || !cmdCtx) return;
|
|
1337
|
-
if (
|
|
1338
|
-
|
|
1151
|
+
if (!s.active || !s.cmdCtx) return;
|
|
1152
|
+
if (s.handlingAgentEnd) return;
|
|
1153
|
+
s.handlingAgentEnd = true;
|
|
1339
1154
|
|
|
1340
1155
|
try {
|
|
1341
1156
|
|
|
@@ -1347,15 +1162,15 @@ export async function handleAgentEnd(
|
|
|
1347
1162
|
// coordinator signals before dispatching the next unit.
|
|
1348
1163
|
const milestoneLock = process.env.GSD_MILESTONE_LOCK;
|
|
1349
1164
|
if (milestoneLock) {
|
|
1350
|
-
const signal = consumeSignal(basePath, milestoneLock);
|
|
1165
|
+
const signal = consumeSignal(s.basePath, milestoneLock);
|
|
1351
1166
|
if (signal) {
|
|
1352
1167
|
if (signal.signal === "stop") {
|
|
1353
|
-
|
|
1168
|
+
s.handlingAgentEnd = false;
|
|
1354
1169
|
await stopAuto(ctx, pi);
|
|
1355
1170
|
return;
|
|
1356
1171
|
}
|
|
1357
1172
|
if (signal.signal === "pause") {
|
|
1358
|
-
|
|
1173
|
+
s.handlingAgentEnd = false;
|
|
1359
1174
|
await pauseAuto(ctx, pi);
|
|
1360
1175
|
return;
|
|
1361
1176
|
}
|
|
@@ -1374,15 +1189,15 @@ export async function handleAgentEnd(
|
|
|
1374
1189
|
// For execute-task units, build a meaningful commit message from the
|
|
1375
1190
|
// task summary (one-liner, key_files, inferred type). For other unit
|
|
1376
1191
|
// types, fall back to the generic chore() message.
|
|
1377
|
-
if (currentUnit) {
|
|
1192
|
+
if (s.currentUnit) {
|
|
1378
1193
|
try {
|
|
1379
1194
|
let taskContext: TaskCommitContext | undefined;
|
|
1380
1195
|
|
|
1381
|
-
if (currentUnit.type === "execute-task") {
|
|
1382
|
-
const parts = currentUnit.id.split("/");
|
|
1196
|
+
if (s.currentUnit.type === "execute-task") {
|
|
1197
|
+
const parts = s.currentUnit.id.split("/");
|
|
1383
1198
|
const [mid, sid, tid] = parts;
|
|
1384
1199
|
if (mid && sid && tid) {
|
|
1385
|
-
const summaryPath = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY");
|
|
1200
|
+
const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY");
|
|
1386
1201
|
if (summaryPath) {
|
|
1387
1202
|
try {
|
|
1388
1203
|
const summaryContent = await loadFile(summaryPath);
|
|
@@ -1402,7 +1217,7 @@ export async function handleAgentEnd(
|
|
|
1402
1217
|
}
|
|
1403
1218
|
}
|
|
1404
1219
|
|
|
1405
|
-
const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id, taskContext);
|
|
1220
|
+
const commitMsg = autoCommitCurrentBranch(s.basePath, s.currentUnit.type, s.currentUnit.id, taskContext);
|
|
1406
1221
|
if (commitMsg) {
|
|
1407
1222
|
ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info");
|
|
1408
1223
|
}
|
|
@@ -1419,10 +1234,10 @@ export async function handleAgentEnd(
|
|
|
1419
1234
|
// Exception: after complete-slice itself, use fixLevel:"all" so roadmap
|
|
1420
1235
|
// checkboxes get fixed even if complete-slice crashed (#839).
|
|
1421
1236
|
try {
|
|
1422
|
-
const scopeParts = currentUnit.id.split("/").slice(0, 2);
|
|
1237
|
+
const scopeParts = s.currentUnit.id.split("/").slice(0, 2);
|
|
1423
1238
|
const doctorScope = scopeParts.join("/");
|
|
1424
|
-
const effectiveFixLevel = currentUnit.type === "complete-slice" ? "all" as const : "task" as const;
|
|
1425
|
-
const report = await runGSDDoctor(basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel });
|
|
1239
|
+
const effectiveFixLevel = s.currentUnit.type === "complete-slice" ? "all" as const : "task" as const;
|
|
1240
|
+
const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel });
|
|
1426
1241
|
if (report.fixesApplied.length > 0) {
|
|
1427
1242
|
ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
|
|
1428
1243
|
}
|
|
@@ -1458,29 +1273,46 @@ export async function handleAgentEnd(
|
|
|
1458
1273
|
} catch {
|
|
1459
1274
|
// Non-fatal — doctor failure should never block dispatch
|
|
1460
1275
|
}
|
|
1276
|
+
// Throttle STATE.md rebuilds to reduce I/O spikes on long sessions.
|
|
1277
|
+
// STATE.md is a derived diagnostic artifact — skipping a rebuild is safe;
|
|
1278
|
+
// the next unit or stop/pause will rebuild it.
|
|
1279
|
+
const now = Date.now();
|
|
1280
|
+
if (now - s.lastStateRebuildAt >= STATE_REBUILD_MIN_INTERVAL_MS) {
|
|
1281
|
+
try {
|
|
1282
|
+
await rebuildState(s.basePath);
|
|
1283
|
+
s.lastStateRebuildAt = now;
|
|
1284
|
+
// State rebuild commit is bookkeeping — generic message is appropriate
|
|
1285
|
+
autoCommitCurrentBranch(s.basePath, "state-rebuild", s.currentUnit.id);
|
|
1286
|
+
} catch {
|
|
1287
|
+
// Non-fatal
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// ── Prune dead bg-shell processes ──────────────────────────────────────
|
|
1292
|
+
// Dead processes retain ~500KB-1MB of output buffers each. Without pruning,
|
|
1293
|
+
// they accumulate during long auto-mode sessions causing memory pressure.
|
|
1461
1294
|
try {
|
|
1462
|
-
await
|
|
1463
|
-
|
|
1464
|
-
autoCommitCurrentBranch(basePath, "state-rebuild", currentUnit.id);
|
|
1295
|
+
const { pruneDeadProcesses } = await import("../bg-shell/process-manager.js");
|
|
1296
|
+
pruneDeadProcesses();
|
|
1465
1297
|
} catch {
|
|
1466
|
-
// Non-fatal
|
|
1298
|
+
// Non-fatal — bg-shell may not be available
|
|
1467
1299
|
}
|
|
1468
1300
|
|
|
1469
1301
|
// ── Sync worktree state back to project root ──────────────────────────
|
|
1470
1302
|
// Ensures that if auto-mode restarts, deriveState(projectRoot) reads
|
|
1471
1303
|
// current milestone progress instead of stale pre-worktree state (#654).
|
|
1472
|
-
if (originalBasePath && originalBasePath !== basePath) {
|
|
1304
|
+
if (s.originalBasePath && s.originalBasePath !== s.basePath) {
|
|
1473
1305
|
try {
|
|
1474
|
-
syncStateToProjectRoot(basePath, originalBasePath, currentMilestoneId);
|
|
1306
|
+
syncStateToProjectRoot(s.basePath, s.originalBasePath, s.currentMilestoneId);
|
|
1475
1307
|
} catch {
|
|
1476
1308
|
// Non-fatal — stale state is the existing behavior, sync is an improvement
|
|
1477
1309
|
}
|
|
1478
1310
|
}
|
|
1479
1311
|
|
|
1480
1312
|
// ── Rewrite-docs completion: resolve overrides and reset circuit breaker ──
|
|
1481
|
-
if (currentUnit.type === "rewrite-docs") {
|
|
1313
|
+
if (s.currentUnit.type === "rewrite-docs") {
|
|
1482
1314
|
try {
|
|
1483
|
-
await resolveAllOverrides(basePath);
|
|
1315
|
+
await resolveAllOverrides(s.basePath);
|
|
1484
1316
|
resetRewriteCircuitBreaker();
|
|
1485
1317
|
ctx.ui.notify("Override(s) resolved — rewrite-docs completed.", "info");
|
|
1486
1318
|
} catch {
|
|
@@ -1492,15 +1324,15 @@ export async function handleAgentEnd(
|
|
|
1492
1324
|
// After a triage-captures unit completes, the LLM has classified captures and
|
|
1493
1325
|
// updated CAPTURES.md. Now we execute those classifications: inject tasks into
|
|
1494
1326
|
// the plan, write replan triggers, and queue quick-tasks for dispatch.
|
|
1495
|
-
if (currentUnit.type === "triage-captures") {
|
|
1327
|
+
if (s.currentUnit.type === "triage-captures") {
|
|
1496
1328
|
try {
|
|
1497
1329
|
const { executeTriageResolutions } = await import("./triage-resolution.js");
|
|
1498
|
-
const state = await deriveState(basePath);
|
|
1330
|
+
const state = await deriveState(s.basePath);
|
|
1499
1331
|
const mid = state.activeMilestone?.id;
|
|
1500
1332
|
const sid = state.activeSlice?.id;
|
|
1501
1333
|
|
|
1502
1334
|
if (mid && sid) {
|
|
1503
|
-
const triageResult = executeTriageResolutions(basePath, mid, sid);
|
|
1335
|
+
const triageResult = executeTriageResolutions(s.basePath, mid, sid);
|
|
1504
1336
|
|
|
1505
1337
|
if (triageResult.injected > 0) {
|
|
1506
1338
|
ctx.ui.notify(
|
|
@@ -1518,7 +1350,7 @@ export async function handleAgentEnd(
|
|
|
1518
1350
|
// Queue quick-tasks for dispatch. They'll be picked up by the
|
|
1519
1351
|
// quick-task dispatch block below the triage check.
|
|
1520
1352
|
for (const qt of triageResult.quickTasks) {
|
|
1521
|
-
pendingQuickTasks.push(qt);
|
|
1353
|
+
s.pendingQuickTasks.push(qt);
|
|
1522
1354
|
}
|
|
1523
1355
|
ctx.ui.notify(
|
|
1524
1356
|
`Triage: ${triageResult.quickTasks.length} quick-task${triageResult.quickTasks.length === 1 ? "" : "s"} queued for execution.`,
|
|
@@ -1539,20 +1371,20 @@ export async function handleAgentEnd(
|
|
|
1539
1371
|
// After doctor + rebuildState, check whether the just-completed unit actually
|
|
1540
1372
|
// produced its expected artifact. If so, persist the completion key now so the
|
|
1541
1373
|
// idempotency check at the top of dispatchNextUnit() skips it — even if
|
|
1542
|
-
// deriveState() still returns this unit as active (e.g. branch mismatch).
|
|
1374
|
+
// deriveState() still returns this unit as s.active (e.g. branch mismatch).
|
|
1543
1375
|
//
|
|
1544
1376
|
// IMPORTANT: For non-hook units, defer persistence until after the hook check.
|
|
1545
1377
|
// If a post-unit hook requests a retry, we need to remove the completion key
|
|
1546
1378
|
// so dispatchNextUnit re-dispatches the trigger unit.
|
|
1547
1379
|
let triggerArtifactVerified = false;
|
|
1548
|
-
if (!currentUnit.type.startsWith("hook/")) {
|
|
1380
|
+
if (!s.currentUnit.type.startsWith("hook/")) {
|
|
1549
1381
|
try {
|
|
1550
|
-
triggerArtifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
|
|
1382
|
+
triggerArtifactVerified = verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath);
|
|
1551
1383
|
if (triggerArtifactVerified) {
|
|
1552
|
-
const completionKey = `${currentUnit.type}/${currentUnit.id}`;
|
|
1553
|
-
if (!completedKeySet.has(completionKey)) {
|
|
1554
|
-
persistCompletedKey(basePath, completionKey);
|
|
1555
|
-
completedKeySet.add(completionKey);
|
|
1384
|
+
const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
|
|
1385
|
+
if (!s.completedKeySet.has(completionKey)) {
|
|
1386
|
+
persistCompletedKey(s.basePath, completionKey);
|
|
1387
|
+
s.completedKeySet.add(completionKey);
|
|
1556
1388
|
}
|
|
1557
1389
|
invalidateAllCaches();
|
|
1558
1390
|
}
|
|
@@ -1562,50 +1394,177 @@ export async function handleAgentEnd(
|
|
|
1562
1394
|
} else {
|
|
1563
1395
|
// Hook unit completed — finalize its runtime record and clear it
|
|
1564
1396
|
try {
|
|
1565
|
-
writeUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id, currentUnit.startedAt, {
|
|
1397
|
+
writeUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, {
|
|
1566
1398
|
phase: "finalized",
|
|
1567
1399
|
progressCount: 1,
|
|
1568
1400
|
lastProgressKind: "hook-completed",
|
|
1569
1401
|
});
|
|
1570
|
-
clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
|
|
1402
|
+
clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id);
|
|
1571
1403
|
} catch {
|
|
1572
1404
|
// Non-fatal
|
|
1573
1405
|
}
|
|
1574
1406
|
}
|
|
1575
1407
|
}
|
|
1576
1408
|
|
|
1409
|
+
// ── Verification gate: run typecheck/lint/test after execute-task ──
|
|
1410
|
+
if (s.currentUnit && s.currentUnit.type === "execute-task") {
|
|
1411
|
+
try {
|
|
1412
|
+
const effectivePrefs = loadEffectiveGSDPreferences();
|
|
1413
|
+
const prefs = effectivePrefs?.preferences;
|
|
1414
|
+
|
|
1415
|
+
// Read task plan verify field from the current task's slice plan
|
|
1416
|
+
// unitId format is "M001/S01/T03" — extract mid, sid, tid
|
|
1417
|
+
const parts = s.currentUnit.id.split("/");
|
|
1418
|
+
let taskPlanVerify: string | undefined;
|
|
1419
|
+
if (parts.length >= 3) {
|
|
1420
|
+
const [mid, sid, tid] = parts;
|
|
1421
|
+
const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN");
|
|
1422
|
+
if (planFile) {
|
|
1423
|
+
const planContent = await loadFile(planFile);
|
|
1424
|
+
if (planContent) {
|
|
1425
|
+
const slicePlan = parsePlan(planContent);
|
|
1426
|
+
const taskEntry = slicePlan?.tasks?.find(t => t.id === tid);
|
|
1427
|
+
taskPlanVerify = taskEntry?.verify;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const result = runVerificationGate({ basePath: s.basePath,
|
|
1433
|
+
unitId: s.currentUnit.id,
|
|
1434
|
+
cwd: s.basePath,
|
|
1435
|
+
preferenceCommands: prefs?.verification_commands,
|
|
1436
|
+
taskPlanVerify,
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
// Capture runtime errors from bg-shell and browser console
|
|
1440
|
+
const runtimeErrors = await captureRuntimeErrors();
|
|
1441
|
+
if (runtimeErrors.length > 0) {
|
|
1442
|
+
result.runtimeErrors = runtimeErrors;
|
|
1443
|
+
// Blocking runtime errors override gate pass
|
|
1444
|
+
if (runtimeErrors.some(e => e.blocking)) {
|
|
1445
|
+
result.passed = false;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Conditional dependency audit (R008)
|
|
1450
|
+
const auditWarnings = runDependencyAudit(s.basePath);
|
|
1451
|
+
if (auditWarnings.length > 0) {
|
|
1452
|
+
result.auditWarnings = auditWarnings;
|
|
1453
|
+
process.stderr.write(`verification-gate: ${auditWarnings.length} audit warning(s)\n`);
|
|
1454
|
+
for (const w of auditWarnings) {
|
|
1455
|
+
process.stderr.write(` [${w.severity}] ${w.name}: ${w.title}\n`);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// Auto-fix retry preferences (R005 / D005)
|
|
1460
|
+
const autoFixEnabled = prefs?.verification_auto_fix !== false; // default true
|
|
1461
|
+
const maxRetries = typeof prefs?.verification_max_retries === "number" ? prefs.verification_max_retries : 2;
|
|
1462
|
+
const completionKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
|
|
1463
|
+
|
|
1464
|
+
if (result.checks.length > 0) {
|
|
1465
|
+
const passCount = result.checks.filter(c => c.exitCode === 0).length;
|
|
1466
|
+
const total = result.checks.length;
|
|
1467
|
+
if (result.passed) {
|
|
1468
|
+
ctx.ui.notify(`Verification gate: ${passCount}/${total} checks passed`);
|
|
1469
|
+
} else {
|
|
1470
|
+
const failures = result.checks.filter(c => c.exitCode !== 0);
|
|
1471
|
+
const failNames = failures.map(f => f.command).join(", ");
|
|
1472
|
+
ctx.ui.notify(`Verification gate: FAILED — ${failNames}`);
|
|
1473
|
+
process.stderr.write(`verification-gate: ${total - passCount}/${total} checks failed\n`);
|
|
1474
|
+
for (const f of failures) {
|
|
1475
|
+
process.stderr.write(` ${f.command} exited ${f.exitCode}\n`);
|
|
1476
|
+
if (f.stderr) process.stderr.write(` stderr: ${f.stderr.slice(0, 500)}\n`);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Log blocking runtime errors to stderr
|
|
1482
|
+
if (result.runtimeErrors?.some(e => e.blocking)) {
|
|
1483
|
+
const blockingErrors = result.runtimeErrors.filter(e => e.blocking);
|
|
1484
|
+
process.stderr.write(`verification-gate: ${blockingErrors.length} blocking runtime error(s) detected\n`);
|
|
1485
|
+
for (const err of blockingErrors) {
|
|
1486
|
+
process.stderr.write(` [${err.source}] ${err.severity}: ${err.message.slice(0, 200)}\n`);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// Write verification evidence JSON artifact
|
|
1491
|
+
const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0;
|
|
1492
|
+
if (parts.length >= 3) {
|
|
1493
|
+
try {
|
|
1494
|
+
const [mid, sid, tid] = parts;
|
|
1495
|
+
const sDir = resolveSlicePath(s.basePath, mid, sid);
|
|
1496
|
+
if (sDir) {
|
|
1497
|
+
const tasksDir = join(sDir, "tasks");
|
|
1498
|
+
if (result.passed) {
|
|
1499
|
+
writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id);
|
|
1500
|
+
} else {
|
|
1501
|
+
const nextAttempt = attempt + 1;
|
|
1502
|
+
writeVerificationJSON(result, tasksDir, tid, s.currentUnit.id, nextAttempt, maxRetries);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
} catch (evidenceErr) {
|
|
1506
|
+
process.stderr.write(`verification-evidence: write error — ${(evidenceErr as Error).message}\n`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// ── Auto-fix retry logic ──
|
|
1511
|
+
if (result.passed) {
|
|
1512
|
+
// Gate passed — clear retry state and continue normal flow
|
|
1513
|
+
s.verificationRetryCount.delete(s.currentUnit.id);
|
|
1514
|
+
s.pendingVerificationRetry = null;
|
|
1515
|
+
} else if (autoFixEnabled && attempt + 1 <= maxRetries) {
|
|
1516
|
+
// Gate failed, retries remaining — set up retry and return early
|
|
1517
|
+
const nextAttempt = attempt + 1;
|
|
1518
|
+
s.verificationRetryCount.set(s.currentUnit.id, nextAttempt);
|
|
1519
|
+
s.pendingVerificationRetry = {
|
|
1520
|
+
unitId: s.currentUnit.id,
|
|
1521
|
+
failureContext: formatFailureContext(result),
|
|
1522
|
+
attempt: nextAttempt,
|
|
1523
|
+
};
|
|
1524
|
+
ctx.ui.notify(`Verification failed — auto-fix attempt ${nextAttempt}/${maxRetries}`, "warning");
|
|
1525
|
+
// Remove completion key so dispatchNextUnit re-dispatches this unit
|
|
1526
|
+
s.completedKeySet.delete(completionKey);
|
|
1527
|
+
removePersistedKey(s.basePath, completionKey);
|
|
1528
|
+
return; // ← Critical: exit before DB dual-write and post-unit hooks
|
|
1529
|
+
} else {
|
|
1530
|
+
// Gate failed, retries exhausted (or auto-fix disabled) — pause for human review
|
|
1531
|
+
const exhaustedAttempt = attempt + 1;
|
|
1532
|
+
s.verificationRetryCount.delete(s.currentUnit.id);
|
|
1533
|
+
s.pendingVerificationRetry = null;
|
|
1534
|
+
ctx.ui.notify(
|
|
1535
|
+
`Verification gate FAILED after ${exhaustedAttempt > maxRetries ? exhaustedAttempt - 1 : exhaustedAttempt} retries — pausing for human review`,
|
|
1536
|
+
"error",
|
|
1537
|
+
);
|
|
1538
|
+
await pauseAuto(ctx, pi);
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
// Gate errors are non-fatal — log and continue
|
|
1543
|
+
process.stderr.write(`verification-gate: error — ${(err as Error).message}\n`);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1577
1547
|
// ── DB dual-write: re-import changed markdown files so next unit's prompts use fresh data ──
|
|
1578
1548
|
if (isDbAvailable()) {
|
|
1579
1549
|
try {
|
|
1580
1550
|
const { migrateFromMarkdown } = await import("./md-importer.js");
|
|
1581
|
-
migrateFromMarkdown(basePath);
|
|
1551
|
+
migrateFromMarkdown(s.basePath);
|
|
1582
1552
|
} catch (err) {
|
|
1583
1553
|
process.stderr.write(`gsd-db: re-import failed: ${(err as Error).message}\n`);
|
|
1584
1554
|
}
|
|
1585
1555
|
}
|
|
1586
1556
|
|
|
1587
1557
|
// ── Post-unit hooks: check if a configured hook should run before normal dispatch ──
|
|
1588
|
-
if (currentUnit && !stepMode) {
|
|
1589
|
-
const hookUnit = checkPostUnitHooks(currentUnit.type, currentUnit.id, basePath);
|
|
1558
|
+
if (s.currentUnit && !s.stepMode) {
|
|
1559
|
+
const hookUnit = checkPostUnitHooks(s.currentUnit.type, s.currentUnit.id, s.basePath);
|
|
1590
1560
|
if (hookUnit) {
|
|
1591
1561
|
// Dispatch the hook unit instead of normal flow
|
|
1592
1562
|
const hookStartedAt = Date.now();
|
|
1593
|
-
if (currentUnit) {
|
|
1594
|
-
|
|
1595
|
-
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
|
|
1596
|
-
const hookActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1597
|
-
if (hookActivityFile) {
|
|
1598
|
-
try {
|
|
1599
|
-
const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
|
|
1600
|
-
const llmCallFn = buildMemoryLLMCall(ctx);
|
|
1601
|
-
if (llmCallFn) {
|
|
1602
|
-
extractMemoriesFromUnit(hookActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
|
|
1603
|
-
}
|
|
1604
|
-
} catch { /* non-fatal */ }
|
|
1605
|
-
}
|
|
1563
|
+
if (s.currentUnit) {
|
|
1564
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
1606
1565
|
}
|
|
1607
|
-
currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt };
|
|
1608
|
-
writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, {
|
|
1566
|
+
s.currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt };
|
|
1567
|
+
writeUnitRuntimeRecord(s.basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, {
|
|
1609
1568
|
phase: "dispatched",
|
|
1610
1569
|
wrapupWarningSent: false,
|
|
1611
1570
|
timeoutAt: null,
|
|
@@ -1614,7 +1573,7 @@ export async function handleAgentEnd(
|
|
|
1614
1573
|
lastProgressKind: "dispatch",
|
|
1615
1574
|
});
|
|
1616
1575
|
|
|
1617
|
-
const state = await deriveState(basePath);
|
|
1576
|
+
const state = await deriveState(s.basePath);
|
|
1618
1577
|
updateProgressWidget(ctx, hookUnit.unitType, hookUnit.unitId, state);
|
|
1619
1578
|
const hookState = getActiveHook();
|
|
1620
1579
|
ctx.ui.notify(
|
|
@@ -1635,27 +1594,27 @@ export async function handleAgentEnd(
|
|
|
1635
1594
|
}
|
|
1636
1595
|
}
|
|
1637
1596
|
|
|
1638
|
-
const result = await cmdCtx!.newSession();
|
|
1597
|
+
const result = await s.cmdCtx!.newSession();
|
|
1639
1598
|
if (result.cancelled) {
|
|
1640
1599
|
resetHookState();
|
|
1641
1600
|
await stopAuto(ctx, pi, "Hook session cancelled");
|
|
1642
1601
|
return;
|
|
1643
1602
|
}
|
|
1644
1603
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
1645
|
-
writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile);
|
|
1604
|
+
writeLock(lockBase(), hookUnit.unitType, hookUnit.unitId, s.completedUnits.length, sessionFile);
|
|
1646
1605
|
// Persist hook state so cycle counts survive crashes
|
|
1647
|
-
persistHookState(basePath);
|
|
1606
|
+
persistHookState(s.basePath);
|
|
1648
1607
|
|
|
1649
1608
|
// Start supervision timers for hook units — hooks can get stuck just
|
|
1650
1609
|
// like normal units, and without a watchdog auto-mode would hang forever.
|
|
1651
1610
|
clearUnitTimeout();
|
|
1652
1611
|
const supervisor = resolveAutoSupervisorConfig();
|
|
1653
1612
|
const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
|
|
1654
|
-
unitTimeoutHandle = setTimeout(async () => {
|
|
1655
|
-
unitTimeoutHandle = null;
|
|
1656
|
-
if (!active) return;
|
|
1657
|
-
if (currentUnit) {
|
|
1658
|
-
writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, currentUnit.startedAt, {
|
|
1613
|
+
s.unitTimeoutHandle = setTimeout(async () => {
|
|
1614
|
+
s.unitTimeoutHandle = null;
|
|
1615
|
+
if (!s.active) return;
|
|
1616
|
+
if (s.currentUnit) {
|
|
1617
|
+
writeUnitRuntimeRecord(s.basePath, hookUnit.unitType, hookUnit.unitId, s.currentUnit.startedAt, {
|
|
1659
1618
|
phase: "timeout",
|
|
1660
1619
|
timeoutAt: Date.now(),
|
|
1661
1620
|
});
|
|
@@ -1669,9 +1628,9 @@ export async function handleAgentEnd(
|
|
|
1669
1628
|
}, hookHardTimeoutMs);
|
|
1670
1629
|
|
|
1671
1630
|
// Guard against race with timeout/pause before sending
|
|
1672
|
-
if (!active) return;
|
|
1631
|
+
if (!s.active) return;
|
|
1673
1632
|
pi.sendMessage(
|
|
1674
|
-
{ customType: "gsd-auto", content: hookUnit.prompt, display: verbose },
|
|
1633
|
+
{ customType: "gsd-auto", content: hookUnit.prompt, display: s.verbose },
|
|
1675
1634
|
{ triggerTurn: true },
|
|
1676
1635
|
);
|
|
1677
1636
|
return; // handleAgentEnd will fire again when hook session completes
|
|
@@ -1684,8 +1643,8 @@ export async function handleAgentEnd(
|
|
|
1684
1643
|
// Remove the trigger unit's completion key so dispatchNextUnit
|
|
1685
1644
|
// will re-dispatch it instead of skipping it as already-complete.
|
|
1686
1645
|
const triggerKey = `${trigger.unitType}/${trigger.unitId}`;
|
|
1687
|
-
completedKeySet.delete(triggerKey);
|
|
1688
|
-
removePersistedKey(basePath, triggerKey);
|
|
1646
|
+
s.completedKeySet.delete(triggerKey);
|
|
1647
|
+
removePersistedKey(s.basePath, triggerKey);
|
|
1689
1648
|
ctx.ui.notify(
|
|
1690
1649
|
`Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`,
|
|
1691
1650
|
"info",
|
|
@@ -1702,17 +1661,17 @@ export async function handleAgentEnd(
|
|
|
1702
1661
|
// Skip for: step mode (shows wizard instead), triage units (prevent triage-on-triage),
|
|
1703
1662
|
// hook units (hooks run before triage conceptually).
|
|
1704
1663
|
if (
|
|
1705
|
-
!stepMode &&
|
|
1706
|
-
currentUnit &&
|
|
1707
|
-
!currentUnit.type.startsWith("hook/") &&
|
|
1708
|
-
currentUnit.type !== "triage-captures" &&
|
|
1709
|
-
currentUnit.type !== "quick-task"
|
|
1664
|
+
!s.stepMode &&
|
|
1665
|
+
s.currentUnit &&
|
|
1666
|
+
!s.currentUnit.type.startsWith("hook/") &&
|
|
1667
|
+
s.currentUnit.type !== "triage-captures" &&
|
|
1668
|
+
s.currentUnit.type !== "quick-task"
|
|
1710
1669
|
) {
|
|
1711
1670
|
try {
|
|
1712
|
-
if (hasPendingCaptures(basePath)) {
|
|
1713
|
-
const pending = loadPendingCaptures(basePath);
|
|
1671
|
+
if (hasPendingCaptures(s.basePath)) {
|
|
1672
|
+
const pending = loadPendingCaptures(s.basePath);
|
|
1714
1673
|
if (pending.length > 0) {
|
|
1715
|
-
const state = await deriveState(basePath);
|
|
1674
|
+
const state = await deriveState(s.basePath);
|
|
1716
1675
|
const mid = state.activeMilestone?.id;
|
|
1717
1676
|
const sid = state.activeSlice?.id;
|
|
1718
1677
|
|
|
@@ -1720,9 +1679,9 @@ export async function handleAgentEnd(
|
|
|
1720
1679
|
// Build triage prompt with current context
|
|
1721
1680
|
let currentPlan = "";
|
|
1722
1681
|
let roadmapContext = "";
|
|
1723
|
-
const planFile = resolveSliceFile(basePath, mid, sid, "PLAN");
|
|
1682
|
+
const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN");
|
|
1724
1683
|
if (planFile) currentPlan = (await loadFile(planFile)) ?? "";
|
|
1725
|
-
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
|
1684
|
+
const roadmapFile = resolveMilestoneFile(s.basePath, mid, "ROADMAP");
|
|
1726
1685
|
if (roadmapFile) roadmapContext = (await loadFile(roadmapFile)) ?? "";
|
|
1727
1686
|
|
|
1728
1687
|
const capturesList = pending.map(c =>
|
|
@@ -1741,27 +1700,16 @@ export async function handleAgentEnd(
|
|
|
1741
1700
|
);
|
|
1742
1701
|
|
|
1743
1702
|
// Close out previous unit metrics
|
|
1744
|
-
if (currentUnit) {
|
|
1745
|
-
|
|
1746
|
-
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1747
|
-
const triageActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1748
|
-
if (triageActivityFile) {
|
|
1749
|
-
try {
|
|
1750
|
-
const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
|
|
1751
|
-
const llmCallFn = buildMemoryLLMCall(ctx);
|
|
1752
|
-
if (llmCallFn) {
|
|
1753
|
-
extractMemoriesFromUnit(triageActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
|
|
1754
|
-
}
|
|
1755
|
-
} catch { /* non-fatal */ }
|
|
1756
|
-
}
|
|
1703
|
+
if (s.currentUnit) {
|
|
1704
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt);
|
|
1757
1705
|
}
|
|
1758
1706
|
|
|
1759
1707
|
// Dispatch triage as a new unit (early-dispatch-and-return)
|
|
1760
1708
|
const triageUnitType = "triage-captures";
|
|
1761
1709
|
const triageUnitId = `${mid}/${sid}/triage`;
|
|
1762
1710
|
const triageStartedAt = Date.now();
|
|
1763
|
-
currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt };
|
|
1764
|
-
writeUnitRuntimeRecord(basePath, triageUnitType, triageUnitId, triageStartedAt, {
|
|
1711
|
+
s.currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt };
|
|
1712
|
+
writeUnitRuntimeRecord(s.basePath, triageUnitType, triageUnitId, triageStartedAt, {
|
|
1765
1713
|
phase: "dispatched",
|
|
1766
1714
|
wrapupWarningSent: false,
|
|
1767
1715
|
timeoutAt: null,
|
|
@@ -1771,21 +1719,21 @@ export async function handleAgentEnd(
|
|
|
1771
1719
|
});
|
|
1772
1720
|
updateProgressWidget(ctx, triageUnitType, triageUnitId, state);
|
|
1773
1721
|
|
|
1774
|
-
const result = await cmdCtx!.newSession();
|
|
1722
|
+
const result = await s.cmdCtx!.newSession();
|
|
1775
1723
|
if (result.cancelled) {
|
|
1776
1724
|
await stopAuto(ctx, pi);
|
|
1777
1725
|
return;
|
|
1778
1726
|
}
|
|
1779
1727
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
1780
|
-
writeLock(lockBase(), triageUnitType, triageUnitId, completedUnits.length, sessionFile);
|
|
1728
|
+
writeLock(lockBase(), triageUnitType, triageUnitId, s.completedUnits.length, sessionFile);
|
|
1781
1729
|
|
|
1782
1730
|
// Start unit timeout for triage (use same supervisor config as hooks)
|
|
1783
1731
|
clearUnitTimeout();
|
|
1784
1732
|
const supervisor = resolveAutoSupervisorConfig();
|
|
1785
1733
|
const triageTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
|
|
1786
|
-
unitTimeoutHandle = setTimeout(async () => {
|
|
1787
|
-
unitTimeoutHandle = null;
|
|
1788
|
-
if (!active) return;
|
|
1734
|
+
s.unitTimeoutHandle = setTimeout(async () => {
|
|
1735
|
+
s.unitTimeoutHandle = null;
|
|
1736
|
+
if (!s.active) return;
|
|
1789
1737
|
ctx.ui.notify(
|
|
1790
1738
|
`Triage unit exceeded timeout. Pausing auto-mode.`,
|
|
1791
1739
|
"warning",
|
|
@@ -1793,9 +1741,9 @@ export async function handleAgentEnd(
|
|
|
1793
1741
|
await pauseAuto(ctx, pi);
|
|
1794
1742
|
}, triageTimeoutMs);
|
|
1795
1743
|
|
|
1796
|
-
if (!active) return;
|
|
1744
|
+
if (!s.active) return;
|
|
1797
1745
|
pi.sendMessage(
|
|
1798
|
-
{ customType: "gsd-auto", content: prompt, display: verbose },
|
|
1746
|
+
{ customType: "gsd-auto", content: prompt, display: s.verbose },
|
|
1799
1747
|
{ triggerTurn: true },
|
|
1800
1748
|
);
|
|
1801
1749
|
return; // handleAgentEnd will fire again when triage session completes
|
|
@@ -1811,13 +1759,13 @@ export async function handleAgentEnd(
|
|
|
1811
1759
|
// Quick-tasks are self-contained one-off tasks that don't modify the plan.
|
|
1812
1760
|
// They're queued during post-triage resolution and dispatched here one at a time.
|
|
1813
1761
|
if (
|
|
1814
|
-
!stepMode &&
|
|
1815
|
-
pendingQuickTasks.length > 0 &&
|
|
1816
|
-
currentUnit &&
|
|
1817
|
-
currentUnit.type !== "quick-task"
|
|
1762
|
+
!s.stepMode &&
|
|
1763
|
+
s.pendingQuickTasks.length > 0 &&
|
|
1764
|
+
s.currentUnit &&
|
|
1765
|
+
s.currentUnit.type !== "quick-task"
|
|
1818
1766
|
) {
|
|
1819
1767
|
try {
|
|
1820
|
-
const capture = pendingQuickTasks.shift()!;
|
|
1768
|
+
const capture = s.pendingQuickTasks.shift()!;
|
|
1821
1769
|
const { buildQuickTaskPrompt } = await import("./triage-resolution.js");
|
|
1822
1770
|
const { markCaptureExecuted } = await import("./captures.js");
|
|
1823
1771
|
const prompt = buildQuickTaskPrompt(capture);
|
|
@@ -1828,27 +1776,16 @@ export async function handleAgentEnd(
|
|
|
1828
1776
|
);
|
|
1829
1777
|
|
|
1830
1778
|
// Close out previous unit metrics
|
|
1831
|
-
if (currentUnit) {
|
|
1832
|
-
|
|
1833
|
-
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
|
|
1834
|
-
const qtActivityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
1835
|
-
if (qtActivityFile) {
|
|
1836
|
-
try {
|
|
1837
|
-
const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
|
|
1838
|
-
const llmCallFn = buildMemoryLLMCall(ctx);
|
|
1839
|
-
if (llmCallFn) {
|
|
1840
|
-
extractMemoriesFromUnit(qtActivityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
|
|
1841
|
-
}
|
|
1842
|
-
} catch { /* non-fatal */ }
|
|
1843
|
-
}
|
|
1779
|
+
if (s.currentUnit) {
|
|
1780
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt);
|
|
1844
1781
|
}
|
|
1845
1782
|
|
|
1846
1783
|
// Dispatch quick-task as a new unit
|
|
1847
1784
|
const qtUnitType = "quick-task";
|
|
1848
|
-
const qtUnitId = `${currentMilestoneId}/${capture.id}`;
|
|
1785
|
+
const qtUnitId = `${ s.currentMilestoneId }/${capture.id}`;
|
|
1849
1786
|
const qtStartedAt = Date.now();
|
|
1850
|
-
currentUnit = { type: qtUnitType, id: qtUnitId, startedAt: qtStartedAt };
|
|
1851
|
-
writeUnitRuntimeRecord(basePath, qtUnitType, qtUnitId, qtStartedAt, {
|
|
1787
|
+
s.currentUnit = { type: qtUnitType, id: qtUnitId, startedAt: qtStartedAt };
|
|
1788
|
+
writeUnitRuntimeRecord(s.basePath, qtUnitType, qtUnitId, qtStartedAt, {
|
|
1852
1789
|
phase: "dispatched",
|
|
1853
1790
|
wrapupWarningSent: false,
|
|
1854
1791
|
timeoutAt: null,
|
|
@@ -1856,27 +1793,27 @@ export async function handleAgentEnd(
|
|
|
1856
1793
|
progressCount: 0,
|
|
1857
1794
|
lastProgressKind: "dispatch",
|
|
1858
1795
|
});
|
|
1859
|
-
const state = await deriveState(basePath);
|
|
1796
|
+
const state = await deriveState(s.basePath);
|
|
1860
1797
|
updateProgressWidget(ctx, qtUnitType, qtUnitId, state);
|
|
1861
1798
|
|
|
1862
|
-
const result = await cmdCtx!.newSession();
|
|
1799
|
+
const result = await s.cmdCtx!.newSession();
|
|
1863
1800
|
if (result.cancelled) {
|
|
1864
1801
|
await stopAuto(ctx, pi);
|
|
1865
1802
|
return;
|
|
1866
1803
|
}
|
|
1867
1804
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
1868
|
-
writeLock(lockBase(), qtUnitType, qtUnitId, completedUnits.length, sessionFile);
|
|
1805
|
+
writeLock(lockBase(), qtUnitType, qtUnitId, s.completedUnits.length, sessionFile);
|
|
1869
1806
|
|
|
1870
1807
|
// Mark capture as executed now that the unit is dispatched
|
|
1871
|
-
markCaptureExecuted(basePath, capture.id);
|
|
1808
|
+
markCaptureExecuted(s.basePath, capture.id);
|
|
1872
1809
|
|
|
1873
1810
|
// Start unit timeout for quick-task
|
|
1874
1811
|
clearUnitTimeout();
|
|
1875
1812
|
const supervisor = resolveAutoSupervisorConfig();
|
|
1876
1813
|
const qtTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
|
|
1877
|
-
unitTimeoutHandle = setTimeout(async () => {
|
|
1878
|
-
unitTimeoutHandle = null;
|
|
1879
|
-
if (!active) return;
|
|
1814
|
+
s.unitTimeoutHandle = setTimeout(async () => {
|
|
1815
|
+
s.unitTimeoutHandle = null;
|
|
1816
|
+
if (!s.active) return;
|
|
1880
1817
|
ctx.ui.notify(
|
|
1881
1818
|
`Quick-task ${capture.id} exceeded timeout. Pausing auto-mode.`,
|
|
1882
1819
|
"warning",
|
|
@@ -1884,9 +1821,9 @@ export async function handleAgentEnd(
|
|
|
1884
1821
|
await pauseAuto(ctx, pi);
|
|
1885
1822
|
}, qtTimeoutMs);
|
|
1886
1823
|
|
|
1887
|
-
if (!active) return;
|
|
1824
|
+
if (!s.active) return;
|
|
1888
1825
|
pi.sendMessage(
|
|
1889
|
-
{ customType: "gsd-auto", content: prompt, display: verbose },
|
|
1826
|
+
{ customType: "gsd-auto", content: prompt, display: s.verbose },
|
|
1890
1827
|
{ triggerTurn: true },
|
|
1891
1828
|
);
|
|
1892
1829
|
return; // handleAgentEnd will fire again when quick-task session completes
|
|
@@ -1896,7 +1833,7 @@ export async function handleAgentEnd(
|
|
|
1896
1833
|
}
|
|
1897
1834
|
|
|
1898
1835
|
// In step mode, pause and show a wizard instead of immediately dispatching
|
|
1899
|
-
if (stepMode) {
|
|
1836
|
+
if (s.stepMode) {
|
|
1900
1837
|
await showStepWizard(ctx, pi);
|
|
1901
1838
|
return;
|
|
1902
1839
|
}
|
|
@@ -1906,7 +1843,7 @@ export async function handleAgentEnd(
|
|
|
1906
1843
|
} catch (dispatchErr) {
|
|
1907
1844
|
// dispatchNextUnit threw — without this catch the error would propagate
|
|
1908
1845
|
// to the pi event emitter which may silently swallow async rejections,
|
|
1909
|
-
// leaving auto-mode active but permanently stalled (see #381).
|
|
1846
|
+
// leaving auto-mode s.active but permanently stalled (see #381).
|
|
1910
1847
|
const message = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr);
|
|
1911
1848
|
ctx.ui.notify(
|
|
1912
1849
|
`Dispatch error after unit completion: ${message}. Retrying in ${DISPATCH_GAP_TIMEOUT_MS / 1000}s.`,
|
|
@@ -1919,15 +1856,15 @@ export async function handleAgentEnd(
|
|
|
1919
1856
|
return;
|
|
1920
1857
|
}
|
|
1921
1858
|
|
|
1922
|
-
// If dispatchNextUnit returned normally but auto-mode is still active and
|
|
1859
|
+
// If dispatchNextUnit returned normally but auto-mode is still s.active and
|
|
1923
1860
|
// no new unit timeout was set (meaning sendMessage was never called), start
|
|
1924
1861
|
// the dispatch gap watchdog as a safety net.
|
|
1925
|
-
if (active && !unitTimeoutHandle && !wrapupWarningHandle) {
|
|
1862
|
+
if (s.active && !s.unitTimeoutHandle && !s.wrapupWarningHandle) {
|
|
1926
1863
|
startDispatchGapWatchdog(ctx, pi);
|
|
1927
1864
|
}
|
|
1928
1865
|
|
|
1929
1866
|
} finally {
|
|
1930
|
-
|
|
1867
|
+
s.handlingAgentEnd = false;
|
|
1931
1868
|
}
|
|
1932
1869
|
}
|
|
1933
1870
|
|
|
@@ -1942,14 +1879,14 @@ async function showStepWizard(
|
|
|
1942
1879
|
ctx: ExtensionContext,
|
|
1943
1880
|
pi: ExtensionAPI,
|
|
1944
1881
|
): Promise<void> {
|
|
1945
|
-
if (!cmdCtx) return;
|
|
1882
|
+
if (!s.cmdCtx) return;
|
|
1946
1883
|
|
|
1947
|
-
const state = await deriveState(basePath);
|
|
1884
|
+
const state = await deriveState(s.basePath);
|
|
1948
1885
|
const mid = state.activeMilestone?.id;
|
|
1949
1886
|
|
|
1950
1887
|
// Build summary of what just completed
|
|
1951
|
-
const justFinished = currentUnit
|
|
1952
|
-
? `${unitVerb(currentUnit.type)} ${currentUnit.id}`
|
|
1888
|
+
const justFinished = s.currentUnit
|
|
1889
|
+
? `${unitVerb(s.currentUnit.type)} ${s.currentUnit.id}`
|
|
1953
1890
|
: "previous unit";
|
|
1954
1891
|
|
|
1955
1892
|
// If no active milestone or everything is complete, stop
|
|
@@ -1957,7 +1894,7 @@ async function showStepWizard(
|
|
|
1957
1894
|
const incomplete = state.registry.filter(m => m.status !== "complete");
|
|
1958
1895
|
if (incomplete.length > 0 && state.phase !== "complete" && state.phase !== "blocked") {
|
|
1959
1896
|
const ids = incomplete.map(m => m.id).join(", ");
|
|
1960
|
-
const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
|
|
1897
|
+
const diag = `basePath=${s.basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
|
|
1961
1898
|
ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
|
|
1962
1899
|
await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids})`);
|
|
1963
1900
|
} else {
|
|
@@ -1969,7 +1906,7 @@ async function showStepWizard(
|
|
|
1969
1906
|
// Peek at what's next by examining state
|
|
1970
1907
|
const nextDesc = _describeNextUnit(state);
|
|
1971
1908
|
|
|
1972
|
-
const choice = await showNextAction(cmdCtx, {
|
|
1909
|
+
const choice = await showNextAction(s.cmdCtx, {
|
|
1973
1910
|
title: `GSD — ${justFinished} complete`,
|
|
1974
1911
|
summary: [
|
|
1975
1912
|
`${mid}: ${state.activeMilestone?.title ?? mid}`,
|
|
@@ -1999,7 +1936,7 @@ async function showStepWizard(
|
|
|
1999
1936
|
if (choice === "continue") {
|
|
2000
1937
|
await dispatchNextUnit(ctx, pi);
|
|
2001
1938
|
} else if (choice === "auto") {
|
|
2002
|
-
stepMode = false;
|
|
1939
|
+
s.stepMode = false;
|
|
2003
1940
|
ctx.ui.setStatus("gsd-auto", "auto");
|
|
2004
1941
|
ctx.ui.notify("Switched to auto-mode.", "info");
|
|
2005
1942
|
await dispatchNextUnit(ctx, pi);
|
|
@@ -2024,58 +1961,57 @@ function updateProgressWidget(
|
|
|
2024
1961
|
unitId: string,
|
|
2025
1962
|
state: GSDState,
|
|
2026
1963
|
): void {
|
|
2027
|
-
const badge = currentUnitRouting?.tier
|
|
2028
|
-
? ({ light: "L", standard: "S", heavy: "H" }[currentUnitRouting.tier] ?? undefined)
|
|
1964
|
+
const badge = s.currentUnitRouting?.tier
|
|
1965
|
+
? ({ light: "L", standard: "S", heavy: "H" }[s.currentUnitRouting.tier] ?? undefined)
|
|
2029
1966
|
: undefined;
|
|
2030
1967
|
_updateProgressWidget(ctx, unitType, unitId, state, widgetStateAccessors, badge);
|
|
2031
1968
|
}
|
|
2032
1969
|
|
|
2033
1970
|
/** State accessors for the widget — closures over module globals. */
|
|
2034
1971
|
const widgetStateAccessors: WidgetStateAccessors = {
|
|
2035
|
-
getAutoStartTime: () => autoStartTime,
|
|
2036
|
-
isStepMode: () => stepMode,
|
|
2037
|
-
getCmdCtx: () => cmdCtx,
|
|
2038
|
-
getBasePath: () => basePath,
|
|
2039
|
-
isVerbose: () => verbose,
|
|
1972
|
+
getAutoStartTime: () => s.autoStartTime,
|
|
1973
|
+
isStepMode: () => s.stepMode,
|
|
1974
|
+
getCmdCtx: () => s.cmdCtx,
|
|
1975
|
+
getBasePath: () => s.basePath,
|
|
1976
|
+
isVerbose: () => s.verbose,
|
|
2040
1977
|
};
|
|
2041
1978
|
|
|
2042
1979
|
// ─── Core Loop ────────────────────────────────────────────────────────────────
|
|
2043
1980
|
|
|
2044
1981
|
/** Tracks recursive skip depth to prevent TUI freeze on cascading completed-unit skips */
|
|
2045
|
-
let _skipDepth = 0;
|
|
2046
|
-
const MAX_SKIP_DEPTH = 20;
|
|
2047
1982
|
|
|
2048
1983
|
/** Reentrancy guard for dispatchNextUnit itself (not just handleAgentEnd).
|
|
2049
1984
|
* Prevents concurrent dispatch from watchdog timers, step wizard, and direct calls
|
|
2050
|
-
* that bypass the
|
|
2051
|
-
* allowed via
|
|
2052
|
-
|
|
1985
|
+
* that bypass the s.handlingAgentEnd guard. Recursive calls (from skip paths) are
|
|
1986
|
+
* allowed via s.skipDepth > 0. */
|
|
1987
|
+
|
|
1988
|
+
/** Keys recently evicted by skip-loop breaker — prevents re-persistence in the fallback path (#912). */
|
|
2053
1989
|
|
|
2054
1990
|
async function dispatchNextUnit(
|
|
2055
1991
|
ctx: ExtensionContext,
|
|
2056
1992
|
pi: ExtensionAPI,
|
|
2057
1993
|
): Promise<void> {
|
|
2058
|
-
if (!active || !cmdCtx) {
|
|
2059
|
-
debugLog(`dispatchNextUnit early return — active=${active}, cmdCtx=${!!cmdCtx}`);
|
|
2060
|
-
if (active && !cmdCtx) {
|
|
1994
|
+
if (!s.active || !s.cmdCtx) {
|
|
1995
|
+
debugLog(`dispatchNextUnit early return — active=${s.active}, cmdCtx=${!!s.cmdCtx}`);
|
|
1996
|
+
if (s.active && !s.cmdCtx) {
|
|
2061
1997
|
ctx.ui.notify("Auto-mode session expired. Run /gsd auto to restart.", "info");
|
|
2062
1998
|
}
|
|
2063
1999
|
return;
|
|
2064
2000
|
}
|
|
2065
2001
|
|
|
2066
|
-
// Reentrancy guard: allow recursive calls from skip paths (
|
|
2002
|
+
// Reentrancy guard: allow recursive calls from skip paths (s.skipDepth > 0)
|
|
2067
2003
|
// but block concurrent external calls (watchdog, step wizard, etc.)
|
|
2068
|
-
if (
|
|
2004
|
+
if (s.dispatching && s.skipDepth === 0) {
|
|
2069
2005
|
debugLog("dispatchNextUnit reentrancy guard — another dispatch in progress, bailing");
|
|
2070
2006
|
return; // Another dispatch is in progress — bail silently
|
|
2071
2007
|
}
|
|
2072
|
-
|
|
2008
|
+
s.dispatching = true;
|
|
2073
2009
|
try {
|
|
2074
2010
|
// Recursion depth guard: when many units are skipped in sequence (e.g., after
|
|
2075
2011
|
// crash recovery with 10+ completed units), recursive dispatchNextUnit calls
|
|
2076
2012
|
// can freeze the TUI or overflow the stack. Yield generously after MAX_SKIP_DEPTH.
|
|
2077
|
-
if (
|
|
2078
|
-
|
|
2013
|
+
if (s.skipDepth > MAX_SKIP_DEPTH) {
|
|
2014
|
+
s.skipDepth = 0;
|
|
2079
2015
|
ctx.ui.notify(`Skipped ${MAX_SKIP_DEPTH}+ completed units. Yielding to UI before continuing.`, "info");
|
|
2080
2016
|
await new Promise(r => setTimeout(r, 200));
|
|
2081
2017
|
}
|
|
@@ -2085,7 +2021,7 @@ async function dispatchNextUnit(
|
|
|
2085
2021
|
// once at startup. If resources were re-synced (e.g. /gsd:update, npm update,
|
|
2086
2022
|
// or dev copy-resources), templates may expect variables the in-memory code
|
|
2087
2023
|
// doesn't provide. Stop gracefully instead of crashing.
|
|
2088
|
-
const staleMsg = checkResourcesStale();
|
|
2024
|
+
const staleMsg = checkResourcesStale(s.resourceVersionOnStart);
|
|
2089
2025
|
if (staleMsg) {
|
|
2090
2026
|
await stopAuto(ctx, pi, staleMsg);
|
|
2091
2027
|
return;
|
|
@@ -2095,14 +2031,14 @@ async function dispatchNextUnit(
|
|
|
2095
2031
|
// Parse cache is also cleared — doctor may have re-populated it with
|
|
2096
2032
|
// stale data between handleAgentEnd and this dispatch call (Path B fix).
|
|
2097
2033
|
invalidateAllCaches();
|
|
2098
|
-
lastPromptCharCount = undefined;
|
|
2099
|
-
lastBaselineCharCount = undefined;
|
|
2034
|
+
s.lastPromptCharCount = undefined;
|
|
2035
|
+
s.lastBaselineCharCount = undefined;
|
|
2100
2036
|
|
|
2101
2037
|
// ── Pre-dispatch health gate ──────────────────────────────────────────
|
|
2102
2038
|
// Lightweight check for critical issues that would cause the next unit
|
|
2103
2039
|
// to fail or corrupt state. Auto-heals what it can, blocks on the rest.
|
|
2104
2040
|
try {
|
|
2105
|
-
const healthGate = await preDispatchHealthGate(basePath);
|
|
2041
|
+
const healthGate = await preDispatchHealthGate(s.basePath);
|
|
2106
2042
|
if (healthGate.fixesApplied.length > 0) {
|
|
2107
2043
|
ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info");
|
|
2108
2044
|
}
|
|
@@ -2119,12 +2055,12 @@ async function dispatchNextUnit(
|
|
|
2119
2055
|
// When the LLM writes artifacts to the main repo filesystem instead of
|
|
2120
2056
|
// the worktree, the worktree's gsd.db becomes stale. Sync before
|
|
2121
2057
|
// deriveState to ensure the worktree has the latest artifacts.
|
|
2122
|
-
if (originalBasePath && basePath !== originalBasePath && currentMilestoneId) {
|
|
2123
|
-
syncProjectRootToWorktree(originalBasePath, basePath, currentMilestoneId);
|
|
2058
|
+
if (s.originalBasePath && s.basePath !== s.originalBasePath && s.currentMilestoneId) {
|
|
2059
|
+
syncProjectRootToWorktree(s.originalBasePath, s.basePath, s.currentMilestoneId);
|
|
2124
2060
|
}
|
|
2125
2061
|
|
|
2126
2062
|
const stopDeriveTimer = debugTime("derive-state");
|
|
2127
|
-
let state = await deriveState(basePath);
|
|
2063
|
+
let state = await deriveState(s.basePath);
|
|
2128
2064
|
stopDeriveTimer({
|
|
2129
2065
|
phase: state.phase,
|
|
2130
2066
|
milestone: state.activeMilestone?.id,
|
|
@@ -2135,27 +2071,75 @@ async function dispatchNextUnit(
|
|
|
2135
2071
|
let midTitle = state.activeMilestone?.title;
|
|
2136
2072
|
|
|
2137
2073
|
// Detect milestone transition
|
|
2138
|
-
if (mid && currentMilestoneId && mid !== currentMilestoneId) {
|
|
2074
|
+
if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) {
|
|
2139
2075
|
ctx.ui.notify(
|
|
2140
|
-
`Milestone ${currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`,
|
|
2076
|
+
`Milestone ${ s.currentMilestoneId } complete. Advancing to ${mid}: ${midTitle}.`,
|
|
2141
2077
|
"info",
|
|
2142
2078
|
);
|
|
2143
|
-
sendDesktopNotification("GSD", `Milestone ${currentMilestoneId} complete!`, "success", "milestone");
|
|
2079
|
+
sendDesktopNotification("GSD", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone");
|
|
2144
2080
|
// Hint: visualizer available after milestone transition
|
|
2145
2081
|
const vizPrefs = loadEffectiveGSDPreferences()?.preferences;
|
|
2146
2082
|
if (vizPrefs?.auto_visualize) {
|
|
2147
2083
|
ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
|
|
2148
2084
|
}
|
|
2085
|
+
// Auto-generate HTML report snapshot on milestone completion (default: on, disable with auto_report: false)
|
|
2086
|
+
if (vizPrefs?.auto_report !== false) {
|
|
2087
|
+
try {
|
|
2088
|
+
const { loadVisualizerData } = await import("./visualizer-data.js");
|
|
2089
|
+
const { generateHtmlReport } = await import("./export-html.js");
|
|
2090
|
+
const { writeReportSnapshot, reportsDir } = await import("./reports.js");
|
|
2091
|
+
const { basename } = await import("node:path");
|
|
2092
|
+
const snapData = await loadVisualizerData(s.basePath);
|
|
2093
|
+
const completedMs = snapData.milestones.find(m => m.id === s.currentMilestoneId);
|
|
2094
|
+
const msTitle = completedMs?.title ?? s.currentMilestoneId;
|
|
2095
|
+
const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
|
|
2096
|
+
const projName = basename(s.basePath);
|
|
2097
|
+
const doneSlices = snapData.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
|
|
2098
|
+
const totalSlices = snapData.milestones.reduce((s, m) => s + m.slices.length, 0);
|
|
2099
|
+
const outPath = writeReportSnapshot({ basePath: s.basePath,
|
|
2100
|
+
html: generateHtmlReport(snapData, {
|
|
2101
|
+
projectName: projName,
|
|
2102
|
+
projectPath: s.basePath,
|
|
2103
|
+
gsdVersion,
|
|
2104
|
+
milestoneId: s.currentMilestoneId,
|
|
2105
|
+
indexRelPath: "index.html",
|
|
2106
|
+
}),
|
|
2107
|
+
milestoneId: s.currentMilestoneId,
|
|
2108
|
+
milestoneTitle: msTitle,
|
|
2109
|
+
kind: "milestone",
|
|
2110
|
+
projectName: projName,
|
|
2111
|
+
projectPath: s.basePath,
|
|
2112
|
+
gsdVersion,
|
|
2113
|
+
totalCost: snapData.totals?.cost ?? 0,
|
|
2114
|
+
totalTokens: snapData.totals?.tokens.total ?? 0,
|
|
2115
|
+
totalDuration: snapData.totals?.duration ?? 0,
|
|
2116
|
+
doneSlices,
|
|
2117
|
+
totalSlices,
|
|
2118
|
+
doneMilestones: snapData.milestones.filter(m => m.status === "complete").length,
|
|
2119
|
+
totalMilestones: snapData.milestones.length,
|
|
2120
|
+
phase: snapData.phase,
|
|
2121
|
+
});
|
|
2122
|
+
ctx.ui.notify(
|
|
2123
|
+
`Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`,
|
|
2124
|
+
"info",
|
|
2125
|
+
);
|
|
2126
|
+
} catch (err) {
|
|
2127
|
+
ctx.ui.notify(
|
|
2128
|
+
`Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2129
|
+
"warning",
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2149
2133
|
// Reset stuck detection for new milestone
|
|
2150
|
-
unitDispatchCount.clear();
|
|
2151
|
-
unitRecoveryCount.clear();
|
|
2152
|
-
unitConsecutiveSkips.clear();
|
|
2153
|
-
unitLifetimeDispatches.clear();
|
|
2134
|
+
s.unitDispatchCount.clear();
|
|
2135
|
+
s.unitRecoveryCount.clear();
|
|
2136
|
+
s.unitConsecutiveSkips.clear();
|
|
2137
|
+
s.unitLifetimeDispatches.clear();
|
|
2154
2138
|
// Clear completed-units.json for the finished milestone
|
|
2155
2139
|
try {
|
|
2156
|
-
const file = completedKeysPath(basePath);
|
|
2140
|
+
const file = completedKeysPath(s.basePath);
|
|
2157
2141
|
if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
|
|
2158
|
-
completedKeySet.clear();
|
|
2142
|
+
s.completedKeySet.clear();
|
|
2159
2143
|
} catch { /* non-fatal */ }
|
|
2160
2144
|
|
|
2161
2145
|
// ── Worktree lifecycle on milestone transition (#616) ──────────────
|
|
@@ -2165,20 +2149,20 @@ async function dispatchNextUnit(
|
|
|
2165
2149
|
// 3. Create a new worktree for the incoming milestone
|
|
2166
2150
|
// Without this, M_new runs inside M_old's worktree on the wrong branch,
|
|
2167
2151
|
// and artifact paths resolve against the wrong .gsd/ directory.
|
|
2168
|
-
if (isInAutoWorktree(basePath) && originalBasePath && shouldUseWorktreeIsolation()) {
|
|
2152
|
+
if (isInAutoWorktree(s.basePath) && s.originalBasePath && shouldUseWorktreeIsolation()) {
|
|
2169
2153
|
try {
|
|
2170
|
-
const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
|
|
2154
|
+
const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
|
|
2171
2155
|
if (roadmapPath) {
|
|
2172
2156
|
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
2173
|
-
const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent);
|
|
2157
|
+
const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
|
|
2174
2158
|
ctx.ui.notify(
|
|
2175
|
-
`Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
2159
|
+
`Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
2176
2160
|
"info",
|
|
2177
2161
|
);
|
|
2178
2162
|
} else {
|
|
2179
2163
|
// No roadmap found — teardown worktree without merge
|
|
2180
|
-
teardownAutoWorktree(originalBasePath, currentMilestoneId);
|
|
2181
|
-
ctx.ui.notify(`Exited worktree for ${currentMilestoneId} (no roadmap for merge).`, "info");
|
|
2164
|
+
teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId);
|
|
2165
|
+
ctx.ui.notify(`Exited worktree for ${ s.currentMilestoneId } (no roadmap for merge).`, "info");
|
|
2182
2166
|
}
|
|
2183
2167
|
} catch (err) {
|
|
2184
2168
|
ctx.ui.notify(
|
|
@@ -2186,28 +2170,28 @@ async function dispatchNextUnit(
|
|
|
2186
2170
|
"warning",
|
|
2187
2171
|
);
|
|
2188
2172
|
// Force cwd back to project root even if merge failed
|
|
2189
|
-
if (originalBasePath) {
|
|
2190
|
-
try { process.chdir(originalBasePath); } catch { /* best-effort */ }
|
|
2173
|
+
if (s.originalBasePath) {
|
|
2174
|
+
try { process.chdir(s.originalBasePath); } catch { /* best-effort */ }
|
|
2191
2175
|
}
|
|
2192
2176
|
}
|
|
2193
2177
|
|
|
2194
|
-
// Update basePath to project root (mergeMilestoneToMain already chdir'd)
|
|
2195
|
-
basePath = originalBasePath;
|
|
2196
|
-
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
2178
|
+
// Update s.basePath to project root (mergeMilestoneToMain already chdir'd)
|
|
2179
|
+
s.basePath = s.originalBasePath;
|
|
2180
|
+
s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
2197
2181
|
invalidateAllCaches();
|
|
2198
2182
|
|
|
2199
2183
|
// Re-derive state from project root before creating new worktree
|
|
2200
|
-
state = await deriveState(basePath);
|
|
2184
|
+
state = await deriveState(s.basePath);
|
|
2201
2185
|
mid = state.activeMilestone?.id;
|
|
2202
2186
|
midTitle = state.activeMilestone?.title;
|
|
2203
2187
|
|
|
2204
2188
|
// Create new worktree for the incoming milestone
|
|
2205
2189
|
if (mid) {
|
|
2206
|
-
captureIntegrationBranch(basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
|
|
2190
|
+
captureIntegrationBranch(s.basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
|
|
2207
2191
|
try {
|
|
2208
|
-
const wtPath = createAutoWorktree(basePath, mid);
|
|
2209
|
-
basePath = wtPath;
|
|
2210
|
-
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
2192
|
+
const wtPath = createAutoWorktree(s.basePath, mid);
|
|
2193
|
+
s.basePath = wtPath;
|
|
2194
|
+
s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
2211
2195
|
ctx.ui.notify(`Created auto-worktree for ${mid} at ${wtPath}`, "info");
|
|
2212
2196
|
} catch (err) {
|
|
2213
2197
|
ctx.ui.notify(
|
|
@@ -2220,7 +2204,7 @@ async function dispatchNextUnit(
|
|
|
2220
2204
|
// Not in worktree — capture integration branch for the new milestone (branch mode only).
|
|
2221
2205
|
// In none mode there's no milestone branch to merge back to, so skip.
|
|
2222
2206
|
if (getIsolationMode() !== "none") {
|
|
2223
|
-
captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
|
|
2207
|
+
captureIntegrationBranch(s.originalBasePath || s.basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
|
|
2224
2208
|
}
|
|
2225
2209
|
}
|
|
2226
2210
|
|
|
@@ -2228,19 +2212,17 @@ async function dispatchNextUnit(
|
|
|
2228
2212
|
const pendingIds = state.registry
|
|
2229
2213
|
.filter(m => m.status !== "complete")
|
|
2230
2214
|
.map(m => m.id);
|
|
2231
|
-
pruneQueueOrder(basePath, pendingIds);
|
|
2215
|
+
pruneQueueOrder(s.basePath, pendingIds);
|
|
2232
2216
|
}
|
|
2233
2217
|
if (mid) {
|
|
2234
|
-
currentMilestoneId = mid;
|
|
2235
|
-
setActiveMilestoneId(basePath, mid);
|
|
2218
|
+
s.currentMilestoneId = mid;
|
|
2219
|
+
setActiveMilestoneId(s.basePath, mid);
|
|
2236
2220
|
}
|
|
2237
2221
|
|
|
2238
2222
|
if (!mid) {
|
|
2239
2223
|
// Save final session before stopping
|
|
2240
|
-
if (currentUnit) {
|
|
2241
|
-
|
|
2242
|
-
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
|
|
2243
|
-
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
2224
|
+
if (s.currentUnit) {
|
|
2225
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
2244
2226
|
}
|
|
2245
2227
|
|
|
2246
2228
|
const incomplete = state.registry.filter(m => m.status !== "complete");
|
|
@@ -2255,9 +2237,9 @@ async function dispatchNextUnit(
|
|
|
2255
2237
|
ctx.ui.notify(`${blockerMsg}. Fix and run /gsd auto.`, "warning");
|
|
2256
2238
|
sendDesktopNotification("GSD", blockerMsg, "error", "attention");
|
|
2257
2239
|
} else {
|
|
2258
|
-
// Milestones with remaining work exist but none became active — unexpected
|
|
2240
|
+
// Milestones with remaining work exist but none became s.active — unexpected
|
|
2259
2241
|
const ids = incomplete.map(m => m.id).join(", ");
|
|
2260
|
-
const diag = `basePath=${basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
|
|
2242
|
+
const diag = `basePath=${s.basePath}, milestones=[${state.registry.map(m => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`;
|
|
2261
2243
|
ctx.ui.notify(`Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error");
|
|
2262
2244
|
await stopAuto(ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`);
|
|
2263
2245
|
}
|
|
@@ -2273,19 +2255,17 @@ async function dispatchNextUnit(
|
|
|
2273
2255
|
}
|
|
2274
2256
|
|
|
2275
2257
|
// ── Mid-merge safety check: detect leftover merge state from a prior session ──
|
|
2276
|
-
if (reconcileMergeState(basePath, ctx)) {
|
|
2258
|
+
if (reconcileMergeState(s.basePath, ctx)) {
|
|
2277
2259
|
invalidateAllCaches();
|
|
2278
|
-
state = await deriveState(basePath);
|
|
2260
|
+
state = await deriveState(s.basePath);
|
|
2279
2261
|
mid = state.activeMilestone?.id;
|
|
2280
2262
|
midTitle = state.activeMilestone?.title;
|
|
2281
2263
|
}
|
|
2282
2264
|
|
|
2283
2265
|
// After merge guard removal (branchless architecture), mid/midTitle could be undefined
|
|
2284
2266
|
if (!mid || !midTitle) {
|
|
2285
|
-
if (currentUnit) {
|
|
2286
|
-
|
|
2287
|
-
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
|
|
2288
|
-
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
2267
|
+
if (s.currentUnit) {
|
|
2268
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
2289
2269
|
}
|
|
2290
2270
|
const noMilestoneReason = !mid
|
|
2291
2271
|
? "No active milestone after merge reconciliation"
|
|
@@ -2300,28 +2280,26 @@ async function dispatchNextUnit(
|
|
|
2300
2280
|
let prompt: string;
|
|
2301
2281
|
|
|
2302
2282
|
if (state.phase === "complete") {
|
|
2303
|
-
if (currentUnit) {
|
|
2304
|
-
|
|
2305
|
-
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
|
|
2306
|
-
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
2283
|
+
if (s.currentUnit) {
|
|
2284
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
2307
2285
|
}
|
|
2308
2286
|
// Clear completed-units.json for the finished milestone so it doesn't grow unbounded.
|
|
2309
2287
|
try {
|
|
2310
|
-
const file = completedKeysPath(basePath);
|
|
2288
|
+
const file = completedKeysPath(s.basePath);
|
|
2311
2289
|
if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
|
|
2312
|
-
completedKeySet.clear();
|
|
2290
|
+
s.completedKeySet.clear();
|
|
2313
2291
|
} catch { /* non-fatal */ }
|
|
2314
2292
|
// ── Milestone merge: squash-merge milestone branch to main before stopping ──
|
|
2315
|
-
if (currentMilestoneId && isInAutoWorktree(basePath) && originalBasePath) {
|
|
2293
|
+
if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {
|
|
2316
2294
|
try {
|
|
2317
|
-
const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
|
|
2318
|
-
if (!roadmapPath) throw new Error(`Cannot resolve ROADMAP file for milestone ${currentMilestoneId}`);
|
|
2295
|
+
const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
|
|
2296
|
+
if (!roadmapPath) throw new Error(`Cannot resolve ROADMAP file for milestone ${ s.currentMilestoneId }`);
|
|
2319
2297
|
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
2320
|
-
const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent);
|
|
2321
|
-
basePath = originalBasePath;
|
|
2322
|
-
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
2298
|
+
const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
|
|
2299
|
+
s.basePath = s.originalBasePath;
|
|
2300
|
+
s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
2323
2301
|
ctx.ui.notify(
|
|
2324
|
-
`Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
2302
|
+
`Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
2325
2303
|
"info",
|
|
2326
2304
|
);
|
|
2327
2305
|
} catch (err) {
|
|
@@ -2332,27 +2310,27 @@ async function dispatchNextUnit(
|
|
|
2332
2310
|
// Ensure cwd is restored even if merge failed partway through (#608).
|
|
2333
2311
|
// mergeMilestoneToMain may have chdir'd but then thrown, leaving us
|
|
2334
2312
|
// in an indeterminate location.
|
|
2335
|
-
if (originalBasePath) {
|
|
2336
|
-
basePath = originalBasePath;
|
|
2337
|
-
try { process.chdir(basePath); } catch { /* best-effort */ }
|
|
2313
|
+
if (s.originalBasePath) {
|
|
2314
|
+
s.basePath = s.originalBasePath;
|
|
2315
|
+
try { process.chdir(s.basePath); } catch { /* best-effort */ }
|
|
2338
2316
|
}
|
|
2339
2317
|
}
|
|
2340
|
-
} else if (currentMilestoneId && !isInAutoWorktree(basePath) && getIsolationMode() !== "none") {
|
|
2318
|
+
} else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() !== "none") {
|
|
2341
2319
|
// Branch isolation mode (#603): no worktree, but we may be on a milestone/* branch.
|
|
2342
2320
|
// Squash-merge back to the integration branch (or main) before stopping.
|
|
2343
2321
|
try {
|
|
2344
|
-
const currentBranch = getCurrentBranch(basePath);
|
|
2345
|
-
const milestoneBranch = autoWorktreeBranch(currentMilestoneId);
|
|
2322
|
+
const currentBranch = getCurrentBranch(s.basePath);
|
|
2323
|
+
const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
|
|
2346
2324
|
if (currentBranch === milestoneBranch) {
|
|
2347
|
-
const roadmapPath = resolveMilestoneFile(basePath, currentMilestoneId, "ROADMAP");
|
|
2325
|
+
const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP");
|
|
2348
2326
|
if (roadmapPath) {
|
|
2349
2327
|
const roadmapContent = readFileSync(roadmapPath, "utf-8");
|
|
2350
2328
|
// mergeMilestoneToMain handles: auto-commit, checkout integration branch,
|
|
2351
2329
|
// squash merge, commit, optional push, branch deletion.
|
|
2352
|
-
const mergeResult = mergeMilestoneToMain(basePath, currentMilestoneId, roadmapContent);
|
|
2353
|
-
gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
2330
|
+
const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
|
|
2331
|
+
s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
|
|
2354
2332
|
ctx.ui.notify(
|
|
2355
|
-
`Milestone ${currentMilestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
2333
|
+
`Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
|
|
2356
2334
|
"info",
|
|
2357
2335
|
);
|
|
2358
2336
|
}
|
|
@@ -2370,10 +2348,8 @@ async function dispatchNextUnit(
|
|
|
2370
2348
|
}
|
|
2371
2349
|
|
|
2372
2350
|
if (state.phase === "blocked") {
|
|
2373
|
-
if (currentUnit) {
|
|
2374
|
-
|
|
2375
|
-
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
|
|
2376
|
-
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
2351
|
+
if (s.currentUnit) {
|
|
2352
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
2377
2353
|
}
|
|
2378
2354
|
const blockerMsg = `Blocked: ${state.blockers.join(", ")}`;
|
|
2379
2355
|
await stopAuto(ctx, pi, blockerMsg);
|
|
@@ -2393,14 +2369,14 @@ async function dispatchNextUnit(
|
|
|
2393
2369
|
const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0;
|
|
2394
2370
|
const budgetPct = totalCost / budgetCeiling;
|
|
2395
2371
|
const budgetAlertLevel = getBudgetAlertLevel(budgetPct);
|
|
2396
|
-
const newBudgetAlertLevel = getNewBudgetAlertLevel(lastBudgetAlertLevel, budgetPct);
|
|
2372
|
+
const newBudgetAlertLevel = getNewBudgetAlertLevel(s.lastBudgetAlertLevel, budgetPct);
|
|
2397
2373
|
const enforcement = prefs?.budget_enforcement ?? "pause";
|
|
2398
2374
|
|
|
2399
2375
|
const budgetEnforcementAction = getBudgetEnforcementAction(enforcement, budgetPct);
|
|
2400
2376
|
|
|
2401
2377
|
if (newBudgetAlertLevel === 100 && budgetEnforcementAction !== "none") {
|
|
2402
2378
|
const msg = `Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}).`;
|
|
2403
|
-
lastBudgetAlertLevel = newBudgetAlertLevel;
|
|
2379
|
+
s.lastBudgetAlertLevel = newBudgetAlertLevel;
|
|
2404
2380
|
if (budgetEnforcementAction === "halt") {
|
|
2405
2381
|
sendDesktopNotification("GSD", msg, "error", "budget");
|
|
2406
2382
|
await stopAuto(ctx, pi, "Budget ceiling reached");
|
|
@@ -2415,28 +2391,28 @@ async function dispatchNextUnit(
|
|
|
2415
2391
|
ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning");
|
|
2416
2392
|
sendDesktopNotification("GSD", msg, "warning", "budget");
|
|
2417
2393
|
} else if (newBudgetAlertLevel === 90) {
|
|
2418
|
-
lastBudgetAlertLevel = newBudgetAlertLevel;
|
|
2394
|
+
s.lastBudgetAlertLevel = newBudgetAlertLevel;
|
|
2419
2395
|
ctx.ui.notify(`Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
|
|
2420
2396
|
sendDesktopNotification("GSD", `Budget 90%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
|
|
2421
2397
|
} else if (newBudgetAlertLevel === 80) {
|
|
2422
|
-
lastBudgetAlertLevel = newBudgetAlertLevel;
|
|
2398
|
+
s.lastBudgetAlertLevel = newBudgetAlertLevel;
|
|
2423
2399
|
ctx.ui.notify(`Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning");
|
|
2424
2400
|
sendDesktopNotification("GSD", `Approaching budget ceiling — 80%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "warning", "budget");
|
|
2425
2401
|
} else if (newBudgetAlertLevel === 75) {
|
|
2426
|
-
lastBudgetAlertLevel = newBudgetAlertLevel;
|
|
2402
|
+
s.lastBudgetAlertLevel = newBudgetAlertLevel;
|
|
2427
2403
|
ctx.ui.notify(`Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info");
|
|
2428
2404
|
sendDesktopNotification("GSD", `Budget 75%: ${formatCost(totalCost)} / ${formatCost(budgetCeiling)}`, "info", "budget");
|
|
2429
2405
|
} else if (budgetAlertLevel === 0) {
|
|
2430
|
-
lastBudgetAlertLevel = 0;
|
|
2406
|
+
s.lastBudgetAlertLevel = 0;
|
|
2431
2407
|
}
|
|
2432
2408
|
} else {
|
|
2433
|
-
lastBudgetAlertLevel = 0;
|
|
2409
|
+
s.lastBudgetAlertLevel = 0;
|
|
2434
2410
|
}
|
|
2435
2411
|
|
|
2436
2412
|
// Context window guard — pause if approaching context limits
|
|
2437
2413
|
const contextThreshold = prefs?.context_pause_threshold ?? 0; // 0 = disabled by default
|
|
2438
|
-
if (contextThreshold > 0 && cmdCtx) {
|
|
2439
|
-
const contextUsage = cmdCtx.getContextUsage();
|
|
2414
|
+
if (contextThreshold > 0 && s.cmdCtx) {
|
|
2415
|
+
const contextUsage = s.cmdCtx.getContextUsage();
|
|
2440
2416
|
if (contextUsage && contextUsage.percent !== null && contextUsage.percent >= contextThreshold) {
|
|
2441
2417
|
const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`;
|
|
2442
2418
|
ctx.ui.notify(`${msg} Run /gsd auto to continue (will start fresh session).`, "warning");
|
|
@@ -2453,9 +2429,9 @@ async function dispatchNextUnit(
|
|
|
2453
2429
|
// into plan-slice / execute-task with no real credentials and mock everything.
|
|
2454
2430
|
const runSecretsGate = async () => {
|
|
2455
2431
|
try {
|
|
2456
|
-
const manifestStatus = await getManifestStatus(basePath, mid);
|
|
2432
|
+
const manifestStatus = await getManifestStatus(s.basePath, mid);
|
|
2457
2433
|
if (manifestStatus && manifestStatus.pending.length > 0) {
|
|
2458
|
-
const result = await collectSecretsFromManifest(basePath, mid, ctx);
|
|
2434
|
+
const result = await collectSecretsFromManifest(s.basePath, mid, ctx);
|
|
2459
2435
|
if (result && result.applied && result.skipped && result.existingSkipped) {
|
|
2460
2436
|
ctx.ui.notify(
|
|
2461
2437
|
`Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`,
|
|
@@ -2476,15 +2452,12 @@ async function dispatchNextUnit(
|
|
|
2476
2452
|
await runSecretsGate();
|
|
2477
2453
|
|
|
2478
2454
|
// ── Dispatch table: resolve phase → unit type + prompt ──
|
|
2479
|
-
const dispatchResult = await resolveDispatch({
|
|
2480
|
-
basePath, mid, midTitle: midTitle!, state, prefs,
|
|
2455
|
+
const dispatchResult = await resolveDispatch({ basePath: s.basePath, mid, midTitle: midTitle!, state, prefs,
|
|
2481
2456
|
});
|
|
2482
2457
|
|
|
2483
2458
|
if (dispatchResult.action === "stop") {
|
|
2484
|
-
if (currentUnit) {
|
|
2485
|
-
|
|
2486
|
-
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
|
|
2487
|
-
saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
2459
|
+
if (s.currentUnit) {
|
|
2460
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
2488
2461
|
}
|
|
2489
2462
|
await stopAuto(ctx, pi, dispatchResult.reason);
|
|
2490
2463
|
return;
|
|
@@ -2503,7 +2476,7 @@ async function dispatchNextUnit(
|
|
|
2503
2476
|
let pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
|
|
2504
2477
|
|
|
2505
2478
|
// ── Pre-dispatch hooks: modify, skip, or replace the unit before dispatch ──
|
|
2506
|
-
const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, basePath);
|
|
2479
|
+
const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, s.basePath);
|
|
2507
2480
|
if (preDispatchResult.firedHooks.length > 0) {
|
|
2508
2481
|
ctx.ui.notify(
|
|
2509
2482
|
`Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`,
|
|
@@ -2524,66 +2497,67 @@ async function dispatchNextUnit(
|
|
|
2524
2497
|
prompt = preDispatchResult.prompt;
|
|
2525
2498
|
}
|
|
2526
2499
|
|
|
2527
|
-
const priorSliceBlocker = getPriorSliceCompletionBlocker(basePath, getMainBranch(basePath), unitType, unitId);
|
|
2500
|
+
const priorSliceBlocker = getPriorSliceCompletionBlocker(s.basePath, getMainBranch(s.basePath), unitType, unitId);
|
|
2528
2501
|
if (priorSliceBlocker) {
|
|
2529
2502
|
await stopAuto(ctx, pi, priorSliceBlocker);
|
|
2530
2503
|
return;
|
|
2531
2504
|
}
|
|
2532
2505
|
|
|
2533
|
-
const observabilityIssues = await
|
|
2506
|
+
const observabilityIssues = await _collectObservabilityWarnings(ctx, s.basePath, unitType, unitId);
|
|
2534
2507
|
|
|
2535
2508
|
// Idempotency: skip units already completed in a prior session.
|
|
2536
2509
|
const idempotencyKey = `${unitType}/${unitId}`;
|
|
2537
|
-
if (completedKeySet.has(idempotencyKey)) {
|
|
2510
|
+
if (s.completedKeySet.has(idempotencyKey)) {
|
|
2538
2511
|
// Cross-validate: does the expected artifact actually exist?
|
|
2539
|
-
const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath);
|
|
2512
|
+
const artifactExists = verifyExpectedArtifact(unitType, unitId, s.basePath);
|
|
2540
2513
|
if (artifactExists) {
|
|
2541
2514
|
// Guard against infinite skip loops: if deriveState keeps returning the
|
|
2542
2515
|
// same completed unit, consecutive skips will trip this breaker. Evict the
|
|
2543
2516
|
// key so the next dispatch forces full reconciliation instead of looping.
|
|
2544
|
-
const skipCount = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
|
|
2545
|
-
unitConsecutiveSkips.set(idempotencyKey, skipCount);
|
|
2517
|
+
const skipCount = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
|
|
2518
|
+
s.unitConsecutiveSkips.set(idempotencyKey, skipCount);
|
|
2546
2519
|
if (skipCount > MAX_CONSECUTIVE_SKIPS) {
|
|
2547
2520
|
// Cross-check: verify deriveState actually returns this unit (#790).
|
|
2548
2521
|
// If the unit's milestone is already complete, this is a phantom skip
|
|
2549
2522
|
// loop from stale crash recovery context — don't evict.
|
|
2550
2523
|
const skippedMid = unitId.split("/")[0];
|
|
2551
2524
|
const skippedMilestoneComplete = skippedMid
|
|
2552
|
-
? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY")
|
|
2525
|
+
? !!resolveMilestoneFile(s.basePath, skippedMid, "SUMMARY")
|
|
2553
2526
|
: false;
|
|
2554
2527
|
if (skippedMilestoneComplete) {
|
|
2555
2528
|
// Milestone is complete — evicting this key would fight self-heal.
|
|
2556
2529
|
// Clear skip counter and re-dispatch from fresh state.
|
|
2557
|
-
unitConsecutiveSkips.delete(idempotencyKey);
|
|
2530
|
+
s.unitConsecutiveSkips.delete(idempotencyKey);
|
|
2558
2531
|
invalidateAllCaches();
|
|
2559
2532
|
ctx.ui.notify(
|
|
2560
2533
|
`Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid}. Re-dispatching from fresh state.`,
|
|
2561
2534
|
"info",
|
|
2562
2535
|
);
|
|
2563
|
-
|
|
2536
|
+
s.skipDepth++;
|
|
2564
2537
|
await new Promise(r => setTimeout(r, 50));
|
|
2565
2538
|
await dispatchNextUnit(ctx, pi);
|
|
2566
|
-
|
|
2539
|
+
s.skipDepth = Math.max(0, s.skipDepth - 1);
|
|
2567
2540
|
return;
|
|
2568
2541
|
}
|
|
2569
|
-
unitConsecutiveSkips.delete(idempotencyKey);
|
|
2570
|
-
completedKeySet.delete(idempotencyKey);
|
|
2571
|
-
|
|
2542
|
+
s.unitConsecutiveSkips.delete(idempotencyKey);
|
|
2543
|
+
s.completedKeySet.delete(idempotencyKey);
|
|
2544
|
+
s.recentlyEvictedKeys.add(idempotencyKey);
|
|
2545
|
+
removePersistedKey(s.basePath, idempotencyKey);
|
|
2572
2546
|
invalidateAllCaches();
|
|
2573
2547
|
ctx.ui.notify(
|
|
2574
2548
|
`Skip loop detected: ${unitType} ${unitId} skipped ${skipCount} times without advancing. Evicting completion record and forcing reconciliation.`,
|
|
2575
2549
|
"warning",
|
|
2576
2550
|
);
|
|
2577
|
-
if (!active) return;
|
|
2578
|
-
|
|
2551
|
+
if (!s.active) return;
|
|
2552
|
+
s.skipDepth++;
|
|
2579
2553
|
await new Promise(r => setTimeout(r, 150));
|
|
2580
2554
|
await dispatchNextUnit(ctx, pi);
|
|
2581
|
-
|
|
2555
|
+
s.skipDepth = Math.max(0, s.skipDepth - 1);
|
|
2582
2556
|
return;
|
|
2583
2557
|
}
|
|
2584
2558
|
// Count toward lifetime cap so hard-stop fires during skip loops (#792)
|
|
2585
|
-
const lifeSkip = (unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
|
|
2586
|
-
unitLifetimeDispatches.set(idempotencyKey, lifeSkip);
|
|
2559
|
+
const lifeSkip = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
|
|
2560
|
+
s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip);
|
|
2587
2561
|
if (lifeSkip > MAX_LIFETIME_DISPATCHES) {
|
|
2588
2562
|
await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`);
|
|
2589
2563
|
ctx.ui.notify(
|
|
@@ -2596,16 +2570,16 @@ async function dispatchNextUnit(
|
|
|
2596
2570
|
`Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
|
|
2597
2571
|
"info",
|
|
2598
2572
|
);
|
|
2599
|
-
if (!active) return;
|
|
2600
|
-
|
|
2573
|
+
if (!s.active) return;
|
|
2574
|
+
s.skipDepth++;
|
|
2601
2575
|
await new Promise(r => setTimeout(r, 150));
|
|
2602
2576
|
await dispatchNextUnit(ctx, pi);
|
|
2603
|
-
|
|
2577
|
+
s.skipDepth = Math.max(0, s.skipDepth - 1);
|
|
2604
2578
|
return;
|
|
2605
2579
|
} else {
|
|
2606
2580
|
// Stale completion record — artifact missing. Remove and re-run.
|
|
2607
|
-
completedKeySet.delete(idempotencyKey);
|
|
2608
|
-
removePersistedKey(basePath, idempotencyKey);
|
|
2581
|
+
s.completedKeySet.delete(idempotencyKey);
|
|
2582
|
+
removePersistedKey(s.basePath, idempotencyKey);
|
|
2609
2583
|
ctx.ui.notify(
|
|
2610
2584
|
`Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`,
|
|
2611
2585
|
"warning",
|
|
@@ -2616,52 +2590,54 @@ async function dispatchNextUnit(
|
|
|
2616
2590
|
// Fallback: if the idempotency key is missing but the expected artifact already
|
|
2617
2591
|
// exists on disk, the task completed in a prior session without persisting the key.
|
|
2618
2592
|
// Persist it now and skip re-dispatch. This prevents infinite loops where a task
|
|
2619
|
-
// completes successfully but the completion key was never written
|
|
2620
|
-
//
|
|
2621
|
-
if
|
|
2622
|
-
|
|
2623
|
-
|
|
2593
|
+
// completes successfully but the completion key was never written.
|
|
2594
|
+
//
|
|
2595
|
+
// EXCEPTION: if the key was just evicted by the skip-loop breaker above, do NOT
|
|
2596
|
+
// re-persist — that would recreate the exact loop the breaker was trying to break (#912).
|
|
2597
|
+
if (verifyExpectedArtifact(unitType, unitId, s.basePath) && !s.recentlyEvictedKeys.has(idempotencyKey)) {
|
|
2598
|
+
persistCompletedKey(s.basePath, idempotencyKey);
|
|
2599
|
+
s.completedKeySet.add(idempotencyKey);
|
|
2624
2600
|
invalidateAllCaches();
|
|
2625
2601
|
// Same consecutive-skip guard as the idempotency path above.
|
|
2626
|
-
const skipCount2 = (unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
|
|
2627
|
-
unitConsecutiveSkips.set(idempotencyKey, skipCount2);
|
|
2602
|
+
const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
|
|
2603
|
+
s.unitConsecutiveSkips.set(idempotencyKey, skipCount2);
|
|
2628
2604
|
if (skipCount2 > MAX_CONSECUTIVE_SKIPS) {
|
|
2629
2605
|
// Cross-check: verify the unit's milestone is still active (#790).
|
|
2630
2606
|
const skippedMid2 = unitId.split("/")[0];
|
|
2631
2607
|
const skippedMilestoneComplete2 = skippedMid2
|
|
2632
|
-
? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY")
|
|
2608
|
+
? !!resolveMilestoneFile(s.basePath, skippedMid2, "SUMMARY")
|
|
2633
2609
|
: false;
|
|
2634
2610
|
if (skippedMilestoneComplete2) {
|
|
2635
|
-
unitConsecutiveSkips.delete(idempotencyKey);
|
|
2611
|
+
s.unitConsecutiveSkips.delete(idempotencyKey);
|
|
2636
2612
|
invalidateAllCaches();
|
|
2637
2613
|
ctx.ui.notify(
|
|
2638
2614
|
`Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid2}. Re-dispatching from fresh state.`,
|
|
2639
2615
|
"info",
|
|
2640
2616
|
);
|
|
2641
|
-
|
|
2617
|
+
s.skipDepth++;
|
|
2642
2618
|
await new Promise(r => setTimeout(r, 50));
|
|
2643
2619
|
await dispatchNextUnit(ctx, pi);
|
|
2644
|
-
|
|
2620
|
+
s.skipDepth = Math.max(0, s.skipDepth - 1);
|
|
2645
2621
|
return;
|
|
2646
2622
|
}
|
|
2647
|
-
unitConsecutiveSkips.delete(idempotencyKey);
|
|
2648
|
-
completedKeySet.delete(idempotencyKey);
|
|
2649
|
-
removePersistedKey(basePath, idempotencyKey);
|
|
2623
|
+
s.unitConsecutiveSkips.delete(idempotencyKey);
|
|
2624
|
+
s.completedKeySet.delete(idempotencyKey);
|
|
2625
|
+
removePersistedKey(s.basePath, idempotencyKey);
|
|
2650
2626
|
invalidateAllCaches();
|
|
2651
2627
|
ctx.ui.notify(
|
|
2652
2628
|
`Skip loop detected: ${unitType} ${unitId} skipped ${skipCount2} times without advancing. Evicting completion record and forcing reconciliation.`,
|
|
2653
2629
|
"warning",
|
|
2654
2630
|
);
|
|
2655
|
-
if (!active) return;
|
|
2656
|
-
|
|
2631
|
+
if (!s.active) return;
|
|
2632
|
+
s.skipDepth++;
|
|
2657
2633
|
await new Promise(r => setTimeout(r, 150));
|
|
2658
2634
|
await dispatchNextUnit(ctx, pi);
|
|
2659
|
-
|
|
2635
|
+
s.skipDepth = Math.max(0, s.skipDepth - 1);
|
|
2660
2636
|
return;
|
|
2661
2637
|
}
|
|
2662
2638
|
// Count toward lifetime cap so hard-stop fires during skip loops (#792)
|
|
2663
|
-
const lifeSkip2 = (unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
|
|
2664
|
-
unitLifetimeDispatches.set(idempotencyKey, lifeSkip2);
|
|
2639
|
+
const lifeSkip2 = (s.unitLifetimeDispatches.get(idempotencyKey) ?? 0) + 1;
|
|
2640
|
+
s.unitLifetimeDispatches.set(idempotencyKey, lifeSkip2);
|
|
2665
2641
|
if (lifeSkip2 > MAX_LIFETIME_DISPATCHES) {
|
|
2666
2642
|
await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId} (skip cycle)`);
|
|
2667
2643
|
ctx.ui.notify(
|
|
@@ -2674,41 +2650,41 @@ async function dispatchNextUnit(
|
|
|
2674
2650
|
`Skipping ${unitType} ${unitId} — artifact exists but completion key was missing. Repaired and advancing.`,
|
|
2675
2651
|
"info",
|
|
2676
2652
|
);
|
|
2677
|
-
if (!active) return;
|
|
2678
|
-
|
|
2653
|
+
if (!s.active) return;
|
|
2654
|
+
s.skipDepth++;
|
|
2679
2655
|
await new Promise(r => setTimeout(r, 150));
|
|
2680
2656
|
await dispatchNextUnit(ctx, pi);
|
|
2681
|
-
|
|
2657
|
+
s.skipDepth = Math.max(0, s.skipDepth - 1);
|
|
2682
2658
|
return;
|
|
2683
2659
|
}
|
|
2684
2660
|
|
|
2685
2661
|
// Stuck detection — tracks total dispatches per unit (not just consecutive repeats).
|
|
2686
2662
|
// Pattern A→B→A→B would reset retryCount every time; this map catches it.
|
|
2687
2663
|
const dispatchKey = `${unitType}/${unitId}`;
|
|
2688
|
-
const prevCount = unitDispatchCount.get(dispatchKey) ?? 0;
|
|
2664
|
+
const prevCount = s.unitDispatchCount.get(dispatchKey) ?? 0;
|
|
2689
2665
|
// Real dispatch reached — clear the consecutive-skip counter for this unit.
|
|
2690
|
-
unitConsecutiveSkips.delete(dispatchKey);
|
|
2666
|
+
s.unitConsecutiveSkips.delete(dispatchKey);
|
|
2691
2667
|
|
|
2692
2668
|
debugLog("dispatch-unit", {
|
|
2693
2669
|
type: unitType,
|
|
2694
2670
|
id: unitId,
|
|
2695
2671
|
cycle: prevCount + 1,
|
|
2696
|
-
lifetime: (unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1,
|
|
2672
|
+
lifetime: (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1,
|
|
2697
2673
|
});
|
|
2698
2674
|
debugCount("dispatches");
|
|
2699
2675
|
|
|
2700
2676
|
// Hard lifetime cap — survives counter resets from loop-recovery/self-repair.
|
|
2701
2677
|
// Catches the case where reconciliation "succeeds" (artifacts exist) but
|
|
2702
2678
|
// deriveState keeps returning the same unit, creating an infinite cycle.
|
|
2703
|
-
const lifetimeCount = (unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1;
|
|
2704
|
-
unitLifetimeDispatches.set(dispatchKey, lifetimeCount);
|
|
2679
|
+
const lifetimeCount = (s.unitLifetimeDispatches.get(dispatchKey) ?? 0) + 1;
|
|
2680
|
+
s.unitLifetimeDispatches.set(dispatchKey, lifetimeCount);
|
|
2705
2681
|
if (lifetimeCount > MAX_LIFETIME_DISPATCHES) {
|
|
2706
|
-
if (currentUnit) {
|
|
2707
|
-
|
|
2708
|
-
|
|
2682
|
+
if (s.currentUnit) {
|
|
2683
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
2684
|
+
} else {
|
|
2685
|
+
saveActivityLog(ctx, s.basePath, unitType, unitId);
|
|
2709
2686
|
}
|
|
2710
|
-
|
|
2711
|
-
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
|
|
2687
|
+
const expected = diagnoseExpectedArtifact(unitType, unitId, s.basePath);
|
|
2712
2688
|
await stopAuto(ctx, pi, `Hard loop: ${unitType} ${unitId}`);
|
|
2713
2689
|
ctx.ui.notify(
|
|
2714
2690
|
`Hard loop detected: ${unitType} ${unitId} dispatched ${lifetimeCount} times total (across reconciliation cycles).${expected ? `\n Expected artifact: ${expected}` : ""}\n This may indicate deriveState() keeps returning the same unit despite artifacts existing.\n Check .gsd/completed-units.json and the slice plan checkbox state.`,
|
|
@@ -2717,11 +2693,11 @@ async function dispatchNextUnit(
|
|
|
2717
2693
|
return;
|
|
2718
2694
|
}
|
|
2719
2695
|
if (prevCount >= MAX_UNIT_DISPATCHES) {
|
|
2720
|
-
if (currentUnit) {
|
|
2721
|
-
|
|
2722
|
-
|
|
2696
|
+
if (s.currentUnit) {
|
|
2697
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
2698
|
+
} else {
|
|
2699
|
+
saveActivityLog(ctx, s.basePath, unitType, unitId);
|
|
2723
2700
|
}
|
|
2724
|
-
saveActivityLog(ctx, basePath, unitType, unitId);
|
|
2725
2701
|
|
|
2726
2702
|
// Final reconciliation pass for execute-task: write any missing durable
|
|
2727
2703
|
// artifacts (summary placeholder + [x] checkbox) so the pipeline can
|
|
@@ -2729,13 +2705,13 @@ async function dispatchNextUnit(
|
|
|
2729
2705
|
if (unitType === "execute-task") {
|
|
2730
2706
|
const [mid, sid, tid] = unitId.split("/");
|
|
2731
2707
|
if (mid && sid && tid) {
|
|
2732
|
-
const status = await inspectExecuteTaskDurability(basePath, unitId);
|
|
2708
|
+
const status = await inspectExecuteTaskDurability(s.basePath, unitId);
|
|
2733
2709
|
if (status) {
|
|
2734
|
-
const reconciled = skipExecuteTask(basePath, mid, sid, tid, status, "loop-recovery", prevCount);
|
|
2710
|
+
const reconciled = skipExecuteTask(s.basePath, mid, sid, tid, status, "loop-recovery", prevCount);
|
|
2735
2711
|
// reconciled: skipExecuteTask attempted to write missing artifacts.
|
|
2736
2712
|
// verifyExpectedArtifact: confirms physical artifacts (summary + [x]) now exist on disk.
|
|
2737
2713
|
// Both must pass before we clear the dispatch counter and advance.
|
|
2738
|
-
if (reconciled && verifyExpectedArtifact(unitType, unitId, basePath)) {
|
|
2714
|
+
if (reconciled && verifyExpectedArtifact(unitType, unitId, s.basePath)) {
|
|
2739
2715
|
ctx.ui.notify(
|
|
2740
2716
|
`Loop recovery: ${unitId} reconciled after ${prevCount + 1} dispatches — blocker artifacts written, pipeline advancing.\n Review ${status.summaryPath} and replace the placeholder with real work.`,
|
|
2741
2717
|
"warning",
|
|
@@ -2743,9 +2719,9 @@ async function dispatchNextUnit(
|
|
|
2743
2719
|
// Persist completion so idempotency check prevents re-dispatch
|
|
2744
2720
|
// if deriveState keeps returning this unit (#462).
|
|
2745
2721
|
const reconciledKey = `${unitType}/${unitId}`;
|
|
2746
|
-
persistCompletedKey(basePath, reconciledKey);
|
|
2747
|
-
completedKeySet.add(reconciledKey);
|
|
2748
|
-
unitDispatchCount.delete(dispatchKey);
|
|
2722
|
+
persistCompletedKey(s.basePath, reconciledKey);
|
|
2723
|
+
s.completedKeySet.add(reconciledKey);
|
|
2724
|
+
s.unitDispatchCount.delete(dispatchKey);
|
|
2749
2725
|
invalidateAllCaches();
|
|
2750
2726
|
await new Promise(r => setImmediate(r));
|
|
2751
2727
|
await dispatchNextUnit(ctx, pi);
|
|
@@ -2763,16 +2739,16 @@ async function dispatchNextUnit(
|
|
|
2763
2739
|
// dispatch limit succeeded but the counter check fires before anyone
|
|
2764
2740
|
// verifies disk state. Without this, a successful final attempt is
|
|
2765
2741
|
// indistinguishable from a failed one.
|
|
2766
|
-
if (verifyExpectedArtifact(unitType, unitId, basePath)) {
|
|
2742
|
+
if (verifyExpectedArtifact(unitType, unitId, s.basePath)) {
|
|
2767
2743
|
ctx.ui.notify(
|
|
2768
2744
|
`Loop recovery: ${unitType} ${unitId} — artifact verified after ${prevCount + 1} dispatches. Advancing.`,
|
|
2769
2745
|
"info",
|
|
2770
2746
|
);
|
|
2771
2747
|
// Persist completion so the idempotency check prevents re-dispatch
|
|
2772
2748
|
// if deriveState keeps returning this unit (see #462).
|
|
2773
|
-
persistCompletedKey(basePath, dispatchKey);
|
|
2774
|
-
completedKeySet.add(dispatchKey);
|
|
2775
|
-
unitDispatchCount.delete(dispatchKey);
|
|
2749
|
+
persistCompletedKey(s.basePath, dispatchKey);
|
|
2750
|
+
s.completedKeySet.add(dispatchKey);
|
|
2751
|
+
s.unitDispatchCount.delete(dispatchKey);
|
|
2776
2752
|
invalidateAllCaches();
|
|
2777
2753
|
await new Promise(r => setImmediate(r));
|
|
2778
2754
|
await dispatchNextUnit(ctx, pi);
|
|
@@ -2784,15 +2760,15 @@ async function dispatchNextUnit(
|
|
|
2784
2760
|
// but the LLM failed to write the summary N times. A stub lets the pipeline advance.
|
|
2785
2761
|
if (unitType === "complete-milestone") {
|
|
2786
2762
|
try {
|
|
2787
|
-
const mPath = resolveMilestonePath(basePath, unitId);
|
|
2763
|
+
const mPath = resolveMilestonePath(s.basePath, unitId);
|
|
2788
2764
|
if (mPath) {
|
|
2789
2765
|
const stubPath = join(mPath, `${unitId}-SUMMARY.md`);
|
|
2790
2766
|
if (!existsSync(stubPath)) {
|
|
2791
2767
|
writeFileSync(stubPath, `# ${unitId} Summary\n\nAuto-generated stub — milestone tasks completed but summary generation failed after ${prevCount + 1} attempts.\nReview and replace this stub with a proper summary.\n`);
|
|
2792
2768
|
ctx.ui.notify(`Generated stub summary for ${unitId} to unblock pipeline. Review later.`, "warning");
|
|
2793
|
-
persistCompletedKey(basePath, dispatchKey);
|
|
2794
|
-
completedKeySet.add(dispatchKey);
|
|
2795
|
-
unitDispatchCount.delete(dispatchKey);
|
|
2769
|
+
persistCompletedKey(s.basePath, dispatchKey);
|
|
2770
|
+
s.completedKeySet.add(dispatchKey);
|
|
2771
|
+
s.unitDispatchCount.delete(dispatchKey);
|
|
2796
2772
|
invalidateAllCaches();
|
|
2797
2773
|
await new Promise(r => setImmediate(r));
|
|
2798
2774
|
await dispatchNextUnit(ctx, pi);
|
|
@@ -2802,8 +2778,8 @@ async function dispatchNextUnit(
|
|
|
2802
2778
|
} catch { /* non-fatal — fall through to normal stop */ }
|
|
2803
2779
|
}
|
|
2804
2780
|
|
|
2805
|
-
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
|
|
2806
|
-
const remediation = buildLoopRemediationSteps(unitType, unitId, basePath);
|
|
2781
|
+
const expected = diagnoseExpectedArtifact(unitType, unitId, s.basePath);
|
|
2782
|
+
const remediation = buildLoopRemediationSteps(unitType, unitId, s.basePath);
|
|
2807
2783
|
await stopAuto(ctx, pi, `Loop: ${unitType} ${unitId}`);
|
|
2808
2784
|
sendDesktopNotification("GSD", `Loop detected: ${unitType} ${unitId}`, "error", "error");
|
|
2809
2785
|
ctx.ui.notify(
|
|
@@ -2812,28 +2788,28 @@ async function dispatchNextUnit(
|
|
|
2812
2788
|
);
|
|
2813
2789
|
return;
|
|
2814
2790
|
}
|
|
2815
|
-
unitDispatchCount.set(dispatchKey, prevCount + 1);
|
|
2791
|
+
s.unitDispatchCount.set(dispatchKey, prevCount + 1);
|
|
2816
2792
|
if (prevCount > 0) {
|
|
2817
2793
|
// Adaptive self-repair: each retry attempts a different remediation step.
|
|
2818
2794
|
if (unitType === "execute-task") {
|
|
2819
|
-
const status = await inspectExecuteTaskDurability(basePath, unitId);
|
|
2795
|
+
const status = await inspectExecuteTaskDurability(s.basePath, unitId);
|
|
2820
2796
|
const [mid, sid, tid] = unitId.split("/");
|
|
2821
2797
|
if (status && mid && sid && tid) {
|
|
2822
2798
|
if (status.summaryExists && !status.taskChecked) {
|
|
2823
2799
|
// Retry 1+: summary exists but checkbox not marked — mark [x] and advance.
|
|
2824
|
-
const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0);
|
|
2800
|
+
const repaired = skipExecuteTask(s.basePath, mid, sid, tid, status, "self-repair", 0);
|
|
2825
2801
|
// repaired: skipExecuteTask updated metadata (returned early-true even if regex missed).
|
|
2826
2802
|
// verifyExpectedArtifact: confirms the physical artifact (summary + [x]) now exists.
|
|
2827
|
-
if (repaired && verifyExpectedArtifact(unitType, unitId, basePath)) {
|
|
2803
|
+
if (repaired && verifyExpectedArtifact(unitType, unitId, s.basePath)) {
|
|
2828
2804
|
ctx.ui.notify(
|
|
2829
2805
|
`Self-repaired ${unitId}: summary existed but checkbox was unmarked. Marked [x] and advancing.`,
|
|
2830
2806
|
"warning",
|
|
2831
2807
|
);
|
|
2832
2808
|
// Persist completion so idempotency check prevents re-dispatch (#462).
|
|
2833
2809
|
const repairedKey = `${unitType}/${unitId}`;
|
|
2834
|
-
persistCompletedKey(basePath, repairedKey);
|
|
2835
|
-
completedKeySet.add(repairedKey);
|
|
2836
|
-
unitDispatchCount.delete(dispatchKey);
|
|
2810
|
+
persistCompletedKey(s.basePath, repairedKey);
|
|
2811
|
+
s.completedKeySet.add(repairedKey);
|
|
2812
|
+
s.unitDispatchCount.delete(dispatchKey);
|
|
2837
2813
|
invalidateAllCaches();
|
|
2838
2814
|
await new Promise(r => setImmediate(r));
|
|
2839
2815
|
await dispatchNextUnit(ctx, pi);
|
|
@@ -2843,8 +2819,8 @@ async function dispatchNextUnit(
|
|
|
2843
2819
|
// Retry STUB_RECOVERY_THRESHOLD+: summary still missing after multiple attempts.
|
|
2844
2820
|
// Write a minimal stub summary so the next agent session has a recovery artifact
|
|
2845
2821
|
// to overwrite, rather than starting from scratch again.
|
|
2846
|
-
const tasksDir = resolveTasksDir(basePath, mid, sid);
|
|
2847
|
-
const sDir = resolveSlicePath(basePath, mid, sid);
|
|
2822
|
+
const tasksDir = resolveTasksDir(s.basePath, mid, sid);
|
|
2823
|
+
const sDir = resolveSlicePath(s.basePath, mid, sid);
|
|
2848
2824
|
const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null);
|
|
2849
2825
|
if (targetDir) {
|
|
2850
2826
|
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
|
|
@@ -2875,28 +2851,15 @@ async function dispatchNextUnit(
|
|
|
2875
2851
|
}
|
|
2876
2852
|
// Snapshot metrics + activity log for the PREVIOUS unit before we reassign.
|
|
2877
2853
|
// The session still holds the previous unit's data (newSession hasn't fired yet).
|
|
2878
|
-
if (currentUnit) {
|
|
2879
|
-
|
|
2880
|
-
snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, { promptCharCount: lastPromptCharCount, baselineCharCount: lastBaselineCharCount, ...(currentUnitRouting ?? {}) });
|
|
2881
|
-
const activityFile = saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
|
|
2882
|
-
|
|
2883
|
-
// Fire-and-forget memory extraction from completed unit
|
|
2884
|
-
if (activityFile) {
|
|
2885
|
-
try {
|
|
2886
|
-
const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
|
|
2887
|
-
const llmCallFn = buildMemoryLLMCall(ctx);
|
|
2888
|
-
if (llmCallFn) {
|
|
2889
|
-
extractMemoriesFromUnit(activityFile, currentUnit.type, currentUnit.id, llmCallFn).catch(() => {});
|
|
2890
|
-
}
|
|
2891
|
-
} catch { /* non-fatal */ }
|
|
2892
|
-
}
|
|
2854
|
+
if (s.currentUnit) {
|
|
2855
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
2893
2856
|
|
|
2894
2857
|
// Record routing outcome for adaptive learning
|
|
2895
|
-
if (currentUnitRouting) {
|
|
2896
|
-
const isRetry = currentUnit.type === unitType && currentUnit.id === unitId;
|
|
2858
|
+
if (s.currentUnitRouting) {
|
|
2859
|
+
const isRetry = s.currentUnit.type === unitType && s.currentUnit.id === unitId;
|
|
2897
2860
|
recordOutcome(
|
|
2898
|
-
currentUnit.type,
|
|
2899
|
-
currentUnitRouting.tier as "light" | "standard" | "heavy",
|
|
2861
|
+
s.currentUnit.type,
|
|
2862
|
+
s.currentUnitRouting.tier as "light" | "standard" | "heavy",
|
|
2900
2863
|
!isRetry, // success = not being retried
|
|
2901
2864
|
);
|
|
2902
2865
|
}
|
|
@@ -2906,55 +2869,55 @@ async function dispatchNextUnit(
|
|
|
2906
2869
|
// 2. The expected artifact actually exists on disk
|
|
2907
2870
|
// For hook units, skip artifact verification — hooks don't produce standard
|
|
2908
2871
|
// artifacts and their runtime records were already finalized in handleAgentEnd.
|
|
2909
|
-
const closeoutKey = `${currentUnit.type}/${currentUnit.id}`;
|
|
2872
|
+
const closeoutKey = `${s.currentUnit.type}/${s.currentUnit.id}`;
|
|
2910
2873
|
const incomingKey = `${unitType}/${unitId}`;
|
|
2911
|
-
const isHookUnit = currentUnit.type.startsWith("hook/");
|
|
2912
|
-
const artifactVerified = isHookUnit || verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
|
|
2874
|
+
const isHookUnit = s.currentUnit.type.startsWith("hook/");
|
|
2875
|
+
const artifactVerified = isHookUnit || verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath);
|
|
2913
2876
|
if (closeoutKey !== incomingKey && artifactVerified) {
|
|
2914
2877
|
if (!isHookUnit) {
|
|
2915
2878
|
// Only persist completion keys for real units — hook keys are
|
|
2916
2879
|
// ephemeral and should not pollute the idempotency set.
|
|
2917
|
-
persistCompletedKey(basePath, closeoutKey);
|
|
2918
|
-
completedKeySet.add(closeoutKey);
|
|
2880
|
+
persistCompletedKey(s.basePath, closeoutKey);
|
|
2881
|
+
s.completedKeySet.add(closeoutKey);
|
|
2919
2882
|
}
|
|
2920
2883
|
|
|
2921
|
-
completedUnits.push({
|
|
2922
|
-
type: currentUnit.type,
|
|
2923
|
-
id: currentUnit.id,
|
|
2924
|
-
startedAt: currentUnit.startedAt,
|
|
2884
|
+
s.completedUnits.push({
|
|
2885
|
+
type: s.currentUnit.type,
|
|
2886
|
+
id: s.currentUnit.id,
|
|
2887
|
+
startedAt: s.currentUnit.startedAt,
|
|
2925
2888
|
finishedAt: Date.now(),
|
|
2926
2889
|
});
|
|
2927
2890
|
// Cap to last 200 entries to prevent unbounded growth (#611)
|
|
2928
|
-
if (completedUnits.length > 200) {
|
|
2929
|
-
completedUnits = completedUnits.slice(-200);
|
|
2891
|
+
if (s.completedUnits.length > 200) {
|
|
2892
|
+
s.completedUnits = s.completedUnits.slice(-200);
|
|
2930
2893
|
}
|
|
2931
|
-
clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
|
|
2932
|
-
unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`);
|
|
2933
|
-
unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`);
|
|
2894
|
+
clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id);
|
|
2895
|
+
s.unitDispatchCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
|
|
2896
|
+
s.unitRecoveryCount.delete(`${s.currentUnit.type}/${s.currentUnit.id}`);
|
|
2934
2897
|
}
|
|
2935
2898
|
}
|
|
2936
|
-
currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
|
|
2899
|
+
s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
|
|
2937
2900
|
captureAvailableSkills(); // Capture skill telemetry at dispatch time (#599)
|
|
2938
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
2901
|
+
writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
|
|
2939
2902
|
phase: "dispatched",
|
|
2940
2903
|
wrapupWarningSent: false,
|
|
2941
2904
|
timeoutAt: null,
|
|
2942
|
-
lastProgressAt: currentUnit.startedAt,
|
|
2905
|
+
lastProgressAt: s.currentUnit.startedAt,
|
|
2943
2906
|
progressCount: 0,
|
|
2944
2907
|
lastProgressKind: "dispatch",
|
|
2945
2908
|
});
|
|
2946
2909
|
|
|
2947
2910
|
// Status bar + progress widget
|
|
2948
2911
|
ctx.ui.setStatus("gsd-auto", "auto");
|
|
2949
|
-
if (mid) updateSliceProgressCache(basePath, mid, state.activeSlice?.id);
|
|
2912
|
+
if (mid) updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id);
|
|
2950
2913
|
updateProgressWidget(ctx, unitType, unitId, state);
|
|
2951
2914
|
|
|
2952
2915
|
// Ensure preconditions — create directories, branches, etc.
|
|
2953
2916
|
// so the LLM doesn't have to get these right
|
|
2954
|
-
ensurePreconditions(unitType, unitId, basePath, state);
|
|
2917
|
+
ensurePreconditions(unitType, unitId, s.basePath, state);
|
|
2955
2918
|
|
|
2956
2919
|
// Fresh session
|
|
2957
|
-
const result = await cmdCtx!.newSession();
|
|
2920
|
+
const result = await s.cmdCtx!.newSession();
|
|
2958
2921
|
if (result.cancelled) {
|
|
2959
2922
|
await stopAuto(ctx, pi, "Session cancelled");
|
|
2960
2923
|
return;
|
|
@@ -2968,21 +2931,32 @@ async function dispatchNextUnit(
|
|
|
2968
2931
|
// Pi appends entries incrementally via appendFileSync, so on crash the
|
|
2969
2932
|
// session file survives with every tool call up to the crash point.
|
|
2970
2933
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
2971
|
-
writeLock(lockBase(), unitType, unitId, completedUnits.length, sessionFile);
|
|
2934
|
+
writeLock(lockBase(), unitType, unitId, s.completedUnits.length, sessionFile);
|
|
2972
2935
|
|
|
2973
2936
|
// On crash recovery, prepend the full recovery briefing
|
|
2974
2937
|
// On retry (stuck detection), prepend deep diagnostic from last attempt
|
|
2975
2938
|
// Cap injected content to prevent unbounded prompt growth → OOM
|
|
2976
2939
|
const MAX_RECOVERY_CHARS = 50_000;
|
|
2977
2940
|
let finalPrompt = prompt;
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2941
|
+
|
|
2942
|
+
// Verification retry — inject failure context so the agent can auto-fix
|
|
2943
|
+
if (s.pendingVerificationRetry) {
|
|
2944
|
+
const retryCtx = s.pendingVerificationRetry;
|
|
2945
|
+
s.pendingVerificationRetry = null;
|
|
2946
|
+
const capped = retryCtx.failureContext.length > MAX_RECOVERY_CHARS
|
|
2947
|
+
? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...failure context truncated]"
|
|
2948
|
+
: retryCtx.failureContext;
|
|
2949
|
+
finalPrompt = `**VERIFICATION FAILED — AUTO-FIX ATTEMPT ${retryCtx.attempt}**\n\nThe verification gate ran after your previous attempt and found failures. Fix these issues before completing the task.\n\n${capped}\n\n---\n\n${finalPrompt}`;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
if (s.pendingCrashRecovery) {
|
|
2953
|
+
const capped = s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS
|
|
2954
|
+
? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...recovery briefing truncated to prevent memory exhaustion]"
|
|
2955
|
+
: s.pendingCrashRecovery;
|
|
2982
2956
|
finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`;
|
|
2983
|
-
pendingCrashRecovery = null;
|
|
2984
|
-
} else if ((unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
|
|
2985
|
-
const diagnostic = getDeepDiagnostic(basePath);
|
|
2957
|
+
s.pendingCrashRecovery = null;
|
|
2958
|
+
} else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
|
|
2959
|
+
const diagnostic = getDeepDiagnostic(s.basePath);
|
|
2986
2960
|
if (diagnostic) {
|
|
2987
2961
|
const cappedDiag = diagnostic.length > MAX_RECOVERY_CHARS
|
|
2988
2962
|
? diagnostic.slice(0, MAX_RECOVERY_CHARS) + "\n\n[...diagnostic truncated to prevent memory exhaustion]"
|
|
@@ -2999,17 +2973,17 @@ async function dispatchNextUnit(
|
|
|
2999
2973
|
}
|
|
3000
2974
|
|
|
3001
2975
|
// ── Prompt char measurement (R051) ──
|
|
3002
|
-
lastPromptCharCount = finalPrompt.length;
|
|
3003
|
-
lastBaselineCharCount = undefined;
|
|
2976
|
+
s.lastPromptCharCount = finalPrompt.length;
|
|
2977
|
+
s.lastBaselineCharCount = undefined;
|
|
3004
2978
|
if (isDbAvailable()) {
|
|
3005
2979
|
try {
|
|
3006
2980
|
const { inlineGsdRootFile } = await import("./auto-prompts.js");
|
|
3007
2981
|
const [decisionsContent, requirementsContent, projectContent] = await Promise.all([
|
|
3008
|
-
inlineGsdRootFile(basePath, "decisions.md", "Decisions"),
|
|
3009
|
-
inlineGsdRootFile(basePath, "requirements.md", "Requirements"),
|
|
3010
|
-
inlineGsdRootFile(basePath, "project.md", "Project"),
|
|
2982
|
+
inlineGsdRootFile(s.basePath, "decisions.md", "Decisions"),
|
|
2983
|
+
inlineGsdRootFile(s.basePath, "requirements.md", "Requirements"),
|
|
2984
|
+
inlineGsdRootFile(s.basePath, "project.md", "Project"),
|
|
3011
2985
|
]);
|
|
3012
|
-
lastBaselineCharCount =
|
|
2986
|
+
s.lastBaselineCharCount =
|
|
3013
2987
|
(decisionsContent?.length ?? 0) +
|
|
3014
2988
|
(requirementsContent?.length ?? 0) +
|
|
3015
2989
|
(projectContent?.length ?? 0);
|
|
@@ -3018,158 +2992,9 @@ async function dispatchNextUnit(
|
|
|
3018
2992
|
}
|
|
3019
2993
|
}
|
|
3020
2994
|
|
|
3021
|
-
//
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
if (modelConfig) {
|
|
3025
|
-
const availableModels = ctx.modelRegistry.getAvailable();
|
|
3026
|
-
|
|
3027
|
-
// ─── Dynamic Model Routing ─────────────────────────────────────────
|
|
3028
|
-
// If enabled, classify unit complexity and potentially downgrade to a
|
|
3029
|
-
// cheaper model. The user's configured model is the ceiling.
|
|
3030
|
-
const routingConfig = resolveDynamicRoutingConfig();
|
|
3031
|
-
let effectiveModelConfig = modelConfig;
|
|
3032
|
-
let routingTierLabel = "";
|
|
3033
|
-
currentUnitRouting = null;
|
|
3034
|
-
|
|
3035
|
-
if (routingConfig.enabled) {
|
|
3036
|
-
// Compute budget pressure if budget ceiling is set
|
|
3037
|
-
let budgetPct: number | undefined;
|
|
3038
|
-
if (routingConfig.budget_pressure !== false) {
|
|
3039
|
-
const budgetCeiling = prefs?.budget_ceiling;
|
|
3040
|
-
if (budgetCeiling !== undefined && budgetCeiling > 0) {
|
|
3041
|
-
const currentLedger = getLedger();
|
|
3042
|
-
const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0;
|
|
3043
|
-
budgetPct = totalCost / budgetCeiling;
|
|
3044
|
-
}
|
|
3045
|
-
}
|
|
3046
|
-
|
|
3047
|
-
// Classify complexity (hook routing controlled by config.hooks)
|
|
3048
|
-
const isHook = unitType.startsWith("hook/");
|
|
3049
|
-
const shouldClassify = !isHook || routingConfig.hooks !== false;
|
|
3050
|
-
|
|
3051
|
-
if (shouldClassify) {
|
|
3052
|
-
const classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct);
|
|
3053
|
-
const availableModelIds = availableModels.map(m => m.id);
|
|
3054
|
-
const routing = resolveModelForComplexity(classification, modelConfig, routingConfig, availableModelIds);
|
|
3055
|
-
|
|
3056
|
-
if (routing.wasDowngraded) {
|
|
3057
|
-
effectiveModelConfig = {
|
|
3058
|
-
primary: routing.modelId,
|
|
3059
|
-
fallbacks: routing.fallbacks,
|
|
3060
|
-
};
|
|
3061
|
-
if (verbose) {
|
|
3062
|
-
ctx.ui.notify(
|
|
3063
|
-
`Dynamic routing [${tierLabel(classification.tier)}]: ${routing.modelId} (${classification.reason})`,
|
|
3064
|
-
"info",
|
|
3065
|
-
);
|
|
3066
|
-
}
|
|
3067
|
-
}
|
|
3068
|
-
routingTierLabel = ` [${tierLabel(classification.tier)}]`;
|
|
3069
|
-
currentUnitRouting = { tier: classification.tier, modelDowngraded: routing.wasDowngraded };
|
|
3070
|
-
}
|
|
3071
|
-
}
|
|
3072
|
-
|
|
3073
|
-
const modelsToTry = [effectiveModelConfig.primary, ...effectiveModelConfig.fallbacks];
|
|
3074
|
-
let modelSet = false;
|
|
3075
|
-
|
|
3076
|
-
for (const modelId of modelsToTry) {
|
|
3077
|
-
// Resolve model from available models.
|
|
3078
|
-
// Handles multiple formats:
|
|
3079
|
-
// "provider/model" → explicit provider targeting (e.g. "anthropic/claude-opus-4-6")
|
|
3080
|
-
// "bare-id" → match by ID across providers
|
|
3081
|
-
// "org/model-name" → OpenRouter-style IDs where the full string is the model ID
|
|
3082
|
-
// "openrouter/org/model" → explicit provider + OpenRouter model ID
|
|
3083
|
-
const slashIdx = modelId.indexOf("/");
|
|
3084
|
-
let model;
|
|
3085
|
-
if (slashIdx !== -1) {
|
|
3086
|
-
const maybeProvider = modelId.substring(0, slashIdx);
|
|
3087
|
-
const id = modelId.substring(slashIdx + 1);
|
|
3088
|
-
|
|
3089
|
-
// Check if the prefix before the first slash is a known provider
|
|
3090
|
-
const knownProviders = new Set(availableModels.map(m => m.provider.toLowerCase()));
|
|
3091
|
-
if (knownProviders.has(maybeProvider.toLowerCase())) {
|
|
3092
|
-
// Explicit "provider/model" format (handles "openrouter/org/model" too)
|
|
3093
|
-
model = availableModels.find(
|
|
3094
|
-
m => m.provider.toLowerCase() === maybeProvider.toLowerCase()
|
|
3095
|
-
&& m.id.toLowerCase() === id.toLowerCase(),
|
|
3096
|
-
);
|
|
3097
|
-
}
|
|
3098
|
-
|
|
3099
|
-
// If the prefix wasn't a known provider, or no match was found within that provider,
|
|
3100
|
-
// try matching the full string as a model ID (OpenRouter-style IDs like "org/model-name")
|
|
3101
|
-
if (!model) {
|
|
3102
|
-
const lower = modelId.toLowerCase();
|
|
3103
|
-
model = availableModels.find(
|
|
3104
|
-
m => m.id.toLowerCase() === lower
|
|
3105
|
-
|| `${m.provider}/${m.id}`.toLowerCase() === lower,
|
|
3106
|
-
);
|
|
3107
|
-
}
|
|
3108
|
-
} else {
|
|
3109
|
-
// For bare IDs, prefer the current session's provider, then first available match
|
|
3110
|
-
const currentProvider = ctx.model?.provider;
|
|
3111
|
-
const exactProviderMatch = availableModels.find(
|
|
3112
|
-
m => m.id === modelId && m.provider === currentProvider,
|
|
3113
|
-
);
|
|
3114
|
-
const anyMatch = availableModels.find(m => m.id === modelId);
|
|
3115
|
-
model = exactProviderMatch ?? anyMatch;
|
|
3116
|
-
|
|
3117
|
-
// Warn if the ID is ambiguous across providers
|
|
3118
|
-
if (anyMatch && !exactProviderMatch) {
|
|
3119
|
-
const providers = availableModels
|
|
3120
|
-
.filter(m => m.id === modelId)
|
|
3121
|
-
.map(m => m.provider);
|
|
3122
|
-
if (providers.length > 1) {
|
|
3123
|
-
ctx.ui.notify(
|
|
3124
|
-
`Model ID "${modelId}" exists in multiple providers (${providers.join(", ")}). ` +
|
|
3125
|
-
`Resolved to ${anyMatch.provider}. Use "provider/model" format for explicit targeting.`,
|
|
3126
|
-
"warning",
|
|
3127
|
-
);
|
|
3128
|
-
}
|
|
3129
|
-
}
|
|
3130
|
-
}
|
|
3131
|
-
if (!model) {
|
|
3132
|
-
if (verbose) ctx.ui.notify(`Model ${modelId} not found, trying fallback.`, "info");
|
|
3133
|
-
continue;
|
|
3134
|
-
}
|
|
3135
|
-
|
|
3136
|
-
const ok = await pi.setModel(model, { persist: false });
|
|
3137
|
-
if (ok) {
|
|
3138
|
-
const fallbackNote = modelId === effectiveModelConfig.primary
|
|
3139
|
-
? ""
|
|
3140
|
-
: ` (fallback from ${effectiveModelConfig.primary})`;
|
|
3141
|
-
const phase = unitPhaseLabel(unitType);
|
|
3142
|
-
ctx.ui.notify(`Model [${phase}]${routingTierLabel}: ${model.provider}/${model.id}${fallbackNote}`, "info");
|
|
3143
|
-
modelSet = true;
|
|
3144
|
-
break;
|
|
3145
|
-
} else {
|
|
3146
|
-
const nextModel = modelsToTry[modelsToTry.indexOf(modelId) + 1];
|
|
3147
|
-
if (nextModel) {
|
|
3148
|
-
if (verbose) ctx.ui.notify(`Failed to set model ${modelId}, trying ${nextModel}...`, "info");
|
|
3149
|
-
} else {
|
|
3150
|
-
ctx.ui.notify(`All preferred models unavailable for ${unitType}. Using default.`, "warning");
|
|
3151
|
-
}
|
|
3152
|
-
}
|
|
3153
|
-
}
|
|
3154
|
-
|
|
3155
|
-
// modelSet=false is already handled by the "all fallbacks exhausted" warning above
|
|
3156
|
-
} else if (autoModeStartModel) {
|
|
3157
|
-
// No model preference for this unit type — re-apply the model captured
|
|
3158
|
-
// at auto-mode start to prevent bleed from the shared global settings.json
|
|
3159
|
-
// when multiple GSD instances run concurrently (#650).
|
|
3160
|
-
const availableModels = ctx.modelRegistry.getAvailable();
|
|
3161
|
-
const startModel = availableModels.find(
|
|
3162
|
-
m => m.provider === autoModeStartModel!.provider && m.id === autoModeStartModel!.id,
|
|
3163
|
-
);
|
|
3164
|
-
if (startModel) {
|
|
3165
|
-
const ok = await pi.setModel(startModel, { persist: false });
|
|
3166
|
-
if (!ok) {
|
|
3167
|
-
// Fallback: try matching just by ID across providers
|
|
3168
|
-
const byId = availableModels.find(m => m.id === autoModeStartModel!.id);
|
|
3169
|
-
if (byId) await pi.setModel(byId, { persist: false });
|
|
3170
|
-
}
|
|
3171
|
-
}
|
|
3172
|
-
}
|
|
2995
|
+
// Select and apply model for this unit (dynamic routing, fallback chains, etc.)
|
|
2996
|
+
const modelResult = await selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel);
|
|
2997
|
+
s.currentUnitRouting = modelResult.routing;
|
|
3173
2998
|
|
|
3174
2999
|
// Start progress-aware supervision: a soft warning, an idle watchdog, and
|
|
3175
3000
|
// a larger hard ceiling. Productive long-running tasks may continue past the
|
|
@@ -3180,17 +3005,17 @@ async function dispatchNextUnit(
|
|
|
3180
3005
|
const idleTimeoutMs = (supervisor.idle_timeout_minutes ?? 0) * 60 * 1000;
|
|
3181
3006
|
const hardTimeoutMs = (supervisor.hard_timeout_minutes ?? 0) * 60 * 1000;
|
|
3182
3007
|
|
|
3183
|
-
wrapupWarningHandle = setTimeout(() => {
|
|
3184
|
-
wrapupWarningHandle = null;
|
|
3185
|
-
if (!active || !currentUnit) return;
|
|
3186
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3008
|
+
s.wrapupWarningHandle = setTimeout(() => {
|
|
3009
|
+
s.wrapupWarningHandle = null;
|
|
3010
|
+
if (!s.active || !s.currentUnit) return;
|
|
3011
|
+
writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
|
|
3187
3012
|
phase: "wrapup-warning-sent",
|
|
3188
3013
|
wrapupWarningSent: true,
|
|
3189
3014
|
});
|
|
3190
3015
|
pi.sendMessage(
|
|
3191
3016
|
{
|
|
3192
3017
|
customType: "gsd-auto-wrapup",
|
|
3193
|
-
display: verbose,
|
|
3018
|
+
display: s.verbose,
|
|
3194
3019
|
content: [
|
|
3195
3020
|
"**TIME BUDGET WARNING — keep going only if progress is real.**",
|
|
3196
3021
|
"This unit crossed the soft time budget.",
|
|
@@ -3205,9 +3030,10 @@ async function dispatchNextUnit(
|
|
|
3205
3030
|
);
|
|
3206
3031
|
}, softTimeoutMs);
|
|
3207
3032
|
|
|
3208
|
-
idleWatchdogHandle = setInterval(async () => {
|
|
3209
|
-
|
|
3210
|
-
|
|
3033
|
+
s.idleWatchdogHandle = setInterval(async () => {
|
|
3034
|
+
try {
|
|
3035
|
+
if (!s.active || !s.currentUnit) return;
|
|
3036
|
+
const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId);
|
|
3211
3037
|
if (!runtime) return;
|
|
3212
3038
|
if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return;
|
|
3213
3039
|
|
|
@@ -3216,11 +3042,11 @@ async function dispatchNextUnit(
|
|
|
3216
3042
|
// if the tool started recently. A tool in-flight for longer than the idle
|
|
3217
3043
|
// timeout is likely stuck — e.g., `python -m http.server 8080 &` keeps the
|
|
3218
3044
|
// shell's stdout/stderr open, causing the Bash tool to hang indefinitely.
|
|
3219
|
-
if (
|
|
3220
|
-
const oldestStart =
|
|
3045
|
+
if (getInFlightToolCount() > 0) {
|
|
3046
|
+
const oldestStart = getOldestInFlightToolStart()!;
|
|
3221
3047
|
const toolAgeMs = Date.now() - oldestStart;
|
|
3222
3048
|
if (toolAgeMs < idleTimeoutMs) {
|
|
3223
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3049
|
+
writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
|
|
3224
3050
|
lastProgressAt: Date.now(),
|
|
3225
3051
|
lastProgressKind: "tool-in-flight",
|
|
3226
3052
|
});
|
|
@@ -3238,24 +3064,24 @@ async function dispatchNextUnit(
|
|
|
3238
3064
|
// Before triggering recovery, check if the agent is actually producing
|
|
3239
3065
|
// work on disk. `git status --porcelain` is cheap and catches any
|
|
3240
3066
|
// staged/unstaged/untracked changes the agent made since lastProgressAt.
|
|
3241
|
-
if (detectWorkingTreeActivity(basePath)) {
|
|
3242
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3067
|
+
if (detectWorkingTreeActivity(s.basePath)) {
|
|
3068
|
+
writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
|
|
3243
3069
|
lastProgressAt: Date.now(),
|
|
3244
3070
|
lastProgressKind: "filesystem-activity",
|
|
3245
3071
|
});
|
|
3246
3072
|
return;
|
|
3247
3073
|
}
|
|
3248
3074
|
|
|
3249
|
-
if (currentUnit) {
|
|
3250
|
-
|
|
3251
|
-
|
|
3075
|
+
if (s.currentUnit) {
|
|
3076
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
3077
|
+
} else {
|
|
3078
|
+
saveActivityLog(ctx, s.basePath, unitType, unitId);
|
|
3252
3079
|
}
|
|
3253
|
-
saveActivityLog(ctx, basePath, unitType, unitId);
|
|
3254
3080
|
|
|
3255
|
-
const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle");
|
|
3081
|
+
const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle", buildRecoveryContext());
|
|
3256
3082
|
if (recovery === "recovered") return;
|
|
3257
3083
|
|
|
3258
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3084
|
+
writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
|
|
3259
3085
|
phase: "paused",
|
|
3260
3086
|
});
|
|
3261
3087
|
ctx.ui.notify(
|
|
@@ -3263,22 +3089,33 @@ async function dispatchNextUnit(
|
|
|
3263
3089
|
"warning",
|
|
3264
3090
|
);
|
|
3265
3091
|
await pauseAuto(ctx, pi);
|
|
3092
|
+
} catch (err) {
|
|
3093
|
+
// Guard against unhandled rejections in the async interval callback.
|
|
3094
|
+
// Without this, a thrown error leaves the interval running forever
|
|
3095
|
+
// while the auto-mode state becomes inconsistent.
|
|
3096
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3097
|
+
console.error(`[idle-watchdog] Unhandled error: ${message}`);
|
|
3098
|
+
try {
|
|
3099
|
+
ctx.ui.notify(`Idle watchdog error: ${message}`, "warning");
|
|
3100
|
+
} catch { /* best effort */ }
|
|
3101
|
+
}
|
|
3266
3102
|
}, 15000);
|
|
3267
3103
|
|
|
3268
|
-
unitTimeoutHandle = setTimeout(async () => {
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
if (
|
|
3272
|
-
|
|
3104
|
+
s.unitTimeoutHandle = setTimeout(async () => {
|
|
3105
|
+
try {
|
|
3106
|
+
s.unitTimeoutHandle = null;
|
|
3107
|
+
if (!s.active) return;
|
|
3108
|
+
if (s.currentUnit) {
|
|
3109
|
+
writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit.startedAt, {
|
|
3273
3110
|
phase: "timeout",
|
|
3274
3111
|
timeoutAt: Date.now(),
|
|
3275
3112
|
});
|
|
3276
|
-
|
|
3277
|
-
|
|
3113
|
+
await closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
|
|
3114
|
+
} else {
|
|
3115
|
+
saveActivityLog(ctx, s.basePath, unitType, unitId);
|
|
3278
3116
|
}
|
|
3279
|
-
saveActivityLog(ctx, basePath, unitType, unitId);
|
|
3280
3117
|
|
|
3281
|
-
const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "hard");
|
|
3118
|
+
const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "hard", buildRecoveryContext());
|
|
3282
3119
|
if (recovery === "recovered") return;
|
|
3283
3120
|
|
|
3284
3121
|
ctx.ui.notify(
|
|
@@ -3286,12 +3123,80 @@ async function dispatchNextUnit(
|
|
|
3286
3123
|
"warning",
|
|
3287
3124
|
);
|
|
3288
3125
|
await pauseAuto(ctx, pi);
|
|
3126
|
+
} catch (err) {
|
|
3127
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3128
|
+
console.error(`[hard-timeout] Unhandled error: ${message}`);
|
|
3129
|
+
try {
|
|
3130
|
+
ctx.ui.notify(`Hard timeout error: ${message}`, "warning");
|
|
3131
|
+
} catch { /* best effort */ }
|
|
3132
|
+
}
|
|
3289
3133
|
}, hardTimeoutMs);
|
|
3290
3134
|
|
|
3291
|
-
//
|
|
3292
|
-
|
|
3135
|
+
// ── Continue-here context-pressure monitor ────────────────────────────
|
|
3136
|
+
// Polls context usage every 15s. When usage hits the continue-here
|
|
3137
|
+
// threshold (70%), sends a one-shot wrap-up signal so the agent finishes
|
|
3138
|
+
// gracefully and the next unit gets a fresh session. This is softer than
|
|
3139
|
+
// context_pause_threshold which hard-pauses auto-mode entirely.
|
|
3140
|
+
if (s.continueHereHandle) {
|
|
3141
|
+
clearInterval(s.continueHereHandle);
|
|
3142
|
+
s.continueHereHandle = null;
|
|
3143
|
+
}
|
|
3144
|
+
const executorContextWindow = resolveExecutorContextWindow(
|
|
3145
|
+
ctx.modelRegistry as Parameters<typeof resolveExecutorContextWindow>[0],
|
|
3146
|
+
prefs as Parameters<typeof resolveExecutorContextWindow>[1],
|
|
3147
|
+
ctx.model?.contextWindow,
|
|
3148
|
+
);
|
|
3149
|
+
const continueHereThreshold = computeBudgets(executorContextWindow).continueThresholdPercent;
|
|
3150
|
+
s.continueHereHandle = setInterval(() => {
|
|
3151
|
+
if (!s.active || !s.currentUnit || !s.cmdCtx) return;
|
|
3152
|
+
// One-shot guard: skip if already fired for this unit
|
|
3153
|
+
const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId);
|
|
3154
|
+
if (runtime?.continueHereFired) return;
|
|
3155
|
+
|
|
3156
|
+
const contextUsage = s.cmdCtx.getContextUsage();
|
|
3157
|
+
if (!contextUsage || contextUsage.percent == null || contextUsage.percent < continueHereThreshold) return;
|
|
3158
|
+
|
|
3159
|
+
// Fire once — mark runtime record and send wrap-up message
|
|
3160
|
+
writeUnitRuntimeRecord(s.basePath, unitType, unitId, s.currentUnit!.startedAt, {
|
|
3161
|
+
continueHereFired: true,
|
|
3162
|
+
});
|
|
3163
|
+
|
|
3164
|
+
if (s.verbose) {
|
|
3165
|
+
ctx.ui.notify(
|
|
3166
|
+
`Context at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%) — sending wrap-up signal.`,
|
|
3167
|
+
"info",
|
|
3168
|
+
);
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
pi.sendMessage(
|
|
3172
|
+
{
|
|
3173
|
+
customType: "gsd-auto-wrapup",
|
|
3174
|
+
display: s.verbose,
|
|
3175
|
+
content: [
|
|
3176
|
+
"**CONTEXT BUDGET WARNING — wrap up this unit now.**",
|
|
3177
|
+
`Context window is at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%).`,
|
|
3178
|
+
"The next unit needs a fresh context to work effectively. Wrap up now:",
|
|
3179
|
+
"1. Finish any in-progress file writes",
|
|
3180
|
+
"2. Write or update the required durable artifacts (summary, checkboxes)",
|
|
3181
|
+
"3. Mark task state on disk correctly",
|
|
3182
|
+
"4. Leave precise resume notes if anything remains unfinished",
|
|
3183
|
+
"Do NOT start new sub-tasks or investigations.",
|
|
3184
|
+
].join("\n"),
|
|
3185
|
+
},
|
|
3186
|
+
{ triggerTurn: true },
|
|
3187
|
+
);
|
|
3188
|
+
|
|
3189
|
+
// Clear the interval after firing — no need to keep polling
|
|
3190
|
+
if (s.continueHereHandle) {
|
|
3191
|
+
clearInterval(s.continueHereHandle);
|
|
3192
|
+
s.continueHereHandle = null;
|
|
3193
|
+
}
|
|
3194
|
+
}, 15_000);
|
|
3195
|
+
|
|
3196
|
+
// Inject prompt — verify auto-mode still s.active (guards against race with timeout/pause)
|
|
3197
|
+
if (!s.active) return;
|
|
3293
3198
|
pi.sendMessage(
|
|
3294
|
-
{ customType: "gsd-auto", content: finalPrompt, display: verbose },
|
|
3199
|
+
{ customType: "gsd-auto", content: finalPrompt, display: s.verbose },
|
|
3295
3200
|
{ triggerTurn: true },
|
|
3296
3201
|
);
|
|
3297
3202
|
|
|
@@ -3306,7 +3211,7 @@ async function dispatchNextUnit(
|
|
|
3306
3211
|
await pauseAuto(ctx, pi);
|
|
3307
3212
|
}
|
|
3308
3213
|
} finally {
|
|
3309
|
-
|
|
3214
|
+
s.dispatching = false;
|
|
3310
3215
|
}
|
|
3311
3216
|
}
|
|
3312
3217
|
|
|
@@ -3356,296 +3261,16 @@ function ensurePreconditions(
|
|
|
3356
3261
|
|
|
3357
3262
|
// ─── Diagnostics ──────────────────────────────────────────────────────────────
|
|
3358
3263
|
|
|
3359
|
-
|
|
3360
|
-
ctx: ExtensionContext,
|
|
3361
|
-
unitType: string,
|
|
3362
|
-
unitId: string,
|
|
3363
|
-
): Promise<import("./observability-validator.ts").ValidationIssue[]> {
|
|
3364
|
-
// Hook units have custom artifacts — skip standard observability checks
|
|
3365
|
-
if (unitType.startsWith("hook/")) return [];
|
|
3366
|
-
|
|
3367
|
-
const parts = unitId.split("/");
|
|
3368
|
-
const mid = parts[0];
|
|
3369
|
-
const sid = parts[1];
|
|
3370
|
-
const tid = parts[2];
|
|
3371
|
-
|
|
3372
|
-
if (!mid || !sid) return [];
|
|
3373
|
-
|
|
3374
|
-
let issues = [] as Awaited<ReturnType<typeof validatePlanBoundary>>;
|
|
3375
|
-
|
|
3376
|
-
if (unitType === "plan-slice") {
|
|
3377
|
-
issues = await validatePlanBoundary(basePath, mid, sid);
|
|
3378
|
-
} else if (unitType === "execute-task" && tid) {
|
|
3379
|
-
issues = await validateExecuteBoundary(basePath, mid, sid, tid);
|
|
3380
|
-
} else if (unitType === "complete-slice") {
|
|
3381
|
-
issues = await validateCompleteBoundary(basePath, mid, sid);
|
|
3382
|
-
}
|
|
3383
|
-
|
|
3384
|
-
if (issues.length > 0) {
|
|
3385
|
-
ctx.ui.notify(
|
|
3386
|
-
`Observability check (${unitType}) found ${issues.length} warning${issues.length === 1 ? "" : "s"}:\n${formatValidationIssues(issues)}`,
|
|
3387
|
-
"warning",
|
|
3388
|
-
);
|
|
3389
|
-
}
|
|
3390
|
-
|
|
3391
|
-
return issues;
|
|
3392
|
-
}
|
|
3393
|
-
|
|
3394
|
-
function buildObservabilityRepairBlock(issues: import("./observability-validator.ts").ValidationIssue[]): string {
|
|
3395
|
-
if (issues.length === 0) return "";
|
|
3396
|
-
const items = issues.map(issue => {
|
|
3397
|
-
const fileName = issue.file.split("/").pop() || issue.file;
|
|
3398
|
-
let line = `- **${fileName}**: ${issue.message}`;
|
|
3399
|
-
if (issue.suggestion) line += ` → ${issue.suggestion}`;
|
|
3400
|
-
return line;
|
|
3401
|
-
});
|
|
3402
|
-
return [
|
|
3403
|
-
"",
|
|
3404
|
-
"---",
|
|
3405
|
-
"",
|
|
3406
|
-
"## Pre-flight: Observability gaps to fix FIRST",
|
|
3407
|
-
"",
|
|
3408
|
-
"The following issues were detected in plan/summary files for this unit.",
|
|
3409
|
-
"**Read each flagged file, apply the fix described, then proceed with the unit.**",
|
|
3410
|
-
"",
|
|
3411
|
-
...items,
|
|
3412
|
-
"",
|
|
3413
|
-
"---",
|
|
3414
|
-
"",
|
|
3415
|
-
].join("\n");
|
|
3416
|
-
}
|
|
3417
|
-
|
|
3418
|
-
async function recoverTimedOutUnit(
|
|
3419
|
-
ctx: ExtensionContext,
|
|
3420
|
-
pi: ExtensionAPI,
|
|
3421
|
-
unitType: string,
|
|
3422
|
-
unitId: string,
|
|
3423
|
-
reason: "idle" | "hard",
|
|
3424
|
-
): Promise<"recovered" | "paused"> {
|
|
3425
|
-
if (!currentUnit) return "paused";
|
|
3426
|
-
|
|
3427
|
-
const runtime = readUnitRuntimeRecord(basePath, unitType, unitId);
|
|
3428
|
-
const recoveryAttempts = runtime?.recoveryAttempts ?? 0;
|
|
3429
|
-
const maxRecoveryAttempts = reason === "idle" ? 2 : 1;
|
|
3430
|
-
|
|
3431
|
-
const recoveryKey = `${unitType}/${unitId}`;
|
|
3432
|
-
const attemptNumber = (unitRecoveryCount.get(recoveryKey) ?? 0) + 1;
|
|
3433
|
-
unitRecoveryCount.set(recoveryKey, attemptNumber);
|
|
3434
|
-
|
|
3435
|
-
if (attemptNumber > 1) {
|
|
3436
|
-
// Exponential backoff: 2^(n-1) seconds, capped at 30s
|
|
3437
|
-
const backoffMs = Math.min(1000 * Math.pow(2, attemptNumber - 2), 30000);
|
|
3438
|
-
ctx.ui.notify(
|
|
3439
|
-
`Recovery attempt ${attemptNumber} for ${unitType} ${unitId}. Waiting ${backoffMs / 1000}s before retry.`,
|
|
3440
|
-
"info",
|
|
3441
|
-
);
|
|
3442
|
-
await new Promise(r => setTimeout(r, backoffMs));
|
|
3443
|
-
}
|
|
3444
|
-
|
|
3445
|
-
if (unitType === "execute-task") {
|
|
3446
|
-
const status = await inspectExecuteTaskDurability(basePath, unitId);
|
|
3447
|
-
if (!status) return "paused";
|
|
3448
|
-
|
|
3449
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3450
|
-
recovery: status,
|
|
3451
|
-
});
|
|
3452
|
-
|
|
3453
|
-
const durableComplete = status.summaryExists && status.taskChecked && status.nextActionAdvanced;
|
|
3454
|
-
if (durableComplete) {
|
|
3455
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3456
|
-
phase: "finalized",
|
|
3457
|
-
recovery: status,
|
|
3458
|
-
});
|
|
3459
|
-
ctx.ui.notify(
|
|
3460
|
-
`${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode. (attempt ${attemptNumber})`,
|
|
3461
|
-
"info",
|
|
3462
|
-
);
|
|
3463
|
-
unitRecoveryCount.delete(recoveryKey);
|
|
3464
|
-
await dispatchNextUnit(ctx, pi);
|
|
3465
|
-
return "recovered";
|
|
3466
|
-
}
|
|
3467
|
-
|
|
3468
|
-
if (recoveryAttempts < maxRecoveryAttempts) {
|
|
3469
|
-
const isEscalation = recoveryAttempts > 0;
|
|
3470
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3471
|
-
phase: "recovered",
|
|
3472
|
-
recovery: status,
|
|
3473
|
-
recoveryAttempts: recoveryAttempts + 1,
|
|
3474
|
-
lastRecoveryReason: reason,
|
|
3475
|
-
lastProgressAt: Date.now(),
|
|
3476
|
-
progressCount: (runtime?.progressCount ?? 0) + 1,
|
|
3477
|
-
lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
|
|
3478
|
-
});
|
|
3479
|
-
|
|
3480
|
-
const steeringLines = isEscalation
|
|
3481
|
-
? [
|
|
3482
|
-
`**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before this task is skipped.**`,
|
|
3483
|
-
`You are still executing ${unitType} ${unitId}.`,
|
|
3484
|
-
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
3485
|
-
`Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
|
|
3486
|
-
"You MUST finish the durable output NOW, even if incomplete.",
|
|
3487
|
-
"Write the task summary with whatever you have accomplished so far.",
|
|
3488
|
-
"Mark the task [x] in the plan. Commit your work.",
|
|
3489
|
-
"A partial summary is infinitely better than no summary.",
|
|
3490
|
-
]
|
|
3491
|
-
: [
|
|
3492
|
-
`**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — do not stop.**`,
|
|
3493
|
-
`You are still executing ${unitType} ${unitId}.`,
|
|
3494
|
-
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
3495
|
-
`Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
|
|
3496
|
-
"Do not keep exploring.",
|
|
3497
|
-
"Immediately finish the required durable output for this unit.",
|
|
3498
|
-
"If full completion is impossible, write the partial artifact/state needed for recovery and make the blocker explicit.",
|
|
3499
|
-
];
|
|
3500
|
-
|
|
3501
|
-
pi.sendMessage(
|
|
3502
|
-
{
|
|
3503
|
-
customType: "gsd-auto-timeout-recovery",
|
|
3504
|
-
display: verbose,
|
|
3505
|
-
content: steeringLines.join("\n"),
|
|
3506
|
-
},
|
|
3507
|
-
{ triggerTurn: true, deliverAs: "steer" },
|
|
3508
|
-
);
|
|
3509
|
-
ctx.ui.notify(
|
|
3510
|
-
`${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to finish durable output (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
|
|
3511
|
-
"warning",
|
|
3512
|
-
);
|
|
3513
|
-
return "recovered";
|
|
3514
|
-
}
|
|
3515
|
-
|
|
3516
|
-
// Retries exhausted — write missing durable artifacts and advance.
|
|
3517
|
-
const diagnostic = formatExecuteTaskRecoveryStatus(status);
|
|
3518
|
-
const [mid, sid, tid] = unitId.split("/");
|
|
3519
|
-
const skipped = mid && sid && tid
|
|
3520
|
-
? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
|
|
3521
|
-
: false;
|
|
3522
|
-
|
|
3523
|
-
if (skipped) {
|
|
3524
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3525
|
-
phase: "skipped",
|
|
3526
|
-
recovery: status,
|
|
3527
|
-
recoveryAttempts: recoveryAttempts + 1,
|
|
3528
|
-
lastRecoveryReason: reason,
|
|
3529
|
-
});
|
|
3530
|
-
ctx.ui.notify(
|
|
3531
|
-
`${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts (${diagnostic}). Blocker artifacts written. Advancing pipeline. (attempt ${attemptNumber})`,
|
|
3532
|
-
"warning",
|
|
3533
|
-
);
|
|
3534
|
-
unitRecoveryCount.delete(recoveryKey);
|
|
3535
|
-
await dispatchNextUnit(ctx, pi);
|
|
3536
|
-
return "recovered";
|
|
3537
|
-
}
|
|
3538
|
-
|
|
3539
|
-
// Fallback: couldn't write skip artifacts — pause as before.
|
|
3540
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3541
|
-
phase: "paused",
|
|
3542
|
-
recovery: status,
|
|
3543
|
-
recoveryAttempts: recoveryAttempts + 1,
|
|
3544
|
-
lastRecoveryReason: reason,
|
|
3545
|
-
});
|
|
3546
|
-
ctx.ui.notify(
|
|
3547
|
-
`${reason === "idle" ? "Idle" : "Timeout"} recovery check for ${unitType} ${unitId}: ${diagnostic}`,
|
|
3548
|
-
"warning",
|
|
3549
|
-
);
|
|
3550
|
-
return "paused";
|
|
3551
|
-
}
|
|
3552
|
-
|
|
3553
|
-
const expected = diagnoseExpectedArtifact(unitType, unitId, basePath) ?? "required durable artifact";
|
|
3554
|
-
|
|
3555
|
-
// Check if the artifact already exists on disk — agent may have written it
|
|
3556
|
-
// without signaling completion.
|
|
3557
|
-
const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath);
|
|
3558
|
-
if (artifactPath && existsSync(artifactPath)) {
|
|
3559
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3560
|
-
phase: "finalized",
|
|
3561
|
-
recoveryAttempts: recoveryAttempts + 1,
|
|
3562
|
-
lastRecoveryReason: reason,
|
|
3563
|
-
});
|
|
3564
|
-
ctx.ui.notify(
|
|
3565
|
-
`${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} artifact already exists on disk. Advancing. (attempt ${attemptNumber})`,
|
|
3566
|
-
"info",
|
|
3567
|
-
);
|
|
3568
|
-
unitRecoveryCount.delete(recoveryKey);
|
|
3569
|
-
await dispatchNextUnit(ctx, pi);
|
|
3570
|
-
return "recovered";
|
|
3571
|
-
}
|
|
3572
|
-
|
|
3573
|
-
if (recoveryAttempts < maxRecoveryAttempts) {
|
|
3574
|
-
const isEscalation = recoveryAttempts > 0;
|
|
3575
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3576
|
-
phase: "recovered",
|
|
3577
|
-
recoveryAttempts: recoveryAttempts + 1,
|
|
3578
|
-
lastRecoveryReason: reason,
|
|
3579
|
-
lastProgressAt: Date.now(),
|
|
3580
|
-
progressCount: (runtime?.progressCount ?? 0) + 1,
|
|
3581
|
-
lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
|
|
3582
|
-
});
|
|
3583
|
-
|
|
3584
|
-
const steeringLines = isEscalation
|
|
3585
|
-
? [
|
|
3586
|
-
`**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before skip.**`,
|
|
3587
|
-
`You are still executing ${unitType} ${unitId}.`,
|
|
3588
|
-
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts} — next failure skips this unit.`,
|
|
3589
|
-
`Expected durable output: ${expected}.`,
|
|
3590
|
-
"You MUST write the artifact file NOW, even if incomplete.",
|
|
3591
|
-
"Write whatever you have — partial research, preliminary findings, best-effort analysis.",
|
|
3592
|
-
"A partial artifact is infinitely better than no artifact.",
|
|
3593
|
-
"If you are truly blocked, write the file with a BLOCKER section explaining why.",
|
|
3594
|
-
]
|
|
3595
|
-
: [
|
|
3596
|
-
`**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — stay in auto-mode.**`,
|
|
3597
|
-
`You are still executing ${unitType} ${unitId}.`,
|
|
3598
|
-
`Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
|
|
3599
|
-
`Expected durable output: ${expected}.`,
|
|
3600
|
-
"Stop broad exploration.",
|
|
3601
|
-
"Write the required artifact now.",
|
|
3602
|
-
"If blocked, write the partial artifact and explicitly record the blocker instead of going silent.",
|
|
3603
|
-
];
|
|
3264
|
+
// collectObservabilityWarnings + buildObservabilityRepairBlock → auto-observability.ts
|
|
3604
3265
|
|
|
3605
|
-
|
|
3606
|
-
{
|
|
3607
|
-
customType: "gsd-auto-timeout-recovery",
|
|
3608
|
-
display: verbose,
|
|
3609
|
-
content: steeringLines.join("\n"),
|
|
3610
|
-
},
|
|
3611
|
-
{ triggerTurn: true, deliverAs: "steer" },
|
|
3612
|
-
);
|
|
3613
|
-
ctx.ui.notify(
|
|
3614
|
-
`${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to produce ${expected} (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
|
|
3615
|
-
"warning",
|
|
3616
|
-
);
|
|
3617
|
-
return "recovered";
|
|
3618
|
-
}
|
|
3266
|
+
// recoverTimedOutUnit → auto-timeout-recovery.ts
|
|
3619
3267
|
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
if (placeholder) {
|
|
3628
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3629
|
-
phase: "skipped",
|
|
3630
|
-
recoveryAttempts: recoveryAttempts + 1,
|
|
3631
|
-
lastRecoveryReason: reason,
|
|
3632
|
-
});
|
|
3633
|
-
ctx.ui.notify(
|
|
3634
|
-
`${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts. Blocker placeholder written to ${placeholder}. Advancing pipeline. (attempt ${attemptNumber})`,
|
|
3635
|
-
"warning",
|
|
3636
|
-
);
|
|
3637
|
-
unitRecoveryCount.delete(recoveryKey);
|
|
3638
|
-
await dispatchNextUnit(ctx, pi);
|
|
3639
|
-
return "recovered";
|
|
3640
|
-
}
|
|
3641
|
-
|
|
3642
|
-
// Fallback: couldn't resolve artifact path — pause as before.
|
|
3643
|
-
writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
|
|
3644
|
-
phase: "paused",
|
|
3645
|
-
recoveryAttempts: recoveryAttempts + 1,
|
|
3646
|
-
lastRecoveryReason: reason,
|
|
3647
|
-
});
|
|
3648
|
-
return "paused";
|
|
3268
|
+
/** Build recovery context from module state for recoverTimedOutUnit */
|
|
3269
|
+
function buildRecoveryContext(): import("./auto-timeout-recovery.js").RecoveryContext {
|
|
3270
|
+
return { basePath: s.basePath, verbose: s.verbose,
|
|
3271
|
+
currentUnitStartedAt: s.currentUnit?.startedAt ?? Date.now(), unitRecoveryCount: s.unitRecoveryCount,
|
|
3272
|
+
dispatchNextUnit,
|
|
3273
|
+
};
|
|
3649
3274
|
}
|
|
3650
3275
|
|
|
3651
3276
|
// Re-export recovery functions for external consumers
|
|
@@ -3661,9 +3286,9 @@ export {
|
|
|
3661
3286
|
* Test-only: expose skip-loop state for unit tests.
|
|
3662
3287
|
* Not part of the public API.
|
|
3663
3288
|
*/
|
|
3664
|
-
export function _getUnitConsecutiveSkips(): Map<string, number> { return unitConsecutiveSkips; }
|
|
3665
|
-
export function _resetUnitConsecutiveSkips(): void { unitConsecutiveSkips.clear(); }
|
|
3666
|
-
|
|
3289
|
+
export function _getUnitConsecutiveSkips(): Map<string, number> { return s.unitConsecutiveSkips; }
|
|
3290
|
+
export function _resetUnitConsecutiveSkips(): void { s.unitConsecutiveSkips.clear(); }
|
|
3291
|
+
// MAX_CONSECUTIVE_SKIPS re-exported from auto/session.ts at top of file
|
|
3667
3292
|
|
|
3668
3293
|
/**
|
|
3669
3294
|
* Dispatch a hook unit directly, bypassing normal pre-dispatch hooks.
|
|
@@ -3679,37 +3304,37 @@ export async function dispatchHookUnit(
|
|
|
3679
3304
|
hookModel: string | undefined,
|
|
3680
3305
|
targetBasePath: string,
|
|
3681
3306
|
): Promise<boolean> {
|
|
3682
|
-
// Ensure auto-mode is active
|
|
3683
|
-
if (!active) {
|
|
3307
|
+
// Ensure auto-mode is s.active
|
|
3308
|
+
if (!s.active) {
|
|
3684
3309
|
// Initialize auto-mode state minimally
|
|
3685
|
-
active = true;
|
|
3686
|
-
stepMode = true;
|
|
3687
|
-
cmdCtx = ctx as ExtensionCommandContext;
|
|
3688
|
-
basePath = targetBasePath;
|
|
3689
|
-
autoStartTime = Date.now();
|
|
3690
|
-
currentUnit = null;
|
|
3691
|
-
completedUnits = [];
|
|
3692
|
-
pendingQuickTasks = [];
|
|
3310
|
+
s.active = true;
|
|
3311
|
+
s.stepMode = true;
|
|
3312
|
+
s.cmdCtx = ctx as ExtensionCommandContext;
|
|
3313
|
+
s.basePath = targetBasePath;
|
|
3314
|
+
s.autoStartTime = Date.now();
|
|
3315
|
+
s.currentUnit = null;
|
|
3316
|
+
s.completedUnits = [];
|
|
3317
|
+
s.pendingQuickTasks = [];
|
|
3693
3318
|
}
|
|
3694
3319
|
|
|
3695
3320
|
const hookUnitType = `hook/${hookName}`;
|
|
3696
3321
|
const hookStartedAt = Date.now();
|
|
3697
3322
|
|
|
3698
3323
|
// Set up the trigger unit as the "current" unit so post-unit hooks can reference it
|
|
3699
|
-
currentUnit = { type: triggerUnitType, id: triggerUnitId, startedAt: hookStartedAt };
|
|
3324
|
+
s.currentUnit = { type: triggerUnitType, id: triggerUnitId, startedAt: hookStartedAt };
|
|
3700
3325
|
|
|
3701
3326
|
// Create a new session for the hook
|
|
3702
|
-
const result = await cmdCtx!.newSession();
|
|
3327
|
+
const result = await s.cmdCtx!.newSession();
|
|
3703
3328
|
if (result.cancelled) {
|
|
3704
3329
|
await stopAuto(ctx, pi);
|
|
3705
3330
|
return false;
|
|
3706
3331
|
}
|
|
3707
3332
|
|
|
3708
3333
|
// Update current unit to the hook unit
|
|
3709
|
-
currentUnit = { type: hookUnitType, id: triggerUnitId, startedAt: hookStartedAt };
|
|
3334
|
+
s.currentUnit = { type: hookUnitType, id: triggerUnitId, startedAt: hookStartedAt };
|
|
3710
3335
|
|
|
3711
3336
|
// Write runtime record
|
|
3712
|
-
writeUnitRuntimeRecord(basePath, hookUnitType, triggerUnitId, hookStartedAt, {
|
|
3337
|
+
writeUnitRuntimeRecord(s.basePath, hookUnitType, triggerUnitId, hookStartedAt, {
|
|
3713
3338
|
phase: "dispatched",
|
|
3714
3339
|
wrapupWarningSent: false,
|
|
3715
3340
|
timeoutAt: null,
|
|
@@ -3733,17 +3358,17 @@ export async function dispatchHookUnit(
|
|
|
3733
3358
|
|
|
3734
3359
|
// Write lock
|
|
3735
3360
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
3736
|
-
writeLock(lockBase(), hookUnitType, triggerUnitId, completedUnits.length, sessionFile);
|
|
3361
|
+
writeLock(lockBase(), hookUnitType, triggerUnitId, s.completedUnits.length, sessionFile);
|
|
3737
3362
|
|
|
3738
3363
|
// Set up timeout
|
|
3739
3364
|
clearUnitTimeout();
|
|
3740
3365
|
const supervisor = resolveAutoSupervisorConfig();
|
|
3741
3366
|
const hookHardTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
|
|
3742
|
-
unitTimeoutHandle = setTimeout(async () => {
|
|
3743
|
-
unitTimeoutHandle = null;
|
|
3744
|
-
if (!active) return;
|
|
3745
|
-
if (currentUnit) {
|
|
3746
|
-
writeUnitRuntimeRecord(basePath, hookUnitType, triggerUnitId, hookStartedAt, {
|
|
3367
|
+
s.unitTimeoutHandle = setTimeout(async () => {
|
|
3368
|
+
s.unitTimeoutHandle = null;
|
|
3369
|
+
if (!s.active) return;
|
|
3370
|
+
if (s.currentUnit) {
|
|
3371
|
+
writeUnitRuntimeRecord(s.basePath, hookUnitType, triggerUnitId, hookStartedAt, {
|
|
3747
3372
|
phase: "timeout",
|
|
3748
3373
|
timeoutAt: Date.now(),
|
|
3749
3374
|
});
|
|
@@ -3757,7 +3382,7 @@ export async function dispatchHookUnit(
|
|
|
3757
3382
|
}, hookHardTimeoutMs);
|
|
3758
3383
|
|
|
3759
3384
|
// Update status
|
|
3760
|
-
ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
|
|
3385
|
+
ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
|
|
3761
3386
|
ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info");
|
|
3762
3387
|
|
|
3763
3388
|
// Send the hook prompt
|
|
@@ -3772,204 +3397,5 @@ export async function dispatchHookUnit(
|
|
|
3772
3397
|
}
|
|
3773
3398
|
|
|
3774
3399
|
|
|
3775
|
-
//
|
|
3776
|
-
|
|
3777
|
-
export async function dispatchDirectPhase(
|
|
3778
|
-
ctx: ExtensionCommandContext,
|
|
3779
|
-
pi: ExtensionAPI,
|
|
3780
|
-
phase: string,
|
|
3781
|
-
base: string,
|
|
3782
|
-
): Promise<void> {
|
|
3783
|
-
const state = await deriveState(base);
|
|
3784
|
-
const mid = state.activeMilestone?.id;
|
|
3785
|
-
const midTitle = state.activeMilestone?.title ?? "";
|
|
3786
|
-
|
|
3787
|
-
if (!mid) {
|
|
3788
|
-
ctx.ui.notify("Cannot dispatch: no active milestone.", "warning");
|
|
3789
|
-
return;
|
|
3790
|
-
}
|
|
3791
|
-
|
|
3792
|
-
const normalized = phase.toLowerCase();
|
|
3793
|
-
let unitType: string;
|
|
3794
|
-
let unitId: string;
|
|
3795
|
-
let prompt: string;
|
|
3796
|
-
|
|
3797
|
-
switch (normalized) {
|
|
3798
|
-
case "research":
|
|
3799
|
-
case "research-milestone":
|
|
3800
|
-
case "research-slice": {
|
|
3801
|
-
const isSlice = normalized === "research-slice" || (normalized === "research" && state.phase !== "pre-planning");
|
|
3802
|
-
if (isSlice) {
|
|
3803
|
-
const sid = state.activeSlice?.id;
|
|
3804
|
-
const sTitle = state.activeSlice?.title ?? "";
|
|
3805
|
-
if (!sid) {
|
|
3806
|
-
ctx.ui.notify("Cannot dispatch research-slice: no active slice.", "warning");
|
|
3807
|
-
return;
|
|
3808
|
-
}
|
|
3809
|
-
|
|
3810
|
-
// When require_slice_discussion is enabled, pause auto-mode before
|
|
3811
|
-
// each new slice so the user can discuss requirements first (#789).
|
|
3812
|
-
const sliceContextFile = resolveSliceFile(base, mid, sid, "CONTEXT");
|
|
3813
|
-
const requireDiscussion = loadEffectiveGSDPreferences()?.preferences?.phases?.require_slice_discussion;
|
|
3814
|
-
if (requireDiscussion && !sliceContextFile) {
|
|
3815
|
-
ctx.ui.notify(
|
|
3816
|
-
`Slice ${sid} requires discussion before planning. Run /gsd discuss to discuss this slice, then /gsd auto to resume.`,
|
|
3817
|
-
"info",
|
|
3818
|
-
);
|
|
3819
|
-
await pauseAuto(ctx, pi);
|
|
3820
|
-
return;
|
|
3821
|
-
}
|
|
3822
|
-
|
|
3823
|
-
unitType = "research-slice";
|
|
3824
|
-
unitId = `${mid}/${sid}`;
|
|
3825
|
-
prompt = await buildResearchSlicePrompt(mid, midTitle, sid, sTitle, base);
|
|
3826
|
-
} else {
|
|
3827
|
-
unitType = "research-milestone";
|
|
3828
|
-
unitId = mid;
|
|
3829
|
-
prompt = await buildResearchMilestonePrompt(mid, midTitle, base);
|
|
3830
|
-
}
|
|
3831
|
-
break;
|
|
3832
|
-
}
|
|
3833
|
-
|
|
3834
|
-
case "plan":
|
|
3835
|
-
case "plan-milestone":
|
|
3836
|
-
case "plan-slice": {
|
|
3837
|
-
const isSlice = normalized === "plan-slice" || (normalized === "plan" && state.phase !== "pre-planning");
|
|
3838
|
-
if (isSlice) {
|
|
3839
|
-
const sid = state.activeSlice?.id;
|
|
3840
|
-
const sTitle = state.activeSlice?.title ?? "";
|
|
3841
|
-
if (!sid) {
|
|
3842
|
-
ctx.ui.notify("Cannot dispatch plan-slice: no active slice.", "warning");
|
|
3843
|
-
return;
|
|
3844
|
-
}
|
|
3845
|
-
unitType = "plan-slice";
|
|
3846
|
-
unitId = `${mid}/${sid}`;
|
|
3847
|
-
prompt = await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, base);
|
|
3848
|
-
} else {
|
|
3849
|
-
unitType = "plan-milestone";
|
|
3850
|
-
unitId = mid;
|
|
3851
|
-
prompt = await buildPlanMilestonePrompt(mid, midTitle, base);
|
|
3852
|
-
}
|
|
3853
|
-
break;
|
|
3854
|
-
}
|
|
3855
|
-
|
|
3856
|
-
case "execute":
|
|
3857
|
-
case "execute-task": {
|
|
3858
|
-
const sid = state.activeSlice?.id;
|
|
3859
|
-
const sTitle = state.activeSlice?.title ?? "";
|
|
3860
|
-
const tid = state.activeTask?.id;
|
|
3861
|
-
const tTitle = state.activeTask?.title ?? "";
|
|
3862
|
-
if (!sid) {
|
|
3863
|
-
ctx.ui.notify("Cannot dispatch execute-task: no active slice.", "warning");
|
|
3864
|
-
return;
|
|
3865
|
-
}
|
|
3866
|
-
if (!tid) {
|
|
3867
|
-
ctx.ui.notify("Cannot dispatch execute-task: no active task.", "warning");
|
|
3868
|
-
return;
|
|
3869
|
-
}
|
|
3870
|
-
unitType = "execute-task";
|
|
3871
|
-
unitId = `${mid}/${sid}/${tid}`;
|
|
3872
|
-
prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base);
|
|
3873
|
-
break;
|
|
3874
|
-
}
|
|
3875
|
-
|
|
3876
|
-
case "complete":
|
|
3877
|
-
case "complete-slice":
|
|
3878
|
-
case "complete-milestone": {
|
|
3879
|
-
const isSlice = normalized === "complete-slice" || (normalized === "complete" && state.phase === "summarizing");
|
|
3880
|
-
if (isSlice) {
|
|
3881
|
-
const sid = state.activeSlice?.id;
|
|
3882
|
-
const sTitle = state.activeSlice?.title ?? "";
|
|
3883
|
-
if (!sid) {
|
|
3884
|
-
ctx.ui.notify("Cannot dispatch complete-slice: no active slice.", "warning");
|
|
3885
|
-
return;
|
|
3886
|
-
}
|
|
3887
|
-
unitType = "complete-slice";
|
|
3888
|
-
unitId = `${mid}/${sid}`;
|
|
3889
|
-
prompt = await buildCompleteSlicePrompt(mid, midTitle, sid, sTitle, base);
|
|
3890
|
-
} else {
|
|
3891
|
-
unitType = "complete-milestone";
|
|
3892
|
-
unitId = mid;
|
|
3893
|
-
prompt = await buildCompleteMilestonePrompt(mid, midTitle, base);
|
|
3894
|
-
}
|
|
3895
|
-
break;
|
|
3896
|
-
}
|
|
3897
|
-
|
|
3898
|
-
case "reassess":
|
|
3899
|
-
case "reassess-roadmap": {
|
|
3900
|
-
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
3901
|
-
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
|
3902
|
-
if (!roadmapContent) {
|
|
3903
|
-
ctx.ui.notify("Cannot dispatch reassess-roadmap: no roadmap found.", "warning");
|
|
3904
|
-
return;
|
|
3905
|
-
}
|
|
3906
|
-
const roadmap = parseRoadmap(roadmapContent);
|
|
3907
|
-
const completedSlices = roadmap.slices.filter(s => s.done);
|
|
3908
|
-
if (completedSlices.length === 0) {
|
|
3909
|
-
ctx.ui.notify("Cannot dispatch reassess-roadmap: no completed slices.", "warning");
|
|
3910
|
-
return;
|
|
3911
|
-
}
|
|
3912
|
-
const completedSliceId = completedSlices[completedSlices.length - 1].id;
|
|
3913
|
-
unitType = "reassess-roadmap";
|
|
3914
|
-
unitId = `${mid}/${completedSliceId}`;
|
|
3915
|
-
prompt = await buildReassessRoadmapPrompt(mid, midTitle, completedSliceId, base);
|
|
3916
|
-
break;
|
|
3917
|
-
}
|
|
3918
|
-
|
|
3919
|
-
case "uat":
|
|
3920
|
-
case "run-uat": {
|
|
3921
|
-
const sid = state.activeSlice?.id;
|
|
3922
|
-
if (!sid) {
|
|
3923
|
-
ctx.ui.notify("Cannot dispatch run-uat: no active slice.", "warning");
|
|
3924
|
-
return;
|
|
3925
|
-
}
|
|
3926
|
-
const uatFile = resolveSliceFile(base, mid, sid, "UAT");
|
|
3927
|
-
if (!uatFile) {
|
|
3928
|
-
ctx.ui.notify("Cannot dispatch run-uat: no UAT file found.", "warning");
|
|
3929
|
-
return;
|
|
3930
|
-
}
|
|
3931
|
-
const uatContent = await loadFile(uatFile);
|
|
3932
|
-
if (!uatContent) {
|
|
3933
|
-
ctx.ui.notify("Cannot dispatch run-uat: UAT file is empty.", "warning");
|
|
3934
|
-
return;
|
|
3935
|
-
}
|
|
3936
|
-
const uatPath = relSliceFile(base, mid, sid, "UAT");
|
|
3937
|
-
unitType = "run-uat";
|
|
3938
|
-
unitId = `${mid}/${sid}`;
|
|
3939
|
-
prompt = await buildRunUatPrompt(mid, sid, uatPath, uatContent, base);
|
|
3940
|
-
break;
|
|
3941
|
-
}
|
|
3942
|
-
|
|
3943
|
-
case "replan":
|
|
3944
|
-
case "replan-slice": {
|
|
3945
|
-
const sid = state.activeSlice?.id;
|
|
3946
|
-
const sTitle = state.activeSlice?.title ?? "";
|
|
3947
|
-
if (!sid) {
|
|
3948
|
-
ctx.ui.notify("Cannot dispatch replan-slice: no active slice.", "warning");
|
|
3949
|
-
return;
|
|
3950
|
-
}
|
|
3951
|
-
unitType = "replan-slice";
|
|
3952
|
-
unitId = `${mid}/${sid}`;
|
|
3953
|
-
prompt = await buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base);
|
|
3954
|
-
break;
|
|
3955
|
-
}
|
|
3956
|
-
|
|
3957
|
-
default:
|
|
3958
|
-
ctx.ui.notify(
|
|
3959
|
-
`Unknown phase "${phase}". Valid phases: research, plan, execute, complete, reassess, uat, replan.`,
|
|
3960
|
-
"warning",
|
|
3961
|
-
);
|
|
3962
|
-
return;
|
|
3963
|
-
}
|
|
3964
|
-
|
|
3965
|
-
ctx.ui.notify(`Dispatching ${unitType} for ${unitId}...`, "info");
|
|
3966
|
-
const result = await ctx.newSession();
|
|
3967
|
-
if (result.cancelled) {
|
|
3968
|
-
ctx.ui.notify("Session creation cancelled.", "warning");
|
|
3969
|
-
return;
|
|
3970
|
-
}
|
|
3971
|
-
pi.sendMessage(
|
|
3972
|
-
{ customType: "gsd-dispatch", content: prompt, display: false },
|
|
3973
|
-
{ triggerTurn: true },
|
|
3974
|
-
);
|
|
3975
|
-
}
|
|
3400
|
+
// Direct phase dispatch → auto-direct-dispatch.ts
|
|
3401
|
+
export { dispatchDirectPhase } from "./auto-direct-dispatch.js";
|