From 356d1d1f99e79ebd6ce1efa8c4827dd1b4df830e Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 01:27:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(uok):=20port=20gsd2=20resilience=20pattern?= =?UTF-8?q?s=20=E2=80=94=20rate-limit,=20evidence=20reload,=20provider=20r?= =?UTF-8?q?ecovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loop.ts: - saveStuckState on main dev path (was only on custom-engine path — P1 fix) - Add pid to stuck-state JSON to prevent test pollution across process runs - Use atomicWriteSync in saveCustomVerifyRetryCounts for crash-safety - Add enforceMinRequestInterval + call before both runUnitPhaseViaContract sites - Update s.lastRequestTimestamp from requestDispatchedAt on each unit session.ts: - Add lastRequestTimestamp and lastUnitAgentEndMessages fields phases.ts: - Add consecutiveSessionTimeouts + exponential-backoff auto-resume (up to 3x) for session-creation timeouts before pausing for manual review - Add loadEvidenceFromDisk after resetEvidence to rehydrate evidence on restart - Add USER_DRIVEN_DEEP_UNITS + isAwaitingUserInput guard to skip artifact verification when a deep-planning unit is paused awaiting user input - Store s.lastUnitAgentEndMessages after each unit run - Add requestDispatchedAt to runUnitPhase return type evidence-collector.ts: add loadEvidenceFromDisk export auto-post-unit.ts: add USER_DRIVEN_DEEP_UNITS set + re-export isAwaitingUserInput user-input-boundary.ts: port from gsd2 (isAwaitingUserInput + approval helpers) run-unit.ts: capture requestDispatchedAt at API dispatch time kernel.ts: remove redundant !legacyFallback guard (enabled already encodes it) tests/uok-kernel-path.test.ts: add SF_UOK_AUDIT_ENVELOPE env var assertions Co-Authored-By: Claude Sonnet 4.6 --- src/resources/extensions/sf/auto-post-unit.ts | 9 + src/resources/extensions/sf/auto/loop.ts | 26 +++ src/resources/extensions/sf/auto/phases.ts | 183 +++++++++++++----- src/resources/extensions/sf/auto/run-unit.ts | 4 +- src/resources/extensions/sf/auto/session.ts | 8 + src/resources/extensions/sf/auto/types.ts | 1 + .../extensions/sf/preferences-types.ts | 9 + .../sf/safety/evidence-collector.ts | 51 ++++- .../extensions/sf/user-input-boundary.ts | 166 ++++++++++++++++ 9 files changed, 405 insertions(+), 52 deletions(-) create mode 100644 src/resources/extensions/sf/user-input-boundary.ts diff --git a/src/resources/extensions/sf/auto-post-unit.ts b/src/resources/extensions/sf/auto-post-unit.ts index b344312e3..3acdaada4 100644 --- a/src/resources/extensions/sf/auto-post-unit.ts +++ b/src/resources/extensions/sf/auto-post-unit.ts @@ -323,9 +323,18 @@ export function buildStepCompleteMessage( ); } +export const USER_DRIVEN_DEEP_UNITS = new Set([ + "discuss-project", + "discuss-requirements", + "discuss-milestone", + "research-decision", +]); +export { isAwaitingUserInput } from "./user-input-boundary.js"; + export interface PreVerificationOpts { skipSettleDelay?: boolean; skipWorktreeSync?: boolean; + agentEndMessages?: unknown[]; } export interface PostUnitContext { diff --git a/src/resources/extensions/sf/auto/loop.ts b/src/resources/extensions/sf/auto/loop.ts index 511bd8dea..b7b6d6dcf 100644 --- a/src/resources/extensions/sf/auto/loop.ts +++ b/src/resources/extensions/sf/auto/loop.ts @@ -310,6 +310,18 @@ async function runUnitPhaseViaContract( return outcome ?? { action: "break", reason: "scheduler-dispatch-missing-result" }; } +async function enforceMinRequestInterval(s: AutoSession, prefs: IterationContext["prefs"]): Promise { + const minInterval = prefs?.min_request_interval_ms ?? 0; + if (minInterval > 0 && s.lastRequestTimestamp > 0) { + const elapsed = Date.now() - s.lastRequestTimestamp; + if (elapsed < minInterval) { + const waitMs = minInterval - elapsed; + debugLog("autoLoop", { phase: "rate-limit-wait", waitMs }); + await new Promise(r => setTimeout(r, waitMs)); + } + } +} + /** * Main auto-mode execution loop. Iterates: derive → dispatch → guards → * runUnit → finalize → repeat. Exits when s.active becomes false or a @@ -620,7 +632,14 @@ export async function autoLoop( } // ── Unit execution (shared with dev path) ── + await enforceMinRequestInterval(s, ic.prefs); const unitPhaseResult = await runUnitPhaseViaContract(dispatchContract, ic, iterData, loopState); + if (unitPhaseResult.action === "next") { + const d = unitPhaseResult.data as { unitStartedAt: number; requestDispatchedAt?: number }; + if (d?.requestDispatchedAt) { + s.lastRequestTimestamp = d.requestDispatchedAt; + } + } deps.uokObserver?.onPhaseResult("unit", unitPhaseResult.action, { unitType: iterData.unitType, unitId: iterData.unitId, @@ -916,6 +935,7 @@ export async function autoLoop( }); } + await enforceMinRequestInterval(s, ic.prefs); const unitPhaseResult = await runUnitPhaseViaContract( dispatchContract, ic, @@ -923,6 +943,12 @@ export async function autoLoop( loopState, sidecarItem, ); + if (unitPhaseResult.action === "next") { + const d = unitPhaseResult.data as { unitStartedAt: number; requestDispatchedAt?: number }; + if (d?.requestDispatchedAt) { + s.lastRequestTimestamp = d.requestDispatchedAt; + } + } deps.uokObserver?.onPhaseResult("unit", unitPhaseResult.action, { unitType: iterData.unitType, unitId: iterData.unitId, diff --git a/src/resources/extensions/sf/auto/phases.ts b/src/resources/extensions/sf/auto/phases.ts index 81ad3b320..bb75c4a20 100644 --- a/src/resources/extensions/sf/auto/phases.ts +++ b/src/resources/extensions/sf/auto/phases.ts @@ -20,10 +20,14 @@ import { } from "../../shared/sf-phase-state.js"; import { atomicWriteSync } from "../atomic-write.js"; import { resetCompletionNudgeState } from "../auto-completion-nudge.js"; -import type { - PostUnitContext, - PreVerificationOpts, +import { + USER_DRIVEN_DEEP_UNITS, + isAwaitingUserInput, + type PostUnitContext, + type PreVerificationOpts, } from "../auto-post-unit.js"; +import { pauseAutoForProviderError } from "../provider-error-pause.js"; +import { resumeAutoAfterProviderDelay } from "../bootstrap/provider-error-resume.js"; import { buildLoopRemediationSteps, diagnoseExpectedArtifact, @@ -54,7 +58,8 @@ import { ensureProductionMutationApprovalTemplate, readProductionMutationApprovalStatus, } from "../production-mutation-approval.js"; -import { resetEvidence } from "../safety/evidence-collector.js"; +import { loadEvidenceFromDisk, resetEvidence } from "../safety/evidence-collector.js"; +import { parseUnitId } from "../unit-id.js"; import { getDirtyFiles } from "../safety/file-change-validator.js"; import { cleanupCheckpoint, @@ -111,6 +116,15 @@ import { type PreDispatchData, } from "./types.js"; +// ─── Session timeout auto-resume state ──────────────────────────────────────── + +let consecutiveSessionTimeouts = 0; +const MAX_SESSION_TIMEOUT_AUTO_RESUMES = 3; + +function resetConsecutiveSessionTimeouts(): void { + consecutiveSessionTimeouts = 0; +} + // ─── generateMilestoneReport ────────────────────────────────────────────────── /** @@ -1787,7 +1801,7 @@ export async function runUnitPhase( iterData: IterationData, loopState: LoopState, sidecarItem?: SidecarItem, -): Promise> { +): Promise> { const { ctx, pi, s, deps, prefs } = ic; const { unitType, unitId, prompt, state, mid } = iterData; @@ -1956,6 +1970,8 @@ export async function runUnitPhase( ); if (safetyConfig.enabled && safetyConfig.evidence_collection) { resetEvidence(unitId, s.basePath); + const { milestone: eMid, slice: eSid, task: eTid } = parseUnitId(unitId); + loadEvidenceFromDisk(s.basePath, eMid, eSid ?? "", eTid ?? ""); } if ( safetyConfig.enabled && @@ -2215,6 +2231,7 @@ export async function runUnitPhase( unitId, }); const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt); + s.lastUnitAgentEndMessages = unitResult.event?.messages ?? null; debugLog("autoLoop", { phase: "runUnit-end", iteration: ic.iteration, @@ -2278,24 +2295,74 @@ export async function runUnitPhase( }); return { action: "break", reason: "provider-pause" }; } - // Session creation timeout (not a structural error): pause auto-mode - // and let the provider-error-resume timer handle recovery (#3767). This - // matches the provider-pause path — break out cleanly, don't hard-stop. + // Timeout category covers two distinct scenarios: + // 1. Session creation timeout (120s) — transient, auto-resume with backoff + // 2. Unit hard timeout (30min+) — stuck agent, pause for manual review // Structural errors (TypeError, is not a function) are NOT transient // and must hard-stop to avoid infinite retry loops. if ( unitResult.errorContext?.isTransient && unitResult.errorContext?.category === "timeout" ) { + const isSessionCreationTimeout = unitResult.errorContext.message?.includes("Session creation timed out"); + + if (isSessionCreationTimeout) { + consecutiveSessionTimeouts += 1; + const baseRetryAfterMs = 30_000; + const retryAfterMs = baseRetryAfterMs * 2 ** Math.max(0, consecutiveSessionTimeouts - 1); + const allowAutoResume = consecutiveSessionTimeouts <= MAX_SESSION_TIMEOUT_AUTO_RESUMES; + + if (!allowAutoResume) { + ctx.ui.notify( + `Session creation timed out ${consecutiveSessionTimeouts} consecutive times for ${unitType} ${unitId}. Pausing for manual review.`, + "warning", + ); + } + + debugLog("autoLoop", { + phase: "session-timeout-pause", + unitType, + unitId, + consecutiveSessionTimeouts, + retryAfterMs, + allowAutoResume, + }); + + const errorDetail = ` for ${unitType} ${unitId}`; + await pauseAutoForProviderError( + ctx.ui, + errorDetail, + () => deps.pauseAuto(ctx, pi), + { + isRateLimit: false, + isTransient: allowAutoResume, + retryAfterMs, + resume: allowAutoResume + ? () => { + void resumeAutoAfterProviderDelay(pi, ctx).catch((err) => { + const message = err instanceof Error ? err.message : String(err); + ctx.ui.notify( + `Session timeout recovery failed: ${message}`, + "error", + ); + }); + } + : undefined, + }, + ); + if (!allowAutoResume) { + resetConsecutiveSessionTimeouts(); + } + await emitCancelledUnitEnd(ic, unitType, unitId, unitStartSeq, unitResult.errorContext); + return { action: "break", reason: "session-timeout" }; + } + + // Unit hard timeout (30min+): pause without auto-resume — stuck agent ctx.ui.notify( - `Session creation timed out for ${unitType} ${unitId}. Pausing auto-mode (recoverable).`, + `Unit timed out for ${unitType} ${unitId} (supervision may have failed). Pausing auto-mode.`, "warning", ); - debugLog("autoLoop", { - phase: "session-timeout-pause", - unitType, - unitId, - }); + debugLog("autoLoop", { phase: "unit-hard-timeout-pause", unitType, unitId }); await deps.pauseAuto(ctx, pi); await emitCancelledUnitEnd( ic, @@ -2304,7 +2371,7 @@ export async function runUnitPhase( unitStartSeq, unitResult.errorContext, ); - return { action: "break", reason: "session-timeout" }; + return { action: "break", reason: "unit-hard-timeout" }; } // All other cancelled states (structural errors, non-transient failures): hard stop if (s.currentUnit) { @@ -2343,6 +2410,8 @@ export async function runUnitPhase( // Guard: stopAuto() may have nulled s.currentUnit via s.reset() while // this coroutine was suspended at `await runUnit(...)` (#2939). if (s.currentUnit) { + // Reset session timeout counter — any successful unit clears the slate + resetConsecutiveSessionTimeouts(); await deps.closeoutUnit( ctx, s.basePath, @@ -2383,33 +2452,41 @@ export async function runUnitPhase( u.startedAt === s.currentUnit?.startedAt, ); if (lastUnit && lastUnit.toolCalls === 0) { - debugLog("runUnitPhase", { - phase: "zero-tool-calls", - unitType, - unitId, - warning: - "Unit completed with 0 tool calls — likely context exhaustion, marking as failed", - }); - ctx.ui.notify( - `${unitType} ${unitId} completed with 0 tool calls — context exhaustion, will retry`, - "warning", - ); - recordLearningOutcomeForUnit( - ic, - unitType, - unitId, - s.currentUnit?.startedAt, - { - succeeded: false, - verificationPassed: null, - }, - ); - // Fall through to next iteration where dispatch will re-derive - // and re-dispatch this unit. - return { - action: "next", - data: { unitStartedAt: s.currentUnit?.startedAt }, - }; + if (USER_DRIVEN_DEEP_UNITS.has(unitType) && isAwaitingUserInput(s.lastUnitAgentEndMessages ?? undefined)) { + debugLog("runUnitPhase", { + phase: "zero-tool-calls-awaiting-user-input", + unitType, + unitId, + }); + } else { + debugLog("runUnitPhase", { + phase: "zero-tool-calls", + unitType, + unitId, + warning: + "Unit completed with 0 tool calls — likely context exhaustion, marking as failed", + }); + ctx.ui.notify( + `${unitType} ${unitId} completed with 0 tool calls — context exhaustion, will retry`, + "warning", + ); + recordLearningOutcomeForUnit( + ic, + unitType, + unitId, + s.currentUnit?.startedAt, + { + succeeded: false, + verificationPassed: null, + }, + ); + // Fall through to next iteration where dispatch will re-derive + // and re-dispatch this unit. + return { + action: "next", + data: { unitStartedAt: s.currentUnit?.startedAt, requestDispatchedAt: unitResult.requestDispatchedAt }, + }; + } } } } @@ -2423,9 +2500,15 @@ export async function runUnitPhase( } const skipArtifactVerification = shouldSkipArtifactVerification(unitType); - const artifactVerified = - skipArtifactVerification || - verifyExpectedArtifact(unitType, unitId, s.basePath); + let artifactVerified: boolean; + if (USER_DRIVEN_DEEP_UNITS.has(unitType) && isAwaitingUserInput(s.lastUnitAgentEndMessages ?? undefined)) { + // Skip artifact verification — unit is paused waiting for user input + artifactVerified = false; + } else { + artifactVerified = + skipArtifactVerification || + verifyExpectedArtifact(unitType, unitId, s.basePath); + } if (artifactVerified) { s.unitDispatchCount.delete(`${unitType}/${unitId}`); s.unitRecoveryCount.delete(`${unitType}/${unitId}`); @@ -2558,7 +2641,7 @@ export async function runUnitPhase( } s.preUnitDirtyFiles = []; - return { action: "next", data: { unitStartedAt: s.currentUnit?.startedAt } }; + return { action: "next", data: { unitStartedAt: s.currentUnit?.startedAt, requestDispatchedAt: unitResult.requestDispatchedAt } }; } // ─── runFinalize ────────────────────────────────────────────────────────────── @@ -2602,11 +2685,11 @@ export async function runFinalize( // mutations are harmless — postUnitPreVerification guards all side effects // behind `if (s.currentUnit)`. The next iteration sets a fresh currentUnit. // Sidecar items use lightweight pre-verification opts - const preVerificationOpts: PreVerificationOpts | undefined = sidecarItem + const preVerificationOpts: PreVerificationOpts = sidecarItem ? sidecarItem.kind === "hook" - ? { skipSettleDelay: true, skipWorktreeSync: true } - : { skipSettleDelay: true } - : undefined; + ? { skipSettleDelay: true, skipWorktreeSync: true, agentEndMessages: s.lastUnitAgentEndMessages ?? undefined } + : { skipSettleDelay: true, agentEndMessages: s.lastUnitAgentEndMessages ?? undefined } + : { agentEndMessages: s.lastUnitAgentEndMessages ?? undefined }; const _preUnitSnapshot = s.currentUnit ? { type: s.currentUnit.type, diff --git a/src/resources/extensions/sf/auto/run-unit.ts b/src/resources/extensions/sf/auto/run-unit.ts index 04afd9bb4..3fe4673a9 100644 --- a/src/resources/extensions/sf/auto/run-unit.ts +++ b/src/resources/extensions/sf/auto/run-unit.ts @@ -145,6 +145,7 @@ export async function runUnit( // is superseded; isStaleWrite() in journal.ts uses it to drop late writes. const capturedTurnGen = getCurrentTurnGeneration(); + const requestDispatchedAt = Date.now(); pi.sendMessage( { customType: "sf-auto", content: prompt, display: s.verbose }, { triggerTurn: true }, @@ -182,6 +183,7 @@ export async function runUnit( unitId, status: result.status, }); + const finalResult: UnitResult = { ...result, requestDispatchedAt }; // Discard trailing follow-up messages (e.g. async_job_result notifications) // from the completed unit. Without this, queued follow-ups trigger wasteful @@ -199,5 +201,5 @@ export async function runUnit( }); } - return result; + return finalResult; } diff --git a/src/resources/extensions/sf/auto/session.ts b/src/resources/extensions/sf/auto/session.ts index c4907fe94..2ce58b821 100644 --- a/src/resources/extensions/sf/auto/session.ts +++ b/src/resources/extensions/sf/auto/session.ts @@ -184,6 +184,10 @@ export class AutoSession { * Moved from module-level to per-session so s.reset() clears it (#1348). */ consecutiveCompleteBootstraps = 0; + // ── Rate-limiting / session tracking ──────────────────────────────────── + lastRequestTimestamp = 0; + lastUnitAgentEndMessages: unknown[] | null = null; + // ── Metrics ────────────────────────────────────────────────────────────── autoStartTime = 0; lastPromptCharCount: number | undefined; @@ -320,6 +324,10 @@ export class AutoSession { this.resourceVersionOnStart = null; this.lastStateRebuildAt = 0; + // Rate-limiting / session tracking + this.lastRequestTimestamp = 0; + this.lastUnitAgentEndMessages = null; + // Metrics this.autoStartTime = 0; this.lastPromptCharCount = undefined; diff --git a/src/resources/extensions/sf/auto/types.ts b/src/resources/extensions/sf/auto/types.ts index e06a83f87..9a643dbb4 100644 --- a/src/resources/extensions/sf/auto/types.ts +++ b/src/resources/extensions/sf/auto/types.ts @@ -89,6 +89,7 @@ export interface UnitResult { status: "completed" | "cancelled" | "error"; event?: AgentEndEvent; errorContext?: ErrorContext; + requestDispatchedAt?: number; } // ─── Phase pipeline types ──────────────────────────────────────────────────── diff --git a/src/resources/extensions/sf/preferences-types.ts b/src/resources/extensions/sf/preferences-types.ts index 9f08c053b..fc5bc2dc9 100644 --- a/src/resources/extensions/sf/preferences-types.ts +++ b/src/resources/extensions/sf/preferences-types.ts @@ -649,6 +649,15 @@ export interface SFPreferences { */ shell_wrapper?: string[]; + /** + * Minimum interval (ms) between consecutive unit request dispatches. + * When set, the loop waits until at least this many milliseconds have + * elapsed since the last dispatch before sending the next one. + * Useful for rate-limiting high-frequency auto-mode runs. + * Default: 0 (no minimum interval). + */ + min_request_interval_ms?: number; + /** * Workspace lifecycle hooks. Shell scripts run at key points in the * worktree lifecycle (inspired by Symphony's hooks model). diff --git a/src/resources/extensions/sf/safety/evidence-collector.ts b/src/resources/extensions/sf/safety/evidence-collector.ts index 01d5ea9cf..afc6b9a17 100644 --- a/src/resources/extensions/sf/safety/evidence-collector.ts +++ b/src/resources/extensions/sf/safety/evidence-collector.ts @@ -7,7 +7,7 @@ * Copyright (c) 2026 Jeremy McSpadden */ -import { appendFileSync, existsSync, mkdirSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -177,6 +177,55 @@ export function recordToolResult( } } +// ─── Disk Load (session resume) ──────────────────────────────────────────── + +function evidencePath(basePath: string, milestoneId: string, sliceId: string, taskId: string): string { + return join(basePath, ".sf", "active", `${milestoneId}/${sliceId}/${taskId}`, "evidence.jsonl"); +} + +function isEvidenceArray(data: unknown): data is EvidenceEntry[] { + if (!Array.isArray(data)) return false; + return data.every((e) => { + if (e === null || typeof e !== "object") return false; + const rec = e as Record; + return typeof rec.toolCallId === "string" && typeof rec.kind === "string"; + }); +} + +/** + * Load evidence from disk into module state after resetEvidence(). + * Call on session resume so evidence collected before a pause is restored. + * No-op if the file does not exist (fresh unit). + */ +export function loadEvidenceFromDisk( + basePath: string, + milestoneId: string, + sliceId: string, + taskId: string, +): void { + try { + const path = evidencePath(basePath, milestoneId, sliceId, taskId); + if (!existsSync(path)) return; + const lines = readFileSync(path, "utf-8") + .split("\n") + .filter((l) => l.trim().length > 0); + const entries: EvidenceEntry[] = []; + for (const line of lines) { + try { + const parsed = JSON.parse(line); + entries.push(parsed as EvidenceEntry); + } catch { + // Skip malformed lines + } + } + if (isEvidenceArray(entries)) { + unitEvidence = entries; + } + } catch { + // Non-fatal — corrupt / missing file is treated as empty evidence + } +} + // ─── Internals ────────────────────────────────────────────────────────────── function findLastUnresolved(kind: string): EvidenceEntry | undefined { diff --git a/src/resources/extensions/sf/user-input-boundary.ts b/src/resources/extensions/sf/user-input-boundary.ts new file mode 100644 index 000000000..4562ba7c0 --- /dev/null +++ b/src/resources/extensions/sf/user-input-boundary.ts @@ -0,0 +1,166 @@ +const USER_APPROVAL_UNIT_TYPES = new Set([ + "discuss-project", + "discuss-requirements", + "discuss-milestone", + "research-decision", +]); + +const REMOTE_QUESTION_FAILURE_RE = + /(?:Remote (?:auth failed|questions failed|channel configured but returned no result|questions timed out|questions timed out or failed)|Failed to send questions via)/i; + +const APPROVAL_WAIT_RE = + /\bwait(?:ing)?\s+for\s+(?:your\s+)?(?:confirmation|approval|input|response|answer)\b/i; + +const APPROVAL_QUESTION_RE = + /\b(?:confirm|confirmation|approve|approval|approved|captured|correct|correctly|happy\s+with|ready\s+to\s+(?:write|save|proceed|ship)|(?:want|need)\s+to\s+adjust|should\s+I\s+(?:write|save|proceed)|do\s+you\s+want\s+me\s+to\s+(?:write|save|proceed)|ship\s+it)\b/i; + +const APPROVAL_RIGHT_QUESTION_RE = + /\b(?:does|do|is|are|was|were|did)\b[^\n?]{0,120}\bright\b/i; + +const APPROVAL_CHANGE_QUESTION_RE = + /\b(?:anything\s+else|anything|something)\s+to\s+(?:adjust|add|remove|reclassify)\b/i; + +const RESEARCH_DECISION_QUESTION_RE = + /\b(?:research|skip)\b/i; + +function extractTextFromMessage(msg: unknown): string { + if (!msg || typeof msg !== "object") return ""; + const content = (msg as { content?: unknown }).content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const typed = block as { type?: unknown; text?: unknown }; + if (typed.type === "text" && typeof typed.text === "string") { + parts.push(typed.text); + } + } + return parts.join("\n"); +} + +function lastAssistantText(messages: unknown[] | undefined): string { + if (!Array.isArray(messages)) return ""; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!msg || typeof msg !== "object") continue; + if ((msg as { role?: unknown }).role !== "assistant") continue; + const text = extractTextFromMessage(msg).trim(); + if (text) return text; + } + return ""; +} + +function anyMessageMatches(messages: unknown[] | undefined, pattern: RegExp): boolean { + if (!Array.isArray(messages)) return false; + return messages.some((msg) => { + if (!msg || typeof msg !== "object") return false; + if ((msg as { role?: unknown }).role === "user") return false; + return pattern.test(extractTextFromMessage(msg)); + }); +} + +function hasApprovalQuestion(text: string): boolean { + for (let i = 0; i < text.length; i++) { + if (text[i] !== "?") continue; + const previousBreak = Math.max( + text.lastIndexOf("\n", i), + text.lastIndexOf(".", i), + text.lastIndexOf("!", i), + text.lastIndexOf("?", i - 1), + ); + const fragment = text.slice(previousBreak + 1, i + 1); + if (APPROVAL_QUESTION_RE.test(fragment)) return true; + if (APPROVAL_RIGHT_QUESTION_RE.test(fragment)) return true; + if (APPROVAL_CHANGE_QUESTION_RE.test(fragment)) return true; + } + return false; +} + +function hasResearchDecisionQuestion(text: string): boolean { + for (let i = 0; i < text.length; i++) { + if (text[i] !== "?") continue; + const previousBreak = Math.max( + text.lastIndexOf("\n", i), + text.lastIndexOf(".", i), + text.lastIndexOf("!", i), + text.lastIndexOf("?", i - 1), + ); + const fragment = text.slice(previousBreak + 1, i + 1); + if (RESEARCH_DECISION_QUESTION_RE.test(fragment)) return true; + } + return false; +} + +export function approvalGateIdForUnit( + unitType: string | undefined, + unitId?: string | null, +): string | null { + if (!unitType) return null; + if (unitType === "discuss-project") return "depth_verification_project_confirm"; + if (unitType === "discuss-requirements") return "depth_verification_requirements_confirm"; + if (unitType === "research-decision") return "depth_verification_research_decision_confirm"; + if (unitType === "discuss-milestone") { + const safeUnitId = typeof unitId === "string" && /^[A-Za-z0-9_-]+$/.test(unitId) + ? unitId + : "milestone"; + return `depth_verification_${safeUnitId}_confirm`; + } + return null; +} + +const CHANGE_REQUEST_RESPONSE_RE = + /\b(?:no|nope|nah|not\s+yet|don't|do\s+not|change|add|remove|reclassify|adjust|clarify|missing|instead|but|however|wait|hold)\b/i; + +const APPROVAL_RESPONSE_RE = + /^(?:y|yes|yeah|yep|approve|approved|confirm|confirmed|correct|right|looks\s+(?:good|right)|sounds\s+good|all\s+good|ok|okay|go\s+ahead|proceed|write\s+it|save\s+it|do\s+it)\b/i; + +const RESEARCH_DECISION_RESPONSE_RE = + /^(?:research|run\s+research|do\s+research|skip|skip\s+research|no\s+research)\b/i; + +export function isExplicitApprovalResponse( + input: string | undefined, + pendingGateId?: string | null, +): boolean { + const text = input?.trim() ?? ""; + if (!text) return false; + if (pendingGateId?.includes("research_decision")) { + return RESEARCH_DECISION_RESPONSE_RE.test(text); + } + if (CHANGE_REQUEST_RESPONSE_RE.test(text)) return false; + return APPROVAL_RESPONSE_RE.test(text); +} + +export function isAwaitingUserInput(messages: unknown[] | undefined): boolean { + if (anyMessageMatches(messages, /ask_user_questions was cancelled before receiving a response/i)) return true; + if (anyMessageMatches(messages, REMOTE_QUESTION_FAILURE_RE)) return true; + const text = lastAssistantText(messages); + if (!text) return false; + if (APPROVAL_WAIT_RE.test(text)) return true; + const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + if (lines.some((line) => line.endsWith("?"))) return true; + return hasApprovalQuestion(text); +} + +export function isAwaitingApprovalBoundary(messages: unknown[] | undefined): boolean { + if (anyMessageMatches(messages, /ask_user_questions was cancelled before receiving a response/i)) return true; + if (anyMessageMatches(messages, REMOTE_QUESTION_FAILURE_RE)) return true; + const text = lastAssistantText(messages); + if (!text) return false; + if (APPROVAL_WAIT_RE.test(text)) return true; + return hasApprovalQuestion(text); +} + +export function shouldPauseForUserApprovalQuestion( + unitType: string | undefined, + messages: unknown[] | undefined, +): boolean { + if (!unitType || !USER_APPROVAL_UNIT_TYPES.has(unitType)) return false; + if (anyMessageMatches(messages, /ask_user_questions was cancelled before receiving a response/i)) return true; + if (anyMessageMatches(messages, REMOTE_QUESTION_FAILURE_RE)) return true; + const text = lastAssistantText(messages); + if (!text) return false; + if (APPROVAL_WAIT_RE.test(text)) return true; + if (unitType === "research-decision") return hasResearchDecisionQuestion(text); + return hasApprovalQuestion(text); +}