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:
parent
dda1bc1206
commit
356d1d1f99
9 changed files with 405 additions and 52 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export interface UnitResult {
|
|||
status: "completed" | "cancelled" | "error";
|
||||
event?: AgentEndEvent;
|
||||
errorContext?: ErrorContext;
|
||||
requestDispatchedAt?: number;
|
||||
}
|
||||
|
||||
// ─── Phase pipeline types ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
166
src/resources/extensions/sf/user-input-boundary.ts
Normal file
166
src/resources/extensions/sf/user-input-boundary.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue