feat(uok): port gsd2 resilience patterns — rate-limit, evidence reload, provider recovery

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 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 01:27:37 +02:00
parent dda1bc1206
commit 356d1d1f99
9 changed files with 405 additions and 52 deletions

View file

@ -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 {

View file

@ -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<void> {
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<void>(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,

View file

@ -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<PhaseResult<{ unitStartedAt: number }>> {
): Promise<PhaseResult<{ unitStartedAt: number; requestDispatchedAt?: number }>> {
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,

View file

@ -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;
}

View file

@ -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;

View file

@ -89,6 +89,7 @@ export interface UnitResult {
status: "completed" | "cancelled" | "error";
event?: AgentEndEvent;
errorContext?: ErrorContext;
requestDispatchedAt?: number;
}
// ─── Phase pipeline types ────────────────────────────────────────────────────

View file

@ -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).

View file

@ -7,7 +7,7 @@
* Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
*/
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<string, unknown>;
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 {

View file

@ -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);
}