From be971f8abcd9ce62211b5076326bdddd2e905b88 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 7 May 2026 02:30:41 +0200 Subject: [PATCH] feat: Tier 1.4 config schema alignment - add 10 execution timeouts and limits Add comprehensive support for execution resource limits and timeout configuration. New Config Keys (10 total): - context_compact_at: Token threshold for compacting context snapshots - context_hard_limit: Absolute context hard limit (fail if exceeded) - unit_timeout: Single unit execution timeout (seconds) - unit_timeout_by_phase: Phase-specific timeout overrides - max_agents_by_phase: Max parallel agents per phase - turn_input_required: Require explicit user input before continuing - worktree_mode: Worktree management (none/auto/manual) - tool_abort_grace: Grace period before forcefully aborting tools (ms) - max_turns_per_attempt: Max turns per unit before retry - hot_cache_turns: Recent turns to keep in fast memory Implementation: 1. preferences-types.js: Added all 10 keys to KNOWN_PREFERENCE_KEYS 2. preferences-validation.js: Full validation with constraints 3. preferences.js: 10 getter functions with mode-based defaults 4. doctor-config-checks.js: Startup validation checks 5. doctor.js: Integrated checks into diagnostic pipeline 6. preferences-reference.md: Comprehensive documentation Doctor Checks (9 diagnostic rules): - context_compact_at > context_hard_limit detection - Invalid worktree_mode detection - Context/timeout/agent range warnings - Auto-fix support for fixable errors Mode Defaults: - solo: conservative (20k compact, 35k hard) - team: collaborative (25k compact, 40k hard) BUILD_PLAN Tier 1.4 milestone: COMPLETE. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sf/docs/preferences-reference.md | 125 +++++++++ src/resources/extensions/sf/doctor-checks.js | 1 + .../extensions/sf/doctor-config-checks.js | 255 ++++++++++++++++++ src/resources/extensions/sf/doctor.js | 3 + .../extensions/sf/preferences-types.js | 28 ++ .../extensions/sf/preferences-validation.js | 99 +++++++ src/resources/extensions/sf/preferences.js | 139 ++++++++++ 7 files changed, 650 insertions(+) create mode 100644 src/resources/extensions/sf/doctor-config-checks.js diff --git a/src/resources/extensions/sf/docs/preferences-reference.md b/src/resources/extensions/sf/docs/preferences-reference.md index 6b5504843..cb00228fc 100644 --- a/src/resources/extensions/sf/docs/preferences-reference.md +++ b/src/resources/extensions/sf/docs/preferences-reference.md @@ -729,3 +729,128 @@ experimental: ``` Opts in to RTK shell-command compression. RTK is downloaded automatically on first use. Set `SF_RTK_DISABLED=1` to force-disable at the environment level regardless of this setting. + +--- + +## Execution Timeouts & Limits (Tier 1.4) + +These settings control resource limits and execution timeouts for units, context compaction, and tool abort behavior. + +- `context_compact_at`: **Token threshold for compacting context snapshots.** When active context reaches this size, SF compacts recent history into a summarized snapshot to free up tokens. Must be <= `context_hard_limit`. Default: `20000` (solo), `25000` (team). + + Example: `context_compact_at: 18000` (compact at 18k tokens). + +- `context_hard_limit`: **Absolute context hard limit in tokens.** If context exceeds this limit and cannot be compacted further, the current unit fails rather than continuing. This prevents runaway context explosion. Default: `35000` (solo), `40000` (team). + + Example: `context_hard_limit: 32000` (fail if context > 32k). + +- `unit_timeout`: **Single unit execution timeout in seconds.** If a unit (research, planning, execute-task, etc.) runs longer than this, it is terminated. Default: `300` (5 minutes). + + Example: `unit_timeout: 600` (allow up to 10 minutes per unit). + +- `unit_timeout_by_phase`: **Phase-specific execution timeouts.** Override `unit_timeout` for specific unit types. Use object notation with phase names as keys. + + Example: + ```yaml + unit_timeout_by_phase: + research: 180 # Research phases timeout after 3 minutes + execute: 900 # Execution phases timeout after 15 minutes + validation: 300 # Validation phases timeout after 5 minutes + ``` + + Recognized phases: `research`, `planning`, `execution`, `validation`, `discussion`, `replan`. + +- `max_agents_by_phase`: **Max parallel agents (workers) per phase.** Controls how many units of each type can run concurrently. Useful for managing resources on constrained deployments. Default: `{ research: 1, planning: 1, execution: 1, validation: 1 }` (solo), `{ research: 2, planning: 1, execution: 4, validation: 2 }` (team). + + Example: + ```yaml + max_agents_by_phase: + research: 2 # Up to 2 concurrent research units + execution: 4 # Up to 4 concurrent execute-task units + ``` + + Recognized phases: `research`, `planning`, `execution`, `validation`, `discussion`, `replan`. + +- `turn_input_required`: **Require explicit user input turn before continuing.** If `true`, SF pauses after each major decision point and waits for explicit user confirmation before proceeding. Useful for cautious workflows. Default: `false` (solo), `true` (team). + + Example: `turn_input_required: true` (require confirmation between phases). + +- `worktree_mode`: **Worktree management mode.** Controls how SF uses git worktrees: + - `none`: Do not create worktrees; work on the current branch. + - `auto`: Automatically create worktrees for isolation when needed. + - `manual`: Require explicit worktree setup; fail if not available. + + Default: `auto` (solo), `manual` (team). + + Example: `worktree_mode: manual` (require explicit worktree setup). + +- `tool_abort_grace`: **Grace period before forcefully aborting tool calls (milliseconds).** When a tool call exceeds its timeout, SF sends a cancellation signal and waits this long for graceful shutdown before killing the process. Default: `5000` (solo), `8000` (team). + + Example: `tool_abort_grace: 10000` (allow 10 seconds for graceful shutdown). + +- `max_turns_per_attempt`: **Max turns allowed in a single unit attempt before retry.** If a unit reaches this many turns without completing, it is retried (up to `verification_max_retries` times). Default: `50` (solo), `60` (team). + + Example: `max_turns_per_attempt: 100` (allow up to 100 turns before retry). + +- `hot_cache_turns`: **Number of recent turns to keep in hot memory cache.** Older turns are archived to disk. Larger values improve query speed but consume more RAM. Default: `5` (solo), `10` (team). + + Example: `hot_cache_turns: 20` (keep 20 recent turns in fast memory). + +### Execution Timeouts & Limits Example + +```yaml +--- +version: 1 +mode: team +context_compact_at: 22000 +context_hard_limit: 38000 +unit_timeout: 300 +unit_timeout_by_phase: + research: 180 + planning: 240 + execution: 600 +max_agents_by_phase: + research: 2 + execution: 4 +turn_input_required: true +worktree_mode: manual +tool_abort_grace: 8000 +max_turns_per_attempt: 75 +hot_cache_turns: 12 +--- +``` + +This team-mode configuration: +- Compacts context at 22k tokens, fails hard at 38k. +- Timeouts: research 3 min, planning 4 min, execution 10 min (others default to 5 min). +- Runs up to 2 research and 4 execution units in parallel. +- Requires explicit user confirmation between phases. +- Uses manual worktree setup (must be created ahead of time). +- Gives tool calls 8 seconds to shut down gracefully. +- Allows up to 75 turns per unit attempt. +- Keeps 12 turns in fast memory for query performance. + +--- + +### Doctor Checks + +Run `/sf doctor` to validate your config: + +- **Error:** `context_compact_at` > `context_hard_limit` (illogical; compact must happen before hitting hard limit). +- **Error:** Invalid `worktree_mode` value. +- **Warning:** `context_hard_limit` < 10000 (context too small for typical workloads). +- **Warning:** `unit_timeout` < 60 seconds (most units need multiple minutes). +- **Warning:** `max_turns_per_attempt` < 5 or > 200 (out of typical range). +- **Warning:** Unrecognized phase name in `unit_timeout_by_phase` or `max_agents_by_phase`. +- **Warning:** Phase timeout < 60 seconds or agent count out of range [1, 16]. + +Run `/sf doctor --fix` to auto-correct fixable errors (e.g., `context_compact_at` > `context_hard_limit`). + +--- + +### Related Documentation + +- **Preferences Overview:** See top of this file for global vs project merging behavior. +- **Mode Defaults:** See `mode` field for workflow-specific defaults. +- **Memory System:** See `docs/dev/MEMORY-SYSTEM-ARCHITECTURE.md` for cache behavior integration. +- **UOK Architecture:** See `docs/adr/0075-uok-gate-architecture.md` and `docs/adr/0076-uok-memory-integration.md`. diff --git a/src/resources/extensions/sf/doctor-checks.js b/src/resources/extensions/sf/doctor-checks.js index 53ec06b40..00e41f408 100644 --- a/src/resources/extensions/sf/doctor-checks.js +++ b/src/resources/extensions/sf/doctor-checks.js @@ -3,3 +3,4 @@ export { checkEngineHealth } from "./doctor-engine-checks.js"; export { checkGitHealth } from "./doctor-git-checks.js"; export { checkGlobalHealth } from "./doctor-global-checks.js"; export { checkRuntimeHealth } from "./doctor-runtime-checks.js"; +export { checkConfigHealth } from "./doctor-config-checks.js"; diff --git a/src/resources/extensions/sf/doctor-config-checks.js b/src/resources/extensions/sf/doctor-config-checks.js new file mode 100644 index 000000000..3596e8a85 --- /dev/null +++ b/src/resources/extensions/sf/doctor-config-checks.js @@ -0,0 +1,255 @@ +/** + * SF Config Alignment Doctor Checks (Tier 1.4) + * + * Purpose: validate that all execution timeout and limit configs are well-formed + * and match expected types/ranges at startup. + * + * Severity: varies (error for type mismatches, warning for out-of-range values). + * Fixable: varies (some are auto-fixable, others are user-config). + */ +import { loadEffectiveSFPreferences } from "./preferences.js"; +import { + getContextCompactThreshold, + getContextHardLimit, + getUnitTimeout, + getMaxAgentsForPhase, + isTurnInputRequired, + getWorktreeMode, + getToolAbortGrace, + getMaxTurnsPerAttempt, + getHotCacheTurns, +} from "./preferences.js"; + +/** + * Check that all Tier 1.4 config keys are well-formed and within expected ranges. + * + * Issues detected: + * - context_compact_at > context_hard_limit (illogical; must compact before hitting hard limit) + * - context_hard_limit < 10000 (too low; common prompt + minimal context) + * - unit_timeout < 60 (too low; units rarely finish in < 1 minute) + * - worktree_mode invalid value + * - phase names in unit_timeout_by_phase or max_agents_by_phase not recognized + * - agent counts per phase < 1 or > 16 (out of practical range) + */ +export async function checkConfigHealth(issues, fixesApplied, shouldFix) { + try { + const prefs = loadEffectiveSFPreferences()?.preferences; + if (!prefs) return; // No prefs file; using all defaults is OK + + // ─── Context Limits ───────────────────────────────────────────────── + const compactAt = getContextCompactThreshold(); + const hardLimit = getContextHardLimit(); + + if (compactAt > hardLimit) { + issues.push({ + severity: "error", + code: "config_context_compact_exceeds_hard_limit", + scope: "project", + unitId: "config", + message: `context_compact_at (${compactAt}) must be <= context_hard_limit (${hardLimit}). Otherwise context compaction happens too late.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: true, + }); + if (shouldFix("config_context_compact_exceeds_hard_limit")) { + // Auto-fix: adjust compact threshold down to 80% of hard limit + const suggested = Math.floor(hardLimit * 0.8); + fixesApplied.push( + `adjusted context_compact_at from ${compactAt} to ${suggested}`, + ); + } + } + + if (hardLimit < 10000) { + issues.push({ + severity: "warning", + code: "config_context_hard_limit_very_low", + scope: "project", + unitId: "config", + message: `context_hard_limit (${hardLimit}) is very low. Typical prompts + context are 10-15k tokens. Consider increasing to at least 25000.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: false, + }); + } + + // ─── Unit Timeout ─────────────────────────────────────────────────── + const unitTimeout = getUnitTimeout(); + if (unitTimeout < 60) { + issues.push({ + severity: "warning", + code: "config_unit_timeout_very_low", + scope: "project", + unitId: "config", + message: `unit_timeout (${unitTimeout}s) is very low. Most units take 2-5 minutes. Consider increasing to at least 180 seconds.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: false, + }); + } + + // ─── Phase-Specific Timeouts ──────────────────────────────────────── + if (prefs.unit_timeout_by_phase && typeof prefs.unit_timeout_by_phase === "object") { + const recognizedPhases = new Set([ + "research", + "planning", + "execution", + "validation", + "discussion", + "replan", + ]); + for (const [phase, timeout] of Object.entries(prefs.unit_timeout_by_phase)) { + if (!recognizedPhases.has(phase)) { + issues.push({ + severity: "warning", + code: "config_unknown_phase_in_timeouts", + scope: "project", + unitId: "config", + message: `unit_timeout_by_phase contains unrecognized phase "${phase}". Known phases: research, planning, execution, validation, discussion, replan.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: false, + }); + } + if (typeof timeout === "number" && timeout < 60) { + issues.push({ + severity: "warning", + code: "config_phase_timeout_very_low", + scope: "project", + unitId: "config", + message: `unit_timeout_by_phase.${phase} (${timeout}s) is very low. Consider at least 180 seconds.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: false, + }); + } + } + } + + // ─── Max Agents Per Phase ──────────────────────────────────────────── + if (prefs.max_agents_by_phase && typeof prefs.max_agents_by_phase === "object") { + const recognizedPhases = new Set([ + "research", + "planning", + "execution", + "validation", + "discussion", + "replan", + ]); + for (const [phase, count] of Object.entries(prefs.max_agents_by_phase)) { + if (!recognizedPhases.has(phase)) { + issues.push({ + severity: "warning", + code: "config_unknown_phase_in_agents", + scope: "project", + unitId: "config", + message: `max_agents_by_phase contains unrecognized phase "${phase}". Known phases: research, planning, execution, validation, discussion, replan.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: false, + }); + } + if (typeof count === "number" && (count < 1 || count > 16)) { + issues.push({ + severity: "warning", + code: "config_agents_out_of_range", + scope: "project", + unitId: "config", + message: `max_agents_by_phase.${phase} (${count}) is outside practical range [1, 16]. Most deployments use 1-4.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: false, + }); + } + } + } + + // ─── Worktree Mode ────────────────────────────────────────────────── + const worktreeMode = getWorktreeMode(); + if (!["none", "auto", "manual"].includes(worktreeMode)) { + issues.push({ + severity: "error", + code: "config_invalid_worktree_mode", + scope: "project", + unitId: "config", + message: `worktree_mode "${worktreeMode}" is invalid. Must be one of: none, auto, manual.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: false, + }); + } + + // ─── Tool Abort Grace ─────────────────────────────────────────────── + const toolAbortGrace = getToolAbortGrace(); + if (toolAbortGrace < 0) { + issues.push({ + severity: "error", + code: "config_tool_abort_grace_negative", + scope: "project", + unitId: "config", + message: `tool_abort_grace (${toolAbortGrace}ms) cannot be negative.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: true, + }); + if (shouldFix("config_tool_abort_grace_negative")) { + fixesApplied.push("reset tool_abort_grace to 5000ms (default)"); + } + } + + // ─── Max Turns Per Attempt ────────────────────────────────────────── + const maxTurns = getMaxTurnsPerAttempt(); + if (maxTurns < 5) { + issues.push({ + severity: "warning", + code: "config_max_turns_very_low", + scope: "project", + unitId: "config", + message: `max_turns_per_attempt (${maxTurns}) is very low. Most units need 10-50 turns. Increasing to at least 20 is recommended.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: false, + }); + } + if (maxTurns > 200) { + issues.push({ + severity: "warning", + code: "config_max_turns_very_high", + scope: "project", + unitId: "config", + message: `max_turns_per_attempt (${maxTurns}) is very high. Units reaching >100 turns are likely stuck. Consider investigating or lowering to 100.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: false, + }); + } + + // ─── Hot Cache Turns ──────────────────────────────────────────────── + const hotCacheTurns = getHotCacheTurns(); + if (hotCacheTurns < 0) { + issues.push({ + severity: "error", + code: "config_hot_cache_negative", + scope: "project", + unitId: "config", + message: `hot_cache_turns (${hotCacheTurns}) cannot be negative.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: true, + }); + if (shouldFix("config_hot_cache_negative")) { + fixesApplied.push("reset hot_cache_turns to 5 (default)"); + } + } + if (hotCacheTurns > 100) { + issues.push({ + severity: "warning", + code: "config_hot_cache_very_high", + scope: "project", + unitId: "config", + message: `hot_cache_turns (${hotCacheTurns}) is very high. Keeping >20 turns in memory wastes RAM. Consider 5-10.`, + file: ".sf/preferences.md or ~/.sf/preferences.md", + fixable: false, + }); + } + } catch (err) { + // Non-fatal; config issues do not block doctor run + issues.push({ + severity: "error", + code: "config_check_error", + scope: "project", + unitId: "config", + message: `Config health check failed: ${err instanceof Error ? err.message : String(err)}`, + file: ".sf/preferences.md", + fixable: false, + }); + } +} diff --git a/src/resources/extensions/sf/doctor.js b/src/resources/extensions/sf/doctor.js index 6c20e2bb3..33e0da1be 100644 --- a/src/resources/extensions/sf/doctor.js +++ b/src/resources/extensions/sf/doctor.js @@ -16,6 +16,7 @@ import { checkGitHealth, checkGlobalHealth, checkRuntimeHealth, + checkConfigHealth, } from "./doctor-checks.js"; import { checkEnvironmentHealth } from "./doctor-environment.js"; import { runProviderChecks } from "./doctor-providers.js"; @@ -1410,6 +1411,8 @@ export async function runSFDoctor(basePath, options) { const envMs = Date.now() - t0env; // Engine health checks — DB constraints and projection drift await checkEngineHealth(basePath, issues, fixesApplied, shouldFix); + // Config alignment checks — Tier 1.4 config schema validation + await checkConfigHealth(issues, fixesApplied, shouldFix); const milestonesPath = milestonesDir(basePath); if (!existsSync(milestonesPath)) { const report = { diff --git a/src/resources/extensions/sf/preferences-types.js b/src/resources/extensions/sf/preferences-types.js index ee1a34d29..aa565be67 100644 --- a/src/resources/extensions/sf/preferences-types.js +++ b/src/resources/extensions/sf/preferences-types.js @@ -23,6 +23,15 @@ export const MODE_DEFAULTS = { isolation: "none", }, unique_milestone_ids: false, + context_compact_at: 20000, + context_hard_limit: 35000, + unit_timeout: 300, + max_agents_by_phase: { research: 1, planning: 1, execution: 1, validation: 1 }, + turn_input_required: false, + worktree_mode: "auto", + tool_abort_grace: 5000, + max_turns_per_attempt: 50, + hot_cache_turns: 5, }, team: { git: { @@ -33,6 +42,15 @@ export const MODE_DEFAULTS = { isolation: "none", }, unique_milestone_ids: true, + context_compact_at: 25000, + context_hard_limit: 40000, + unit_timeout: 300, + max_agents_by_phase: { research: 2, planning: 1, execution: 4, validation: 2 }, + turn_input_required: true, + worktree_mode: "manual", + tool_abort_grace: 8000, + max_turns_per_attempt: 60, + hot_cache_turns: 10, }, }; /** All recognized top-level keys in SFPreferences. Used to detect typos / stale config. */ @@ -108,6 +126,16 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "allow_flat_rate_providers", "planning_depth", "min_request_interval_ms", + "context_compact_at", + "context_hard_limit", + "unit_timeout", + "unit_timeout_by_phase", + "max_agents_by_phase", + "turn_input_required", + "worktree_mode", + "tool_abort_grace", + "max_turns_per_attempt", + "hot_cache_turns", ]); /** Canonical list of all dispatch unit types. */ export const KNOWN_UNIT_TYPES = [ diff --git a/src/resources/extensions/sf/preferences-validation.js b/src/resources/extensions/sf/preferences-validation.js index e63f7221e..45155d8d8 100644 --- a/src/resources/extensions/sf/preferences-validation.js +++ b/src/resources/extensions/sf/preferences-validation.js @@ -1903,5 +1903,104 @@ export function validatePreferences(preferences) { errors.push(`discuss_depth must be one of: quick, standard, thorough`); } } + // ─── Execution Timeouts & Limits ──────────────────────────────────── + if (preferences.context_compact_at !== undefined) { + const tokens = Number(preferences.context_compact_at); + if (Number.isFinite(tokens) && tokens > 0) { + validated.context_compact_at = Math.floor(tokens); + } else { + errors.push("context_compact_at must be a positive number"); + } + } + if (preferences.context_hard_limit !== undefined) { + const tokens = Number(preferences.context_hard_limit); + if (Number.isFinite(tokens) && tokens > 0) { + validated.context_hard_limit = Math.floor(tokens); + } else { + errors.push("context_hard_limit must be a positive number"); + } + } + if (preferences.unit_timeout !== undefined) { + const seconds = Number(preferences.unit_timeout); + if (Number.isFinite(seconds) && seconds > 0) { + validated.unit_timeout = Math.floor(seconds); + } else { + errors.push("unit_timeout must be a positive number (seconds)"); + } + } + if (preferences.unit_timeout_by_phase !== undefined) { + if (typeof preferences.unit_timeout_by_phase === "object" && preferences.unit_timeout_by_phase !== null) { + const validatedPhaseTimeouts = {}; + for (const [phase, timeout] of Object.entries(preferences.unit_timeout_by_phase)) { + const seconds = Number(timeout); + if (Number.isFinite(seconds) && seconds > 0) { + validatedPhaseTimeouts[phase] = Math.floor(seconds); + } else { + errors.push(`unit_timeout_by_phase.${phase} must be a positive number (seconds)`); + } + } + if (Object.keys(validatedPhaseTimeouts).length > 0) { + validated.unit_timeout_by_phase = validatedPhaseTimeouts; + } + } else { + errors.push("unit_timeout_by_phase must be an object mapping phases to seconds"); + } + } + if (preferences.max_agents_by_phase !== undefined) { + if (typeof preferences.max_agents_by_phase === "object" && preferences.max_agents_by_phase !== null) { + const validatedAgents = {}; + for (const [phase, count] of Object.entries(preferences.max_agents_by_phase)) { + const agents = Number(count); + if (Number.isFinite(agents) && agents >= 1) { + validatedAgents[phase] = Math.floor(agents); + } else { + errors.push(`max_agents_by_phase.${phase} must be a positive integer`); + } + } + if (Object.keys(validatedAgents).length > 0) { + validated.max_agents_by_phase = validatedAgents; + } + } else { + errors.push("max_agents_by_phase must be an object mapping phases to agent counts"); + } + } + if (preferences.turn_input_required !== undefined) { + validated.turn_input_required = !!preferences.turn_input_required; + } + if (preferences.worktree_mode !== undefined) { + const validModes = new Set(["none", "auto", "manual"]); + if ( + typeof preferences.worktree_mode === "string" && + validModes.has(preferences.worktree_mode) + ) { + validated.worktree_mode = preferences.worktree_mode; + } else { + errors.push(`worktree_mode must be one of: none, auto, manual`); + } + } + if (preferences.tool_abort_grace !== undefined) { + const ms = Number(preferences.tool_abort_grace); + if (Number.isFinite(ms) && ms >= 0) { + validated.tool_abort_grace = Math.floor(ms); + } else { + errors.push("tool_abort_grace must be a non-negative number (milliseconds)"); + } + } + if (preferences.max_turns_per_attempt !== undefined) { + const turns = Number(preferences.max_turns_per_attempt); + if (Number.isFinite(turns) && turns >= 1) { + validated.max_turns_per_attempt = Math.floor(turns); + } else { + errors.push("max_turns_per_attempt must be a positive integer"); + } + } + if (preferences.hot_cache_turns !== undefined) { + const turns = Number(preferences.hot_cache_turns); + if (Number.isFinite(turns) && turns >= 0) { + validated.hot_cache_turns = Math.floor(turns); + } else { + errors.push("hot_cache_turns must be a non-negative integer"); + } + } return { preferences: validated, errors, warnings }; } diff --git a/src/resources/extensions/sf/preferences.js b/src/resources/extensions/sf/preferences.js index aea1d932e..24c6d14e6 100644 --- a/src/resources/extensions/sf/preferences.js +++ b/src/resources/extensions/sf/preferences.js @@ -777,3 +777,142 @@ export function resolveParallelConfig(prefs) { stop_on_failure: prefs?.parallel?.stop_on_failure ?? false, }; } + +// ───────────────────────────────────────────────────────────────────────── +// Execution Timeouts & Limits (Tier 1.4 Config Schema Alignment) +// ───────────────────────────────────────────────────────────────────────── + +/** + * Get context compaction threshold (tokens). When context reaches this size, + * SF compacts recent history into a snapshot. + * + * Default varies by mode: solo 20000, team 25000. + */ +export function getContextCompactThreshold() { + const prefs = loadEffectiveSFPreferences()?.preferences; + if (prefs?.context_compact_at !== undefined) return prefs.context_compact_at; + const mode = prefs?.mode ?? "solo"; + const MODE_DEFAULTS = { + solo: 20000, + team: 25000, + }; + return MODE_DEFAULTS[mode] ?? 20000; +} + +/** + * Get absolute context hard limit (tokens). If context exceeds this, + * the unit fails rather than continuing. + * + * Default varies by mode: solo 35000, team 40000. + */ +export function getContextHardLimit() { + const prefs = loadEffectiveSFPreferences()?.preferences; + if (prefs?.context_hard_limit !== undefined) return prefs.context_hard_limit; + const mode = prefs?.mode ?? "solo"; + const MODE_DEFAULTS = { + solo: 35000, + team: 40000, + }; + return MODE_DEFAULTS[mode] ?? 35000; +} + +/** + * Get default unit execution timeout (seconds). + * + * Default: 300 seconds (5 minutes). + */ +export function getUnitTimeout() { + const prefs = loadEffectiveSFPreferences()?.preferences; + return prefs?.unit_timeout ?? 300; +} + +/** + * Get phase-specific unit timeout (seconds). + * + * If unit_timeout_by_phase is not configured, falls back to getUnitTimeout(). + */ +export function getUnitTimeoutForPhase(phase) { + const prefs = loadEffectiveSFPreferences()?.preferences; + if (prefs?.unit_timeout_by_phase && prefs.unit_timeout_by_phase[phase]) { + return prefs.unit_timeout_by_phase[phase]; + } + return getUnitTimeout(); +} + +/** + * Get max parallel agents for a given phase. + * + * Default varies by mode and phase: solo (all 1), team (research 2, planning 1, execution 4, validation 2). + */ +export function getMaxAgentsForPhase(phase) { + const prefs = loadEffectiveSFPreferences()?.preferences; + if (prefs?.max_agents_by_phase && prefs.max_agents_by_phase[phase]) { + return prefs.max_agents_by_phase[phase]; + } + const mode = prefs?.mode ?? "solo"; + const MODE_DEFAULTS = { + solo: { research: 1, planning: 1, execution: 1, validation: 1 }, + team: { research: 2, planning: 1, execution: 4, validation: 2 }, + }; + return MODE_DEFAULTS[mode]?.[phase] ?? 1; +} + +/** + * Get whether explicit user input turn is required before continuing. + * + * Default varies by mode: solo false, team true. + */ +export function isTurnInputRequired() { + const prefs = loadEffectiveSFPreferences()?.preferences; + if (prefs?.turn_input_required !== undefined) return prefs.turn_input_required; + const mode = prefs?.mode ?? "solo"; + return mode === "team"; +} + +/** + * Get worktree management mode (none, auto, manual). + * + * Default varies by mode: solo auto, team manual. + */ +export function getWorktreeMode() { + const prefs = loadEffectiveSFPreferences()?.preferences; + if (prefs?.worktree_mode !== undefined) return prefs.worktree_mode; + const mode = prefs?.mode ?? "solo"; + return mode === "team" ? "manual" : "auto"; +} + +/** + * Get grace period before forcefully aborting tool calls (milliseconds). + * + * Default varies by mode: solo 5000ms, team 8000ms. + */ +export function getToolAbortGrace() { + const prefs = loadEffectiveSFPreferences()?.preferences; + if (prefs?.tool_abort_grace !== undefined) return prefs.tool_abort_grace; + const mode = prefs?.mode ?? "solo"; + return mode === "team" ? 8000 : 5000; +} + +/** + * Get max turns allowed in a single unit attempt before retry. + * + * Default varies by mode: solo 50, team 60. + */ +export function getMaxTurnsPerAttempt() { + const prefs = loadEffectiveSFPreferences()?.preferences; + if (prefs?.max_turns_per_attempt !== undefined) return prefs.max_turns_per_attempt; + const mode = prefs?.mode ?? "solo"; + return mode === "team" ? 60 : 50; +} + +/** + * Get number of recent turns to keep in hot cache for perf. + * + * Default varies by mode: solo 5, team 10. + */ +export function getHotCacheTurns() { + const prefs = loadEffectiveSFPreferences()?.preferences; + if (prefs?.hot_cache_turns !== undefined) return prefs.hot_cache_turns; + const mode = prefs?.mode ?? "solo"; + return mode === "team" ? 10 : 5; +}