@navels/neal 0.1.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/LICENSE +21 -0
- package/README.md +527 -0
- package/SECURITY.md +91 -0
- package/config.yml +104 -0
- package/dist/neal/activity-footer.js +177 -0
- package/dist/neal/activity-reporting.js +1 -0
- package/dist/neal/adjudicator/artifacts.js +58 -0
- package/dist/neal/adjudicator/blocked-adjudicator.js +223 -0
- package/dist/neal/adjudicator/contracts.js +139 -0
- package/dist/neal/adjudicator/execute.js +611 -0
- package/dist/neal/adjudicator/final-completion.js +104 -0
- package/dist/neal/adjudicator/planning.js +145 -0
- package/dist/neal/adjudicator/specs.js +453 -0
- package/dist/neal/agents/prompts.js +120 -0
- package/dist/neal/agents/rounds.js +706 -0
- package/dist/neal/agents/schemas.js +832 -0
- package/dist/neal/agents/structured-coder.js +82 -0
- package/dist/neal/agents/structured-json.js +528 -0
- package/dist/neal/agents.js +4 -0
- package/dist/neal/atomic-write.js +18 -0
- package/dist/neal/blocked-guidance.js +406 -0
- package/dist/neal/cli.js +471 -0
- package/dist/neal/commands/check.js +401 -0
- package/dist/neal/commands/compat.js +807 -0
- package/dist/neal/commands/interactive-activity.js +57 -0
- package/dist/neal/commands/new-run.js +79 -0
- package/dist/neal/commands/plan-and-execute.js +44 -0
- package/dist/neal/commands/recovery-guidance.js +217 -0
- package/dist/neal/commands/resume-run.js +395 -0
- package/dist/neal/commands/review.js +21 -0
- package/dist/neal/commands/runtime.js +557 -0
- package/dist/neal/commands/setup.js +596 -0
- package/dist/neal/commands/squash.js +113 -0
- package/dist/neal/commands/status.js +33 -0
- package/dist/neal/commands/writer-exit-codes.js +42 -0
- package/dist/neal/commit-message.js +17 -0
- package/dist/neal/config.js +432 -0
- package/dist/neal/context/artifacts.js +140 -0
- package/dist/neal/context/context.js +324 -0
- package/dist/neal/context/inline-review-context.js +131 -0
- package/dist/neal/context/reviewer-context.js +166 -0
- package/dist/neal/context/shared.js +117 -0
- package/dist/neal/context/types.js +1 -0
- package/dist/neal/diagnostic.js +208 -0
- package/dist/neal/execute-finalization.js +5 -0
- package/dist/neal/final-completion-review.js +188 -0
- package/dist/neal/final-completion.js +229 -0
- package/dist/neal/git.js +339 -0
- package/dist/neal/index.js +135 -0
- package/dist/neal/interactive-controls.js +85 -0
- package/dist/neal/logger.js +102 -0
- package/dist/neal/manual-gates.js +121 -0
- package/dist/neal/orchestrator/artifacts.js +70 -0
- package/dist/neal/orchestrator/completion.js +531 -0
- package/dist/neal/orchestrator/failures.js +31 -0
- package/dist/neal/orchestrator/notifications.js +175 -0
- package/dist/neal/orchestrator/phases/coder.js +516 -0
- package/dist/neal/orchestrator/phases/planning.js +540 -0
- package/dist/neal/orchestrator/phases/recovery.js +798 -0
- package/dist/neal/orchestrator/phases/review.js +136 -0
- package/dist/neal/orchestrator/phases/shared.js +279 -0
- package/dist/neal/orchestrator/run-loop.js +113 -0
- package/dist/neal/orchestrator/split-plan.js +235 -0
- package/dist/neal/orchestrator/transitions.js +309 -0
- package/dist/neal/orchestrator.js +215 -0
- package/dist/neal/phase-display.js +27 -0
- package/dist/neal/plan-doc.js +154 -0
- package/dist/neal/plan-queue.js +1092 -0
- package/dist/neal/plan-refinement.js +39 -0
- package/dist/neal/plan-validation.js +525 -0
- package/dist/neal/progress.js +237 -0
- package/dist/neal/prompts/assert-builder.js +13 -0
- package/dist/neal/prompts/execute.js +290 -0
- package/dist/neal/prompts/guidance.js +70 -0
- package/dist/neal/prompts/planning.js +313 -0
- package/dist/neal/prompts/review-doctrine.js +142 -0
- package/dist/neal/prompts/shared.js +101 -0
- package/dist/neal/prompts/specialized.js +212 -0
- package/dist/neal/prompts/specs.js +572 -0
- package/dist/neal/providers/anthropic-claude.js +1599 -0
- package/dist/neal/providers/detection.js +139 -0
- package/dist/neal/providers/generic-agentic-tools.js +586 -0
- package/dist/neal/providers/generic-agentic.js +1238 -0
- package/dist/neal/providers/liveness.js +151 -0
- package/dist/neal/providers/openai-codex.js +1014 -0
- package/dist/neal/providers/openai-compatible.js +654 -0
- package/dist/neal/providers/registry.js +389 -0
- package/dist/neal/providers/telemetry.js +208 -0
- package/dist/neal/providers/types.js +21 -0
- package/dist/neal/recovery-artifacts.js +50 -0
- package/dist/neal/resume-decision.js +220 -0
- package/dist/neal/resume-planner.js +265 -0
- package/dist/neal/retrospective.js +391 -0
- package/dist/neal/review-debt.js +18 -0
- package/dist/neal/review-findings/artifacts.js +173 -0
- package/dist/neal/review-findings/prompts.js +172 -0
- package/dist/neal/review-findings/provider.js +330 -0
- package/dist/neal/review-findings/run.js +373 -0
- package/dist/neal/review-findings/types.js +1 -0
- package/dist/neal/review-mode.js +67 -0
- package/dist/neal/review.js +137 -0
- package/dist/neal/run-lock.js +334 -0
- package/dist/neal/run-metrics.js +355 -0
- package/dist/neal/run-narrative-types.js +1 -0
- package/dist/neal/run-narrative.js +1374 -0
- package/dist/neal/run-registry.js +218 -0
- package/dist/neal/run-status.js +25 -0
- package/dist/neal/scopes.js +451 -0
- package/dist/neal/sensitive-text.js +8 -0
- package/dist/neal/squash-message.js +379 -0
- package/dist/neal/squash.js +591 -0
- package/dist/neal/state-invariants.js +496 -0
- package/dist/neal/state-views.js +344 -0
- package/dist/neal/state.js +887 -0
- package/dist/neal/status-footer.js +258 -0
- package/dist/neal/status.js +1260 -0
- package/dist/neal/storage-paths.js +57 -0
- package/dist/neal/support.js +58 -0
- package/dist/neal/terminal-narrator.js +435 -0
- package/dist/neal/types.js +1 -0
- package/dist/neal/verification-events.js +81 -0
- package/dist/neal/version.js +37 -0
- package/dist/neal/worktree-status.js +137 -0
- package/dist/notifier.js +44 -0
- package/docs/ADJUDICATOR_INVENTORY.md +310 -0
- package/docs/PROMPT_SPECS.md +266 -0
- package/docs/README.md +22 -0
- package/docs/architecture.md +113 -0
- package/docs/assets/neal-execution-flow.png +0 -0
- package/docs/automation.md +65 -0
- package/docs/comparison.md +105 -0
- package/docs/compat.md +269 -0
- package/docs/compatible-models.md +135 -0
- package/docs/demo.md +55 -0
- package/docs/maintenance.md +64 -0
- package/docs/plan-format.md +213 -0
- package/docs/providers.md +751 -0
- package/docs/release.md +147 -0
- package/docs/state-machine.md +266 -0
- package/docs/storage.md +207 -0
- package/docs/troubleshooting.md +152 -0
- package/examples/compat/add-edit-verify/PLAN.md +29 -0
- package/examples/compat/add-edit-verify/broken.diff +8 -0
- package/examples/compat/add-edit-verify/good.diff +8 -0
- package/examples/compat/add-edit-verify/package.json +5 -0
- package/examples/compat/add-edit-verify/src/add.js +2 -0
- package/examples/compat/add-edit-verify/test/add.test.js +9 -0
- package/examples/compat/is-even-add-test/PLAN.md +30 -0
- package/examples/compat/is-even-add-test/broken.diff +11 -0
- package/examples/compat/is-even-add-test/good.diff +11 -0
- package/examples/compat/is-even-add-test/package.json +5 -0
- package/examples/compat/is-even-add-test/src/is-even.js +3 -0
- package/examples/compat/is-even-add-test/test/is-even.test.js +9 -0
- package/examples/compat/manifest.json +60 -0
- package/examples/compat/plan-greeting/ISSUE.md +25 -0
- package/examples/compat/plan-greeting/package.json +5 -0
- package/examples/compat/plan-greeting/src/greet.js +2 -0
- package/examples/compat/plan-greeting/test/greet.test.js +8 -0
- package/examples/compat/reverse-grep-edit/PLAN.md +32 -0
- package/examples/compat/reverse-grep-edit/broken.diff +12 -0
- package/examples/compat/reverse-grep-edit/good.diff +12 -0
- package/examples/compat/reverse-grep-edit/package.json +5 -0
- package/examples/compat/reverse-grep-edit/src/strings.js +10 -0
- package/examples/compat/reverse-grep-edit/test/strings.test.js +15 -0
- package/examples/issue-triage-js/PLAN.md +83 -0
- package/examples/issue-triage-js/README.md +76 -0
- package/examples/issue-triage-js/package.json +9 -0
- package/examples/issue-triage-js/src/issue-triage.js +87 -0
- package/examples/issue-triage-js/test/issue-triage.test.js +107 -0
- package/package.json +70 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import { CoderRoundError, runBlockedRecoveryCoderRound, } from '../../agents.js';
|
|
2
|
+
import { ADJUDICATOR_ELIGIBLE_SOURCE_PHASES, buildRecentBlockCandidate, isReviewerAdjudicatorPhase, runBlockedAdjudicator, upsertRecentBlock, } from '../../adjudicator/blocked-adjudicator.js';
|
|
3
|
+
import { UNATTENDED_AUTO_RESUME_GUIDANCE } from '../../blocked-guidance.js';
|
|
4
|
+
import { getInteractiveBlockedRecoveryMaxTurns, getReviewStuckArbiterMaxAttempts } from '../../config.js';
|
|
5
|
+
import { EXECUTE_FINALIZATION_PHASE } from '../../execute-finalization.js';
|
|
6
|
+
import { hasPendingOperatorGuidance } from '../../run-status.js';
|
|
7
|
+
import { getExecutionPlanPath } from '../../scopes.js';
|
|
8
|
+
import { loadState, saveState } from '../../state.js';
|
|
9
|
+
import { isActivePendingDerivedPlanReview } from '../../state-views.js';
|
|
10
|
+
import { writeExecutionArtifacts } from '../artifacts.js';
|
|
11
|
+
import { isCoderTimeoutError, shouldNotifyFailure } from '../failures.js';
|
|
12
|
+
import { flushDerivedPlanNotifications, notifyBlocked } from '../notifications.js';
|
|
13
|
+
import { persistSplitPlanRecovery } from '../split-plan.js';
|
|
14
|
+
import { bestEffortCleanupTimedOutCoder, persistBlockedScope, persistCoderFailureState, persistUnattendedBlockUnresolvedFailure, scheduleCoderFreshSessionRetry, shouldRetryCoderWithFreshSession, } from './shared.js';
|
|
15
|
+
// Bounded number of synthesized conservative auto-resumes the execute-mode
|
|
16
|
+
// interactive-recovery chokepoint performs under `unattended` before it fails
|
|
17
|
+
// cleanly and terminally. Kept a module constant (not a config knob) and held
|
|
18
|
+
// at or below `interactive_blocked_recovery_max_turns` (default 3) so an
|
|
19
|
+
// auto-resume turn never pushes past the recovery turn cap. Revisit here if the
|
|
20
|
+
// unattended push proves too short or too long for headless runs.
|
|
21
|
+
export const UNATTENDED_MAX_AUTO_RESUMES = 2;
|
|
22
|
+
export class InteractiveBlockedRecoveryPendingTurnError extends Error {
|
|
23
|
+
pendingTurn;
|
|
24
|
+
constructor(pendingTurn) {
|
|
25
|
+
super(`Interactive blocked recovery already has unhandled operator guidance for turn ${pendingTurn}; resume the run before recording more guidance.`);
|
|
26
|
+
this.name = 'InteractiveBlockedRecoveryPendingTurnError';
|
|
27
|
+
this.pendingTurn = pendingTurn;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function getInteractiveBlockedRecoverySourcePhase(phase) {
|
|
31
|
+
switch (phase) {
|
|
32
|
+
case 'coder_plan':
|
|
33
|
+
case 'reviewer_plan':
|
|
34
|
+
case 'coder_plan_response':
|
|
35
|
+
case 'coder_plan_optional_response':
|
|
36
|
+
case 'awaiting_derived_plan_execution':
|
|
37
|
+
case 'coder_scope':
|
|
38
|
+
case 'reviewer_scope':
|
|
39
|
+
case 'coder_response':
|
|
40
|
+
case 'coder_optional_response':
|
|
41
|
+
case EXECUTE_FINALIZATION_PHASE:
|
|
42
|
+
case 'final_completion_review':
|
|
43
|
+
return phase;
|
|
44
|
+
default:
|
|
45
|
+
throw new Error(`Interactive blocked recovery does not support source phase: ${String(phase)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function isInteractiveBlockedRecoveryTopLevelMode(state) {
|
|
49
|
+
return state.topLevelMode === 'execute';
|
|
50
|
+
}
|
|
51
|
+
// The generalized adjudicator triages two block classes whose source phase is in
|
|
52
|
+
// `ADJUDICATOR_ELIGIBLE_SOURCE_PHASES`:
|
|
53
|
+
// - a coder-blocked signal (`coder_scope`/`coder_response`/`coder_optional_response`,
|
|
54
|
+
// which also carries the rerouted split-plan invalid-payload block): the coder
|
|
55
|
+
// emits free-text blockers with no structural prefix, so ANY coder block on these
|
|
56
|
+
// phases is eligible;
|
|
57
|
+
// - a reviewer `review_stuck` deadlock (`reviewer_scope`/`reviewer_plan`): only the
|
|
58
|
+
// structural `review_stuck:` reason the review adjudicator emits for a genuine
|
|
59
|
+
// deadlock is eligible. Ordinary blocking-finding blocks that reach recovery via a
|
|
60
|
+
// reviewer phase are normal review back-and-forth, NOT a deadlock, so they keep
|
|
61
|
+
// today's generic recovery behavior.
|
|
62
|
+
// Every other accepted source phase (`coder_plan`, `coder_plan_response`,
|
|
63
|
+
// `coder_plan_optional_response`, `awaiting_derived_plan_execution`,
|
|
64
|
+
// `execute_finalization`, `final_completion_review`) is ineligible and keeps today's
|
|
65
|
+
// generic recovery behavior with zero adjudicator invocations.
|
|
66
|
+
function isAdjudicatorEligibleBlock(reason, sourcePhase) {
|
|
67
|
+
if (!ADJUDICATOR_ELIGIBLE_SOURCE_PHASES.has(sourcePhase)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (isReviewerAdjudicatorPhase(sourcePhase)) {
|
|
71
|
+
return reason.startsWith('review_stuck:');
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
// Whether the per-scope adjudicator budget allows another invocation. Returns
|
|
76
|
+
// false when the disable knob is 0 or `reviewStuckArbiterCount` has reached the
|
|
77
|
+
// configured maximum for the current scope. Both the disabled and the
|
|
78
|
+
// budget-exhausted cases emit NO `review_stuck_arbiter.*` events so they preserve
|
|
79
|
+
// the generic recovery path byte-for-byte: a disabled or exhausted adjudicator
|
|
80
|
+
// must be indistinguishable from the adjudicator never having existed.
|
|
81
|
+
// The single uniform budget is shared by both run modes — an invocation
|
|
82
|
+
// consumes one unit whether it auto-acts (unattended) or only produces advice
|
|
83
|
+
// (attended) — and is reset to 0 at every scope boundary (see the scope-advance
|
|
84
|
+
// transitions and the split-plan persist) so one scope's adjudication never
|
|
85
|
+
// exhausts a later scope.
|
|
86
|
+
function isAdjudicatorBudgetAvailable(state) {
|
|
87
|
+
const maxAttempts = getReviewStuckArbiterMaxAttempts(state.cwd);
|
|
88
|
+
if (maxAttempts <= 0) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return state.reviewStuckArbiterCount < maxAttempts;
|
|
92
|
+
}
|
|
93
|
+
// Unattended interception for every eligible block class. Runs the read-only
|
|
94
|
+
// adjudicator (which may itself short-circuit on an anti-thrash repeat) and, on a
|
|
95
|
+
// recoverable verdict with a concrete in-scope directive, enters interactive
|
|
96
|
+
// recovery with that directive injected as the pending turn — bounded by a
|
|
97
|
+
// SEPARATE counter (`reviewStuckArbiterCount`) that never touches
|
|
98
|
+
// `unattendedAutoResumeCount` or the recovery turn cap. A non-recoverable verdict
|
|
99
|
+
// (including a thrash repeat) is finalized TERMINALLY rather than silently
|
|
100
|
+
// auto-resumed. Every gate that prevents the adjudicator from running — an
|
|
101
|
+
// ineligible source phase, the disabled/exhausted budget, the turn cap, or an
|
|
102
|
+
// adjudicator error — returns null so the caller falls through to the existing
|
|
103
|
+
// generic auto-resume / terminal-fail path with `recentBlocks` left unchanged.
|
|
104
|
+
// The adjudicator itself makes zero commits and zero file edits; this function is
|
|
105
|
+
// the sole writer of `recentBlocks`, and only on the branches where the
|
|
106
|
+
// adjudicator actually ran.
|
|
107
|
+
async function maybeResolveBlockedUnattended(state, statePath, reason, sourcePhase, nextRecovery, logger) {
|
|
108
|
+
if (!isAdjudicatorEligibleBlock(reason, sourcePhase)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (!isAdjudicatorBudgetAvailable(state)) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
// Never push past the recovery turn cap; if there is no room for a recovery
|
|
115
|
+
// turn, fall through to the generic bound check unchanged.
|
|
116
|
+
if (nextRecovery.turns.length >= nextRecovery.maxTurns) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
await logger?.event('review_stuck_arbiter.start', {
|
|
120
|
+
scopeNumber: state.currentScopeNumber,
|
|
121
|
+
sourcePhase,
|
|
122
|
+
blockedReason: reason,
|
|
123
|
+
});
|
|
124
|
+
let verdict;
|
|
125
|
+
try {
|
|
126
|
+
verdict = await runBlockedAdjudicator(state, reason, sourcePhase, logger);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
// An adjudicator failure must never crash the run or weaken existing recovery;
|
|
130
|
+
// record the decline and fall through to the generic path with `recentBlocks`
|
|
131
|
+
// unchanged (the adjudicator did not complete for this block).
|
|
132
|
+
await logger?.event('review_stuck_arbiter.declined', {
|
|
133
|
+
scopeNumber: state.currentScopeNumber,
|
|
134
|
+
sourcePhase,
|
|
135
|
+
blockedReason: reason,
|
|
136
|
+
error: error instanceof Error ? error.message : String(error),
|
|
137
|
+
});
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
// The adjudicator ran (possibly short-circuiting internally on a thrash repeat),
|
|
141
|
+
// so this block is recorded in the anti-thrash window regardless of the verdict.
|
|
142
|
+
// The candidate is built from the PRE-update array and written in this same
|
|
143
|
+
// transition, so a block can never match itself. It MUST be built from the same
|
|
144
|
+
// `state` snapshot `runBlockedAdjudicator` checked: the candidate's evidence
|
|
145
|
+
// fingerprint is derived from `state.createdCommits`, and a divergent snapshot
|
|
146
|
+
// would record a fingerprint the guard never compared against.
|
|
147
|
+
const candidate = buildRecentBlockCandidate(state, reason, sourcePhase);
|
|
148
|
+
const recentBlocks = upsertRecentBlock(state.recentBlocks, candidate);
|
|
149
|
+
const resolutionDirective = verdict.resolutionDirective.trim();
|
|
150
|
+
if (!verdict.recoverable || !resolutionDirective) {
|
|
151
|
+
// Genuine wall (recoverable:false) or a thrash repeat: an unattended run has
|
|
152
|
+
// no operator to escalate to, so finalize TERMINALLY instead of synthesizing a
|
|
153
|
+
// generic auto-resume. The adjudicator actually ran, so this branch consumes
|
|
154
|
+
// one unit of the shared per-scope budget (`reviewStuckArbiterCount`) exactly
|
|
155
|
+
// like the recoverable branch, and persists the anti-thrash record via the
|
|
156
|
+
// threaded `recentBlocks`. Only the fallback paths where `runBlockedAdjudicator`
|
|
157
|
+
// was never invoked leave the budget untouched.
|
|
158
|
+
await logger?.event('review_stuck_arbiter.declined', {
|
|
159
|
+
scopeNumber: state.currentScopeNumber,
|
|
160
|
+
sourcePhase,
|
|
161
|
+
blockedReason: reason,
|
|
162
|
+
recoverable: verdict.recoverable,
|
|
163
|
+
triageCategory: verdict.triageCategory,
|
|
164
|
+
reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1,
|
|
165
|
+
});
|
|
166
|
+
return failUnattendedRecoveryTerminally({ ...state, recentBlocks, reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1 }, statePath, 'terminal_block', state.coderSessionHandle, logger);
|
|
167
|
+
}
|
|
168
|
+
await logger?.event('review_stuck_arbiter.verdict', {
|
|
169
|
+
scopeNumber: state.currentScopeNumber,
|
|
170
|
+
sourcePhase,
|
|
171
|
+
blockedReason: reason,
|
|
172
|
+
recoverable: verdict.recoverable,
|
|
173
|
+
triageCategory: verdict.triageCategory,
|
|
174
|
+
targetCanonicalIds: verdict.targetCanonicalIds,
|
|
175
|
+
// Report the post-increment count this verdict is about to consume so the
|
|
176
|
+
// verdict and the later `resolved` event agree on the budget figure.
|
|
177
|
+
reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1,
|
|
178
|
+
});
|
|
179
|
+
// Enter recovery exactly as the generic unattended path does, but increment
|
|
180
|
+
// the SEPARATE adjudicator counter (never `unattendedAutoResumeCount`) and
|
|
181
|
+
// persist the anti-thrash record.
|
|
182
|
+
const enteredState = await saveState(statePath, {
|
|
183
|
+
...state,
|
|
184
|
+
recentBlocks,
|
|
185
|
+
phase: 'interactive_blocked_recovery',
|
|
186
|
+
status: 'running',
|
|
187
|
+
blockedFromPhase: state.blockedFromPhase ?? state.phase,
|
|
188
|
+
reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1,
|
|
189
|
+
interactiveBlockedRecovery: {
|
|
190
|
+
...nextRecovery,
|
|
191
|
+
sourcePhase,
|
|
192
|
+
blockedReason: reason,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
await writeExecutionArtifacts(enteredState);
|
|
196
|
+
await logger?.event('interactive_blocked_recovery.entered', {
|
|
197
|
+
scopeNumber: enteredState.currentScopeNumber,
|
|
198
|
+
sourcePhase: enteredState.interactiveBlockedRecovery?.sourcePhase,
|
|
199
|
+
blockedReason: reason,
|
|
200
|
+
});
|
|
201
|
+
// Inject the adjudicator's in-scope directive as the pending recovery turn so
|
|
202
|
+
// the coder consumes it in runInteractiveBlockedRecoveryPhase, exactly like a
|
|
203
|
+
// human-supplied `neal resume --message`.
|
|
204
|
+
const resolvedState = await recordInteractiveBlockedRecoveryGuidance(statePath, resolutionDirective, logger);
|
|
205
|
+
await logger?.event('review_stuck_arbiter.resolved', {
|
|
206
|
+
scopeNumber: resolvedState.currentScopeNumber,
|
|
207
|
+
sourcePhase,
|
|
208
|
+
blockedReason: reason,
|
|
209
|
+
recoverable: verdict.recoverable,
|
|
210
|
+
triageCategory: verdict.triageCategory,
|
|
211
|
+
targetCanonicalIds: verdict.targetCanonicalIds,
|
|
212
|
+
reviewStuckArbiterCount: resolvedState.reviewStuckArbiterCount,
|
|
213
|
+
});
|
|
214
|
+
return resolvedState;
|
|
215
|
+
}
|
|
216
|
+
// Attended interception for every eligible block class. The adjudicator NEVER
|
|
217
|
+
// auto-applies its verdict in attended mode; instead it triages read-only and the
|
|
218
|
+
// verdict is returned as advice (plus the updated anti-thrash window) for the
|
|
219
|
+
// caller to persist alongside the operator yield. Gated by the SAME eligibility +
|
|
220
|
+
// disable knob + per-scope budget as the unattended path; when any gate blocks the
|
|
221
|
+
// adjudicator (ineligible phase, knob 0, exhausted budget, or an adjudicator
|
|
222
|
+
// error) this returns null and the caller yields exactly as today with no advice,
|
|
223
|
+
// no budget consumption, and `recentBlocks` unchanged.
|
|
224
|
+
//
|
|
225
|
+
// The adjudicator runs BEFORE any `review_stuck_arbiter.*` event is emitted, so an
|
|
226
|
+
// adjudicator error degrades to today's plain attended yield with a byte-for-byte
|
|
227
|
+
// generic observable surface: zero adjudicator events, no advice, and no
|
|
228
|
+
// counter/`recentBlocks` mutation. The `start`/`verdict` audit pair is emitted only
|
|
229
|
+
// once the adjudicator has actually produced a verdict for this block.
|
|
230
|
+
async function buildAttendedAdjudicatorAdvice(state, reason, sourcePhase, logger) {
|
|
231
|
+
if (!isAdjudicatorEligibleBlock(reason, sourcePhase)) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
if (!isAdjudicatorBudgetAvailable(state)) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
let verdict;
|
|
238
|
+
try {
|
|
239
|
+
verdict = await runBlockedAdjudicator(state, reason, sourcePhase, logger);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Degrade to today's plain attended yield: never crash the run, and emit NO
|
|
243
|
+
// `review_stuck_arbiter.*` events so the fallback is indistinguishable from the
|
|
244
|
+
// disabled/exhausted/ineligible generic yield.
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
await logger?.event('review_stuck_arbiter.start', {
|
|
248
|
+
scopeNumber: state.currentScopeNumber,
|
|
249
|
+
sourcePhase,
|
|
250
|
+
blockedReason: reason,
|
|
251
|
+
});
|
|
252
|
+
await logger?.event('review_stuck_arbiter.verdict', {
|
|
253
|
+
scopeNumber: state.currentScopeNumber,
|
|
254
|
+
sourcePhase,
|
|
255
|
+
blockedReason: reason,
|
|
256
|
+
recoverable: verdict.recoverable,
|
|
257
|
+
triageCategory: verdict.triageCategory,
|
|
258
|
+
targetCanonicalIds: verdict.targetCanonicalIds,
|
|
259
|
+
reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1,
|
|
260
|
+
});
|
|
261
|
+
// Same-snapshot rule as the unattended writer: the recorded candidate's
|
|
262
|
+
// commit-trail evidence fingerprint must come from the `state` the adjudicator
|
|
263
|
+
// just checked, never a fresher snapshot.
|
|
264
|
+
const candidate = buildRecentBlockCandidate(state, reason, sourcePhase);
|
|
265
|
+
const recentBlocks = upsertRecentBlock(state.recentBlocks, candidate);
|
|
266
|
+
const advice = {
|
|
267
|
+
recordedAt: new Date().toISOString(),
|
|
268
|
+
recoverable: verdict.recoverable,
|
|
269
|
+
triageCategory: verdict.triageCategory,
|
|
270
|
+
resolutionDirective: verdict.resolutionDirective,
|
|
271
|
+
rationale: verdict.rationale,
|
|
272
|
+
};
|
|
273
|
+
return { advice, recentBlocks };
|
|
274
|
+
}
|
|
275
|
+
export async function enterInteractiveBlockedRecovery(state, statePath, reason, logger) {
|
|
276
|
+
if (!isInteractiveBlockedRecoveryTopLevelMode(state)) {
|
|
277
|
+
throw new Error('Interactive blocked recovery is only supported for execute-mode runs');
|
|
278
|
+
}
|
|
279
|
+
const sourcePhase = getInteractiveBlockedRecoverySourcePhase(state.blockedFromPhase ?? state.phase);
|
|
280
|
+
const nextRecovery = state.interactiveBlockedRecovery ?? {
|
|
281
|
+
enteredAt: new Date().toISOString(),
|
|
282
|
+
sourcePhase,
|
|
283
|
+
blockedReason: reason,
|
|
284
|
+
// Keep the operator/coder loop short so recovery remains bounded and auditable.
|
|
285
|
+
maxTurns: getInteractiveBlockedRecoveryMaxTurns(state.cwd),
|
|
286
|
+
lastHandledTurn: 0,
|
|
287
|
+
pendingDirective: null,
|
|
288
|
+
turns: [],
|
|
289
|
+
};
|
|
290
|
+
// Unattended runs have no operator to answer, so instead of yielding-and-halting
|
|
291
|
+
// we either synthesize a conservative auto-resume turn (bounded) or, past the
|
|
292
|
+
// bound, run the shared terminal-fail action. Gate purely on structural state:
|
|
293
|
+
// the persisted counter and the recovery turn cap, never on guidance text.
|
|
294
|
+
if (state.unattended) {
|
|
295
|
+
// Before the generic auto-resume/terminal-fail decision, give the bounded
|
|
296
|
+
// read-only adjudicator a chance to triage the block: autonomously resolve it
|
|
297
|
+
// with an in-scope directive (recoverable) or finalize terminally (a genuine
|
|
298
|
+
// wall or thrash repeat). Any ineligible source phase, disabled/exhausted
|
|
299
|
+
// budget, turn cap, or adjudicator error falls through to the existing generic
|
|
300
|
+
// behavior unchanged.
|
|
301
|
+
const adjudicatorResolved = await maybeResolveBlockedUnattended(state, statePath, reason, sourcePhase, nextRecovery, logger);
|
|
302
|
+
if (adjudicatorResolved) {
|
|
303
|
+
return adjudicatorResolved;
|
|
304
|
+
}
|
|
305
|
+
const canAutoResume = state.unattendedAutoResumeCount < UNATTENDED_MAX_AUTO_RESUMES &&
|
|
306
|
+
nextRecovery.turns.length < nextRecovery.maxTurns;
|
|
307
|
+
if (!canAutoResume) {
|
|
308
|
+
// Past the bound, finalize any active recovery record into history and land
|
|
309
|
+
// on a terminal failed shape (status:'failed', phase:'blocked',
|
|
310
|
+
// interactiveBlockedRecovery:null) so the run is never persisted as an
|
|
311
|
+
// active/waiting recovery. Reuses the same finalizer as the disposition
|
|
312
|
+
// terminal-fail paths.
|
|
313
|
+
return failUnattendedRecoveryTerminally(state, statePath, 'terminal_block', state.coderSessionHandle, logger);
|
|
314
|
+
}
|
|
315
|
+
const enteredState = await saveState(statePath, {
|
|
316
|
+
...state,
|
|
317
|
+
phase: 'interactive_blocked_recovery',
|
|
318
|
+
status: 'running',
|
|
319
|
+
blockedFromPhase: state.blockedFromPhase ?? state.phase,
|
|
320
|
+
unattendedAutoResumeCount: state.unattendedAutoResumeCount + 1,
|
|
321
|
+
interactiveBlockedRecovery: {
|
|
322
|
+
...nextRecovery,
|
|
323
|
+
sourcePhase,
|
|
324
|
+
blockedReason: reason,
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
await writeExecutionArtifacts(enteredState);
|
|
328
|
+
await logger?.event('interactive_blocked_recovery.entered', {
|
|
329
|
+
scopeNumber: enteredState.currentScopeNumber,
|
|
330
|
+
sourcePhase: enteredState.interactiveBlockedRecovery?.sourcePhase,
|
|
331
|
+
blockedReason: reason,
|
|
332
|
+
});
|
|
333
|
+
await logger?.event('interactive_blocked_recovery.unattended_auto_resume', {
|
|
334
|
+
scopeNumber: enteredState.currentScopeNumber,
|
|
335
|
+
sourcePhase,
|
|
336
|
+
autoResumeCount: enteredState.unattendedAutoResumeCount,
|
|
337
|
+
maxAutoResumes: UNATTENDED_MAX_AUTO_RESUMES,
|
|
338
|
+
});
|
|
339
|
+
// Reuse the turn-recording helper so the synthesized guidance turn satisfies
|
|
340
|
+
// the same invariants a human-supplied `neal resume --message` would; the run
|
|
341
|
+
// loop then proceeds into runInteractiveBlockedRecoveryPhase to consume it.
|
|
342
|
+
return recordInteractiveBlockedRecoveryGuidance(statePath, UNATTENDED_AUTO_RESUME_GUIDANCE, logger);
|
|
343
|
+
}
|
|
344
|
+
// Attended runs still stop and wait for the operator; the adjudicator only adds
|
|
345
|
+
// its verdict as advice (and never auto-applies it). When the disable knob,
|
|
346
|
+
// budget, or eligibility gate the adjudicator off, this returns null and the run
|
|
347
|
+
// yields exactly as today with no advice, no budget consumption, and
|
|
348
|
+
// `recentBlocks` unchanged.
|
|
349
|
+
const advisory = await buildAttendedAdjudicatorAdvice(state, reason, sourcePhase, logger);
|
|
350
|
+
const nextState = await saveState(statePath, {
|
|
351
|
+
...state,
|
|
352
|
+
...(advisory
|
|
353
|
+
? { recentBlocks: advisory.recentBlocks, reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1 }
|
|
354
|
+
: {}),
|
|
355
|
+
phase: 'interactive_blocked_recovery',
|
|
356
|
+
status: 'running',
|
|
357
|
+
blockedFromPhase: state.blockedFromPhase ?? state.phase,
|
|
358
|
+
interactiveBlockedRecovery: {
|
|
359
|
+
...nextRecovery,
|
|
360
|
+
sourcePhase,
|
|
361
|
+
blockedReason: reason,
|
|
362
|
+
...(advisory ? { adjudicatorAdvice: advisory.advice } : {}),
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
await writeExecutionArtifacts(nextState);
|
|
366
|
+
await logger?.event('interactive_blocked_recovery.entered', {
|
|
367
|
+
scopeNumber: nextState.currentScopeNumber,
|
|
368
|
+
sourcePhase: nextState.interactiveBlockedRecovery?.sourcePhase,
|
|
369
|
+
blockedReason: reason,
|
|
370
|
+
});
|
|
371
|
+
return nextState;
|
|
372
|
+
}
|
|
373
|
+
// Whether a caller of `enterInteractiveBlockedRecovery` should emit an attended
|
|
374
|
+
// blocked / interactive-recovery notification for the returned state. Only
|
|
375
|
+
// attended runs leave a pending operator block here; unattended runs instead
|
|
376
|
+
// auto-resume (status:'running' with synthesized guidance) or terminally fail
|
|
377
|
+
// (status:'failed', phase:'blocked'), neither of which warrants an attended
|
|
378
|
+
// notification. Gate structurally on the persisted flag and resulting phase,
|
|
379
|
+
// never on text.
|
|
380
|
+
export function shouldNotifyInteractiveBlockedRecoveryEntry(state) {
|
|
381
|
+
return !state.unattended && state.phase === 'interactive_blocked_recovery';
|
|
382
|
+
}
|
|
383
|
+
export async function recordInteractiveBlockedRecoveryGuidance(statePath, operatorGuidance, logger) {
|
|
384
|
+
const trimmedGuidance = operatorGuidance.trim();
|
|
385
|
+
if (!trimmedGuidance) {
|
|
386
|
+
throw new Error('Recovery guidance must not be empty');
|
|
387
|
+
}
|
|
388
|
+
const state = await loadState(statePath);
|
|
389
|
+
if (state.phase !== 'interactive_blocked_recovery' || !state.interactiveBlockedRecovery) {
|
|
390
|
+
throw new Error(`Run is not in interactive blocked recovery: ${statePath}`);
|
|
391
|
+
}
|
|
392
|
+
const turns = state.interactiveBlockedRecovery.turns;
|
|
393
|
+
if (state.interactiveBlockedRecovery.pendingDirective) {
|
|
394
|
+
throw new InteractiveBlockedRecoveryPendingTurnError(turns.length + 1);
|
|
395
|
+
}
|
|
396
|
+
const pendingTurn = turns.at(-1);
|
|
397
|
+
if (pendingTurn && pendingTurn.number > state.interactiveBlockedRecovery.lastHandledTurn) {
|
|
398
|
+
throw new InteractiveBlockedRecoveryPendingTurnError(pendingTurn.number);
|
|
399
|
+
}
|
|
400
|
+
if (turns.length >= state.interactiveBlockedRecovery.maxTurns) {
|
|
401
|
+
const nextState = await saveState(statePath, {
|
|
402
|
+
...state,
|
|
403
|
+
interactiveBlockedRecovery: {
|
|
404
|
+
...state.interactiveBlockedRecovery,
|
|
405
|
+
pendingDirective: {
|
|
406
|
+
recordedAt: new Date().toISOString(),
|
|
407
|
+
operatorGuidance: trimmedGuidance,
|
|
408
|
+
terminalOnly: true,
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
await writeExecutionArtifacts(nextState);
|
|
413
|
+
await logger?.event('interactive_blocked_recovery.terminal_directive_recorded', {
|
|
414
|
+
scopeNumber: nextState.currentScopeNumber,
|
|
415
|
+
sourcePhase: nextState.interactiveBlockedRecovery?.sourcePhase,
|
|
416
|
+
recoveryTurn: turns.length,
|
|
417
|
+
});
|
|
418
|
+
return nextState;
|
|
419
|
+
}
|
|
420
|
+
const nextState = await saveState(statePath, {
|
|
421
|
+
...state,
|
|
422
|
+
interactiveBlockedRecovery: {
|
|
423
|
+
...state.interactiveBlockedRecovery,
|
|
424
|
+
turns: [
|
|
425
|
+
...turns,
|
|
426
|
+
{
|
|
427
|
+
number: turns.length + 1,
|
|
428
|
+
recordedAt: new Date().toISOString(),
|
|
429
|
+
operatorGuidance: trimmedGuidance,
|
|
430
|
+
disposition: null,
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
await writeExecutionArtifacts(nextState);
|
|
436
|
+
await logger?.event('interactive_blocked_recovery.guidance_recorded', {
|
|
437
|
+
scopeNumber: nextState.currentScopeNumber,
|
|
438
|
+
recoveryTurn: nextState.interactiveBlockedRecovery?.turns.at(-1)?.number,
|
|
439
|
+
sourcePhase: nextState.interactiveBlockedRecovery?.sourcePhase,
|
|
440
|
+
});
|
|
441
|
+
return nextState;
|
|
442
|
+
}
|
|
443
|
+
export function isResumableBlockedPhase(phase) {
|
|
444
|
+
return (phase === 'coder_scope' ||
|
|
445
|
+
phase === 'coder_response' ||
|
|
446
|
+
phase === 'coder_optional_response' ||
|
|
447
|
+
phase === 'coder_plan' ||
|
|
448
|
+
phase === 'coder_plan_response' ||
|
|
449
|
+
phase === 'coder_plan_optional_response');
|
|
450
|
+
}
|
|
451
|
+
export function hasPendingInteractiveBlockedRecoveryTurn(state) {
|
|
452
|
+
return hasPendingOperatorGuidance(state);
|
|
453
|
+
}
|
|
454
|
+
function withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, sessionHandle, resultingPhase) {
|
|
455
|
+
if (!state.interactiveBlockedRecovery) {
|
|
456
|
+
return state;
|
|
457
|
+
}
|
|
458
|
+
if (state.interactiveBlockedRecovery.pendingDirective) {
|
|
459
|
+
return {
|
|
460
|
+
...state,
|
|
461
|
+
interactiveBlockedRecovery: {
|
|
462
|
+
...state.interactiveBlockedRecovery,
|
|
463
|
+
pendingDirective: null,
|
|
464
|
+
turns: [
|
|
465
|
+
...state.interactiveBlockedRecovery.turns,
|
|
466
|
+
{
|
|
467
|
+
number: state.interactiveBlockedRecovery.turns.length + 1,
|
|
468
|
+
recordedAt: state.interactiveBlockedRecovery.pendingDirective.recordedAt,
|
|
469
|
+
operatorGuidance: state.interactiveBlockedRecovery.pendingDirective.operatorGuidance,
|
|
470
|
+
disposition: {
|
|
471
|
+
recordedAt: new Date().toISOString(),
|
|
472
|
+
sessionHandle,
|
|
473
|
+
action: disposition.action,
|
|
474
|
+
summary: disposition.summary,
|
|
475
|
+
rationale: disposition.rationale,
|
|
476
|
+
blocker: disposition.blocker.trim(),
|
|
477
|
+
replacementPlan: disposition.replacementPlan.trim(),
|
|
478
|
+
resultingPhase,
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const latestTurn = state.interactiveBlockedRecovery.turns.at(-1);
|
|
486
|
+
if (!latestTurn) {
|
|
487
|
+
return state;
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
...state,
|
|
491
|
+
interactiveBlockedRecovery: {
|
|
492
|
+
...state.interactiveBlockedRecovery,
|
|
493
|
+
turns: state.interactiveBlockedRecovery.turns.map((turn) => turn.number === latestTurn.number
|
|
494
|
+
? {
|
|
495
|
+
...turn,
|
|
496
|
+
disposition: {
|
|
497
|
+
recordedAt: new Date().toISOString(),
|
|
498
|
+
sessionHandle,
|
|
499
|
+
action: disposition.action,
|
|
500
|
+
summary: disposition.summary,
|
|
501
|
+
rationale: disposition.rationale,
|
|
502
|
+
blocker: disposition.blocker.trim(),
|
|
503
|
+
replacementPlan: disposition.replacementPlan.trim(),
|
|
504
|
+
resultingPhase,
|
|
505
|
+
},
|
|
506
|
+
}
|
|
507
|
+
: turn),
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function finalizeInteractiveBlockedRecovery(state, action, resultPhase) {
|
|
512
|
+
if (!state.interactiveBlockedRecovery) {
|
|
513
|
+
return state;
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
...state,
|
|
517
|
+
interactiveBlockedRecovery: null,
|
|
518
|
+
interactiveBlockedRecoveryHistory: [
|
|
519
|
+
...state.interactiveBlockedRecoveryHistory,
|
|
520
|
+
{
|
|
521
|
+
...state.interactiveBlockedRecovery,
|
|
522
|
+
pendingDirective: null,
|
|
523
|
+
resolvedAt: new Date().toISOString(),
|
|
524
|
+
resolvedByAction: action,
|
|
525
|
+
resultPhase,
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
async function persistFinalizedInteractiveBlockedRecovery(state, statePath, disposition, sessionHandle, resultPhase) {
|
|
531
|
+
const finalizedState = finalizeInteractiveBlockedRecovery(withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, sessionHandle, resultPhase), disposition.action, resultPhase);
|
|
532
|
+
const nextState = await saveState(statePath, {
|
|
533
|
+
...finalizedState,
|
|
534
|
+
phase: resultPhase,
|
|
535
|
+
status: resultPhase === 'blocked' ? 'blocked' : 'running',
|
|
536
|
+
blockedFromPhase: resultPhase === 'blocked' ? state.interactiveBlockedRecovery?.sourcePhase ?? state.blockedFromPhase : null,
|
|
537
|
+
});
|
|
538
|
+
await writeExecutionArtifacts(nextState);
|
|
539
|
+
return nextState;
|
|
540
|
+
}
|
|
541
|
+
function getInteractiveBlockedRecoveryResumePhase(sourcePhase) {
|
|
542
|
+
switch (sourcePhase) {
|
|
543
|
+
case 'reviewer_scope':
|
|
544
|
+
return 'coder_response';
|
|
545
|
+
case 'reviewer_plan':
|
|
546
|
+
return 'coder_plan_response';
|
|
547
|
+
case 'awaiting_derived_plan_execution':
|
|
548
|
+
return 'coder_scope';
|
|
549
|
+
default:
|
|
550
|
+
return sourcePhase;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// Shared unattended terminal-fail for a recovery disposition. The active
|
|
554
|
+
// recovery record is finalized into history (so `interactiveBlockedRecovery`
|
|
555
|
+
// becomes null and the lifecycle view is no longer "active"/waiting — required
|
|
556
|
+
// because the state invariant ties a non-null record to the recovery phase),
|
|
557
|
+
// the run lands on the recovery's source phase for diagnostics, and the shared
|
|
558
|
+
// classified terminal-fail action runs (status:'failed', no `notifyBlocked`).
|
|
559
|
+
// `state` must carry the disposition already recorded on its latest turn.
|
|
560
|
+
async function failUnattendedRecoveryTerminally(state, statePath, action, sessionHandle, logger) {
|
|
561
|
+
const recovery = state.interactiveBlockedRecovery;
|
|
562
|
+
const sourcePhase = recovery?.sourcePhase ?? state.blockedFromPhase ?? state.phase;
|
|
563
|
+
// The history record's resultPhase must satisfy the disposition-result invariant
|
|
564
|
+
// (stay_blocked -> recovery, terminal_block/replace -> blocked); the run itself
|
|
565
|
+
// lands on the terminal `blocked` phase with status:'failed', which keeps the
|
|
566
|
+
// lifecycle view out of any waiting/active recovery state.
|
|
567
|
+
const historyResultPhase = action === 'stay_blocked' ? 'interactive_blocked_recovery' : 'blocked';
|
|
568
|
+
await logger?.event('interactive_blocked_recovery.unattended_terminal_fail', {
|
|
569
|
+
scopeNumber: state.currentScopeNumber,
|
|
570
|
+
sourcePhase,
|
|
571
|
+
autoResumeCount: state.unattendedAutoResumeCount,
|
|
572
|
+
maxAutoResumes: UNATTENDED_MAX_AUTO_RESUMES,
|
|
573
|
+
});
|
|
574
|
+
const finalized = recovery ? finalizeInteractiveBlockedRecovery(state, action, historyResultPhase) : state;
|
|
575
|
+
return persistUnattendedBlockUnresolvedFailure({
|
|
576
|
+
...finalized,
|
|
577
|
+
phase: 'blocked',
|
|
578
|
+
blockedFromPhase: sourcePhase,
|
|
579
|
+
coderSessionHandle: sessionHandle,
|
|
580
|
+
coderSessionProtocol: sessionHandle ? state.coderSessionProtocol : null,
|
|
581
|
+
coderRetryCount: 0,
|
|
582
|
+
}, statePath, 'interactive_blocked_recovery', logger);
|
|
583
|
+
}
|
|
584
|
+
// Under `unattended`, a recovery disposition that would otherwise leave the run
|
|
585
|
+
// waiting for an operator (`stay_blocked`) is resolved structurally: synthesize
|
|
586
|
+
// another conservative auto-resume turn while still under the persisted
|
|
587
|
+
// auto-resume cap and the recovery turn cap, otherwise run the shared
|
|
588
|
+
// terminal-fail action. `state` must already be persisted in
|
|
589
|
+
// `interactive_blocked_recovery` with its recovery record holding the handled
|
|
590
|
+
// turns. Gates only on the persisted counter and turn cap, never on text.
|
|
591
|
+
async function continueOrTerminateUnattendedRecovery(state, statePath, sessionHandle, logger) {
|
|
592
|
+
const recovery = state.interactiveBlockedRecovery;
|
|
593
|
+
const canAutoResume = !!recovery &&
|
|
594
|
+
state.unattendedAutoResumeCount < UNATTENDED_MAX_AUTO_RESUMES &&
|
|
595
|
+
recovery.turns.length < recovery.maxTurns;
|
|
596
|
+
if (!canAutoResume) {
|
|
597
|
+
return failUnattendedRecoveryTerminally(state, statePath, 'stay_blocked', sessionHandle, logger);
|
|
598
|
+
}
|
|
599
|
+
const incremented = await saveState(statePath, {
|
|
600
|
+
...state,
|
|
601
|
+
unattendedAutoResumeCount: state.unattendedAutoResumeCount + 1,
|
|
602
|
+
});
|
|
603
|
+
await writeExecutionArtifacts(incremented);
|
|
604
|
+
await logger?.event('interactive_blocked_recovery.unattended_auto_resume', {
|
|
605
|
+
scopeNumber: incremented.currentScopeNumber,
|
|
606
|
+
sourcePhase: recovery.sourcePhase,
|
|
607
|
+
autoResumeCount: incremented.unattendedAutoResumeCount,
|
|
608
|
+
maxAutoResumes: UNATTENDED_MAX_AUTO_RESUMES,
|
|
609
|
+
});
|
|
610
|
+
return recordInteractiveBlockedRecoveryGuidance(statePath, UNATTENDED_AUTO_RESUME_GUIDANCE, logger);
|
|
611
|
+
}
|
|
612
|
+
export async function applyInteractiveBlockedRecoveryDisposition(state, statePath, disposition, sessionHandle, logger) {
|
|
613
|
+
if (state.phase !== 'interactive_blocked_recovery' || !state.interactiveBlockedRecovery) {
|
|
614
|
+
throw new Error(`Run is not in interactive blocked recovery: ${statePath}`);
|
|
615
|
+
}
|
|
616
|
+
if (!isInteractiveBlockedRecoveryTopLevelMode(state)) {
|
|
617
|
+
throw new Error('Interactive blocked recovery is only supported for execute-mode runs');
|
|
618
|
+
}
|
|
619
|
+
const latestTurn = state.interactiveBlockedRecovery.turns.at(-1);
|
|
620
|
+
const terminalDirective = state.interactiveBlockedRecovery.pendingDirective;
|
|
621
|
+
if (!latestTurn && !terminalDirective) {
|
|
622
|
+
throw new Error('Interactive blocked recovery requires recorded operator guidance before a coder response can be applied.');
|
|
623
|
+
}
|
|
624
|
+
if (terminalDirective &&
|
|
625
|
+
disposition.action !== 'replace_current_scope' &&
|
|
626
|
+
disposition.action !== 'terminal_block') {
|
|
627
|
+
throw new Error('Interactive blocked recovery reached its turn cap and now only allows replace_current_scope or terminal_block.');
|
|
628
|
+
}
|
|
629
|
+
const turnNumber = latestTurn?.number ?? state.interactiveBlockedRecovery.turns.length + 1;
|
|
630
|
+
const trimmedBlocker = disposition.blocker.trim();
|
|
631
|
+
await logger?.event('interactive_blocked_recovery.disposition', {
|
|
632
|
+
scopeNumber: state.currentScopeNumber,
|
|
633
|
+
recoveryTurn: turnNumber,
|
|
634
|
+
sourcePhase: state.interactiveBlockedRecovery.sourcePhase,
|
|
635
|
+
action: disposition.action,
|
|
636
|
+
sessionHandle,
|
|
637
|
+
});
|
|
638
|
+
if (disposition.action === 'replace_current_scope') {
|
|
639
|
+
const persistedState = await persistSplitPlanRecovery({
|
|
640
|
+
...state,
|
|
641
|
+
coderSessionHandle: sessionHandle,
|
|
642
|
+
coderSessionProtocol: sessionHandle ? state.coderSessionProtocol : null,
|
|
643
|
+
status: 'running',
|
|
644
|
+
coderRetryCount: 0,
|
|
645
|
+
}, statePath, {
|
|
646
|
+
sourcePhase: state.interactiveBlockedRecovery.sourcePhase,
|
|
647
|
+
derivedPlanMarkdown: disposition.replacementPlan.trim(),
|
|
648
|
+
createdCommits: [],
|
|
649
|
+
logger,
|
|
650
|
+
}, {
|
|
651
|
+
persistBlockedScope,
|
|
652
|
+
writeExecutionArtifacts,
|
|
653
|
+
});
|
|
654
|
+
const resultPhase = persistedState.phase === 'blocked' ? 'blocked' : 'reviewer_plan';
|
|
655
|
+
if (state.unattended && resultPhase === 'blocked') {
|
|
656
|
+
// An unattended replacement that could not produce a runnable plan must not
|
|
657
|
+
// leave the run resumable-blocked; finalize recovery and fail cleanly.
|
|
658
|
+
return failUnattendedRecoveryTerminally({
|
|
659
|
+
...persistedState,
|
|
660
|
+
interactiveBlockedRecovery: withRecordedInteractiveBlockedRecoveryDisposition({ ...persistedState, interactiveBlockedRecovery: state.interactiveBlockedRecovery }, disposition, sessionHandle, 'blocked').interactiveBlockedRecovery,
|
|
661
|
+
}, statePath, 'replace_current_scope', sessionHandle, logger);
|
|
662
|
+
}
|
|
663
|
+
return persistFinalizedInteractiveBlockedRecovery({
|
|
664
|
+
...persistedState,
|
|
665
|
+
interactiveBlockedRecovery: state.interactiveBlockedRecovery,
|
|
666
|
+
}, statePath, disposition, sessionHandle, resultPhase);
|
|
667
|
+
}
|
|
668
|
+
if (disposition.action === 'resume_current_scope') {
|
|
669
|
+
const resumedPhase = getInteractiveBlockedRecoveryResumePhase(state.interactiveBlockedRecovery.sourcePhase);
|
|
670
|
+
const finalizedState = await persistFinalizedInteractiveBlockedRecovery(state, statePath, disposition, sessionHandle, resumedPhase);
|
|
671
|
+
const nextState = await saveState(statePath, {
|
|
672
|
+
...finalizedState,
|
|
673
|
+
coderSessionHandle: sessionHandle,
|
|
674
|
+
coderSessionProtocol: sessionHandle ? state.coderSessionProtocol : null,
|
|
675
|
+
phase: resumedPhase,
|
|
676
|
+
status: 'running',
|
|
677
|
+
blockedFromPhase: null,
|
|
678
|
+
coderRetryCount: 0,
|
|
679
|
+
});
|
|
680
|
+
await writeExecutionArtifacts(nextState);
|
|
681
|
+
return nextState;
|
|
682
|
+
}
|
|
683
|
+
if (disposition.action === 'stay_blocked') {
|
|
684
|
+
const recordedState = withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, sessionHandle, 'interactive_blocked_recovery');
|
|
685
|
+
if (!recordedState.interactiveBlockedRecovery) {
|
|
686
|
+
throw new Error('Interactive blocked recovery state disappeared while recording a stay_blocked disposition.');
|
|
687
|
+
}
|
|
688
|
+
const nextState = await saveState(statePath, {
|
|
689
|
+
...recordedState,
|
|
690
|
+
coderSessionHandle: sessionHandle,
|
|
691
|
+
coderSessionProtocol: sessionHandle ? state.coderSessionProtocol : null,
|
|
692
|
+
phase: 'interactive_blocked_recovery',
|
|
693
|
+
status: 'running',
|
|
694
|
+
blockedFromPhase: state.interactiveBlockedRecovery.sourcePhase,
|
|
695
|
+
interactiveBlockedRecovery: {
|
|
696
|
+
...recordedState.interactiveBlockedRecovery,
|
|
697
|
+
blockedReason: trimmedBlocker,
|
|
698
|
+
lastHandledTurn: turnNumber,
|
|
699
|
+
pendingDirective: null,
|
|
700
|
+
},
|
|
701
|
+
coderRetryCount: 0,
|
|
702
|
+
});
|
|
703
|
+
await writeExecutionArtifacts(nextState);
|
|
704
|
+
if (nextState.unattended) {
|
|
705
|
+
// No operator will answer a stay_blocked: synthesize another conservative
|
|
706
|
+
// auto-resume turn under the bounds, or run the shared terminal-fail action.
|
|
707
|
+
return continueOrTerminateUnattendedRecovery(nextState, statePath, sessionHandle, logger);
|
|
708
|
+
}
|
|
709
|
+
return nextState;
|
|
710
|
+
}
|
|
711
|
+
const terminalBlockedState = isActivePendingDerivedPlanReview(state)
|
|
712
|
+
? {
|
|
713
|
+
...state,
|
|
714
|
+
derivedPlanStatus: 'rejected',
|
|
715
|
+
derivedScopeIndex: null,
|
|
716
|
+
}
|
|
717
|
+
: state;
|
|
718
|
+
if (state.unattended) {
|
|
719
|
+
// Unattended terminal_block must not take the attended blocked + notifyBlocked
|
|
720
|
+
// path; record the coder's terminal decision for diagnostics, then run the
|
|
721
|
+
// shared classified terminal-fail action (status:'failed', no notifyBlocked).
|
|
722
|
+
const recordedTerminalState = withRecordedInteractiveBlockedRecoveryDisposition(terminalBlockedState, disposition, sessionHandle, 'blocked');
|
|
723
|
+
return failUnattendedRecoveryTerminally(recordedTerminalState, statePath, 'terminal_block', sessionHandle, logger);
|
|
724
|
+
}
|
|
725
|
+
const finalizedBlockedState = await persistFinalizedInteractiveBlockedRecovery(terminalBlockedState, statePath, disposition, sessionHandle, 'blocked');
|
|
726
|
+
const blockedState = await saveState(statePath, {
|
|
727
|
+
...finalizedBlockedState,
|
|
728
|
+
coderSessionHandle: sessionHandle,
|
|
729
|
+
coderSessionProtocol: sessionHandle ? state.coderSessionProtocol : null,
|
|
730
|
+
phase: 'blocked',
|
|
731
|
+
status: 'blocked',
|
|
732
|
+
lastScopeMarker: state.lastScopeMarker ?? 'AUTONOMY_BLOCKED',
|
|
733
|
+
blockedFromPhase: state.interactiveBlockedRecovery.sourcePhase,
|
|
734
|
+
coderRetryCount: 0,
|
|
735
|
+
});
|
|
736
|
+
await writeExecutionArtifacts(blockedState);
|
|
737
|
+
const persistedState = await persistBlockedScope(blockedState, statePath, trimmedBlocker);
|
|
738
|
+
await notifyBlocked(persistedState, trimmedBlocker, logger);
|
|
739
|
+
return flushDerivedPlanNotifications(persistedState, statePath, logger, trimmedBlocker);
|
|
740
|
+
}
|
|
741
|
+
export async function runInteractiveBlockedRecoveryPhase(state, statePath, logger) {
|
|
742
|
+
if (!state.interactiveBlockedRecovery) {
|
|
743
|
+
throw new Error('Cannot run interactive blocked recovery without blocked-recovery state');
|
|
744
|
+
}
|
|
745
|
+
const latestTurn = state.interactiveBlockedRecovery.turns.at(-1);
|
|
746
|
+
const pendingDirective = state.interactiveBlockedRecovery.pendingDirective;
|
|
747
|
+
const hasPendingTurn = Boolean(latestTurn && latestTurn.number > state.interactiveBlockedRecovery.lastHandledTurn);
|
|
748
|
+
if (!hasPendingTurn && !pendingDirective) {
|
|
749
|
+
throw new Error('Interactive blocked recovery has no pending operator guidance to process.');
|
|
750
|
+
}
|
|
751
|
+
await logger?.event('phase.start', {
|
|
752
|
+
phase: 'interactive_blocked_recovery',
|
|
753
|
+
recoveryTurn: pendingDirective ? state.interactiveBlockedRecovery.turns.length : latestTurn?.number,
|
|
754
|
+
sourcePhase: state.interactiveBlockedRecovery.sourcePhase,
|
|
755
|
+
terminalOnly: Boolean(pendingDirective?.terminalOnly),
|
|
756
|
+
});
|
|
757
|
+
let codex;
|
|
758
|
+
try {
|
|
759
|
+
codex = await runBlockedRecoveryCoderRound({
|
|
760
|
+
coder: state.agentConfig.coder,
|
|
761
|
+
cwd: state.cwd,
|
|
762
|
+
planDoc: getExecutionPlanPath(state),
|
|
763
|
+
progressMarkdownPath: state.progressMarkdownPath,
|
|
764
|
+
recoveryMarkdownPath: state.recoveryMarkdownPath,
|
|
765
|
+
blockedReason: state.interactiveBlockedRecovery.blockedReason,
|
|
766
|
+
operatorGuidance: pendingDirective?.operatorGuidance ?? latestTurn?.operatorGuidance ?? state.interactiveBlockedRecovery.blockedReason,
|
|
767
|
+
maxTurns: state.interactiveBlockedRecovery.maxTurns,
|
|
768
|
+
turnsTaken: pendingDirective ? state.interactiveBlockedRecovery.turns.length : latestTurn?.number ?? 0,
|
|
769
|
+
terminalOnly: Boolean(pendingDirective?.terminalOnly),
|
|
770
|
+
allowReplacement: true,
|
|
771
|
+
sessionHandle: state.coderSessionHandle,
|
|
772
|
+
logger,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
if (error instanceof CoderRoundError) {
|
|
777
|
+
if (shouldRetryCoderWithFreshSession(state, 'interactive_blocked_recovery', error)) {
|
|
778
|
+
return scheduleCoderFreshSessionRetry(state, statePath, 'interactive_blocked_recovery', error, logger);
|
|
779
|
+
}
|
|
780
|
+
if (isCoderTimeoutError(error)) {
|
|
781
|
+
await bestEffortCleanupTimedOutCoder(error.sessionHandle ?? state.coderSessionHandle, logger);
|
|
782
|
+
}
|
|
783
|
+
const failedState = await persistCoderFailureState(state, statePath, 'interactive_blocked_recovery', error, logger);
|
|
784
|
+
if (shouldNotifyFailure(error)) {
|
|
785
|
+
await notifyBlocked(failedState, error.message, logger);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
throw error;
|
|
789
|
+
}
|
|
790
|
+
const nextState = await applyInteractiveBlockedRecoveryDisposition(state, statePath, codex.payload, codex.sessionHandle, logger);
|
|
791
|
+
await logger?.event('phase.complete', {
|
|
792
|
+
phase: 'interactive_blocked_recovery',
|
|
793
|
+
recoveryTurn: pendingDirective ? state.interactiveBlockedRecovery.turns.length : latestTurn?.number,
|
|
794
|
+
action: codex.payload.action,
|
|
795
|
+
nextPhase: nextState.phase,
|
|
796
|
+
});
|
|
797
|
+
return nextState;
|
|
798
|
+
}
|