//! Re-exports of the crate-internal event types that live in //! `kigi-file-utils`. The orphan-rule items (`From<&permission::Decision>` //! and the doom-loop categorizer) stay here since they need shell-local //! types. pub(crate) use kigi_file_utils::events::tracker::EventTracker; pub(crate) use kigi_file_utils::events::types::{ CancellationCategory, EVENT_SCHEMA_VERSION, Event, GoalClassifierVerdictTelemetry, GoalPauseReasonTelemetry, InterjectionSource, Phase, RedirectKind, SessionRelationship, ToolOutcome, TurnOutcomeLabel, }; // ── Laziness detector (Layer 3) discriminator vocabulary ───────────── // // Single source of truth for the `category` field on // `Event::LazinessClassifierFired` / `LazinessNudgeFired` and the // `reason` field on `Event::LazinessClassifierAborted`. The producer // wraps the category strings in `LazinessCategory::as_const_str()` // (acp_session.rs) for compile-time closure over the set, and the // abort reasons are emitted only via the `LAZINESS_ABORT_*` consts // below — no string literals at any producer site. // // The category strings are also the lowercase serde representation of // the classifier's JSON output, so producer (classifier prompt) and // consumer (Rust enum + telemetry) share one vocabulary. /// Stalled — the model emitted prose narration claiming progress /// without any real tool calls. pub const LAZINESS_STALLED_NARRATION: &str = "stalled_narration"; /// Stalled — the model asked the user for permission to continue a /// task that is already in flight. pub const LAZINESS_STALLED_PERMISSION_ASKING: &str = "stalled_permission_asking"; /// Stalled — the model has no todo list but a multi-step task is /// clearly in flight (no active plan tool calls despite a complex /// pending task). pub const LAZINESS_STALLED_NO_TODOS_BUT_TASK_IN_FLIGHT: &str = "stalled_no_todos_but_task_in_flight"; /// Stalled — the agent declared completion/success but the transcript /// shows substantive claims unbacked by tool-call evidence (e.g. claims /// running `make test` but no `make` tool_call appears; claims /// "overnight 8+ hour run" but elapsed time is minutes; claims N review /// rounds but only M happened). pub const LAZINESS_STALLED_FALSE_COMPLETION: &str = "stalled_false_completion"; /// Not stalled — the model has genuinely completed its task. pub const LAZINESS_NOT_STALLED_COMPLETE: &str = "not_stalled_complete"; /// Not stalled — the model is correctly waiting on a backgrounded task /// it cannot drive forward. pub const LAZINESS_NOT_STALLED_WAITING_BG: &str = "not_stalled_waiting_on_background"; /// Not stalled — the model is correctly waiting on user input for a /// genuine ambiguity. pub const LAZINESS_NOT_STALLED_WAITING_USER: &str = "not_stalled_waiting_on_user"; /// Aborted because a fresh user prompt arrived before classification /// completed. pub const LAZINESS_ABORT_USER_INPUT: &str = "user_input"; /// Aborted because the user switched models mid-classification. pub const LAZINESS_ABORT_MODEL_SWITCH: &str = "model_switch"; /// Aborted because the classifier exceeded its wall-clock budget. pub const LAZINESS_ABORT_TIMEOUT: &str = "timeout"; /// Aborted because the classifier response failed to parse after the /// tolerant parser exhausted all three passes. pub const LAZINESS_ABORT_CLASSIFIER_ERROR: &str = "classifier_error"; // Compile-time guard against an accidentally-empty const breaking the // dashboards' group-by. `const _: () = assert!(…)` fires at build // time, not at first test run. #[allow(clippy::const_is_empty)] const _: () = assert!( !LAZINESS_STALLED_NARRATION.is_empty() && !LAZINESS_STALLED_PERMISSION_ASKING.is_empty() && !LAZINESS_STALLED_NO_TODOS_BUT_TASK_IN_FLIGHT.is_empty() && !LAZINESS_STALLED_FALSE_COMPLETION.is_empty() && !LAZINESS_NOT_STALLED_COMPLETE.is_empty() && !LAZINESS_NOT_STALLED_WAITING_BG.is_empty() && !LAZINESS_NOT_STALLED_WAITING_USER.is_empty() && !LAZINESS_ABORT_USER_INPUT.is_empty() && !LAZINESS_ABORT_MODEL_SWITCH.is_empty() && !LAZINESS_ABORT_TIMEOUT.is_empty() && !LAZINESS_ABORT_CLASSIFIER_ERROR.is_empty(), "Laziness discriminator consts must be non-empty", ); /// Closed set of categories the Layer-3 classifier can return. /// /// Mirrors the JSON schema in the classifier prompt; `serde` uses the /// `LAZINESS_*` strings above as the wire format (snake_case). The /// `as_const_str()` mapping is exhaustive over the variants — the /// `laziness_category_round_trip` test asserts the variant ↔ const /// pairing is one-to-one. Mirrors the `TodoGateReason` pattern. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum LazinessCategory { StalledNarration, StalledPermissionAsking, StalledNoTodosButTaskInFlight, StalledFalseCompletion, NotStalledComplete, NotStalledWaitingOnBackground, NotStalledWaitingOnUser, } impl LazinessCategory { /// Returns the `LAZINESS_*` string for this variant. Exhaustive /// `match` — adding a variant forces a new const + arm. pub(crate) fn as_const_str(self) -> &'static str { match self { Self::StalledNarration => LAZINESS_STALLED_NARRATION, Self::StalledPermissionAsking => LAZINESS_STALLED_PERMISSION_ASKING, Self::StalledNoTodosButTaskInFlight => LAZINESS_STALLED_NO_TODOS_BUT_TASK_IN_FLIGHT, Self::StalledFalseCompletion => LAZINESS_STALLED_FALSE_COMPLETION, Self::NotStalledComplete => LAZINESS_NOT_STALLED_COMPLETE, Self::NotStalledWaitingOnBackground => LAZINESS_NOT_STALLED_WAITING_BG, Self::NotStalledWaitingOnUser => LAZINESS_NOT_STALLED_WAITING_USER, } } /// Four variants count as "stalled" and are eligible for a nudge. pub(crate) fn is_stalled(self) -> bool { match self { Self::StalledNarration | Self::StalledPermissionAsking | Self::StalledNoTodosButTaskInFlight | Self::StalledFalseCompletion => true, Self::NotStalledComplete | Self::NotStalledWaitingOnBackground | Self::NotStalledWaitingOnUser => false, } } /// Every variant of this enum. Used by the producer-consistency /// tests to enumerate the closed set rather than a hand-coded /// array that would silently drift if a new variant were added. /// `as_const_str` and `is_stalled` are compiler-enforced /// exhaustive matches; the `_assert_exhaustive` helper below is /// the cheap drift guard that forces this list to stay in sync /// (adding any variant without listing it here is a compile error). #[cfg_attr( not(test), expect( dead_code, reason = "exhaustiveness guard for tests; remove expect if used in prod" ) )] pub(crate) const fn all() -> &'static [Self] { // The exhaustive match in this helper wires the array length // to the variant count: adding a variant breaks `match` AND // the array's expected count. const fn _assert_exhaustive(c: LazinessCategory) { match c { LazinessCategory::StalledNarration => (), LazinessCategory::StalledPermissionAsking => (), LazinessCategory::StalledNoTodosButTaskInFlight => (), LazinessCategory::StalledFalseCompletion => (), LazinessCategory::NotStalledComplete => (), LazinessCategory::NotStalledWaitingOnBackground => (), LazinessCategory::NotStalledWaitingOnUser => (), } } &[ Self::StalledNarration, Self::StalledPermissionAsking, Self::StalledNoTodosButTaskInFlight, Self::StalledFalseCompletion, Self::NotStalledComplete, Self::NotStalledWaitingOnBackground, Self::NotStalledWaitingOnUser, ] } } // ── TodoGate discriminator vocabulary ───────────────────────────────── // // Source of truth for the `reason` field on `Event::TodoGateFired`. // Producer wraps these via `TodoGateReason::as_str()` (acp_session.rs). /// The TodoGate fired because a content-only turn ended with one or more /// pending or unbacked in-progress todos. pub const TODO_GATE_IN_FLIGHT: &str = "in_flight"; // Compile-time non-empty check — empty would silently break dashboards' group-by. #[allow(clippy::const_is_empty)] const _: () = assert!( !TODO_GATE_IN_FLIGHT.is_empty(), "TodoGate discriminator consts must be non-empty", ); /// Map a [`CancellationCategory`] to the [`PriorTurnInterrupt`] marker stamped /// onto the *next* real user turn — but only for the user-interruption subset. /// Automatic terminations (doom-loop, hook-denied) return `None`: they are not /// user interruptions, so the follow-up user message carries no marker. /// Exhaustive `match` (no wildcard) so a new `CancellationCategory` forces an /// explicit decision here. (Interjection has no `CancellationCategory` — it /// never cancels a turn — so it is mapped directly at the drain site.) pub(crate) fn prior_turn_interrupt_from_cancellation( category: CancellationCategory, ) -> Option { use kigi_sampling_types::PriorTurnInterrupt; match category { CancellationCategory::MidTurnAbort => Some(PriorTurnInterrupt::MidTurnAbort), CancellationCategory::PermissionRejected => Some(PriorTurnInterrupt::PermissionRejected), CancellationCategory::PermissionCancelled => Some(PriorTurnInterrupt::PermissionCancelled), CancellationCategory::HookDenied => None, } } // ── GoalClassifier discriminator vocabulary ─────────────────────────── // // Single source of truth for the `reason` field on // `Event::GoalClassifierFailOpen` / `Event::GoalClassifierFailClosed`. // The producer wraps the reason strings via the `as_const_str()` // methods on `GoalClassifierFailOpenReason` / `GoalClassifierFailClosedReason` // so the variant set is compiler-enforced; the consts here are the wire // vocabulary the dashboards group by. /// Fail-open — legacy wire string; runner does not emit. pub const GOAL_CLASSIFIER_FAIL_OPEN_TIMEOUT: &str = "timeout"; /// Fail-open — the subagent spawn returned an error (channel closed, /// coordinator rejected, etc.). INFRA-class. pub const GOAL_CLASSIFIER_FAIL_OPEN_SAMPLER_ERROR: &str = "sampler_error"; /// Fail-open — the user aborted the run mid-classification. pub const GOAL_CLASSIFIER_FAIL_OPEN_ABORTED: &str = "aborted"; /// Fail-open — the harness could not pre-create the details-file parent /// directory or otherwise prepare the on-disk state. pub const GOAL_CLASSIFIER_FAIL_OPEN_FILE_WRITE_FAILED: &str = "file_write_failed"; /// Fail-open — the goal was no longer Active when the runner resolved /// its inputs (status changed during the spawn). pub const GOAL_CLASSIFIER_FAIL_OPEN_GOAL_NOT_ACTIVE: &str = "goal_not_active_at_resolve"; /// Fail-closed — a second `update_goal(completed: true)` arrived /// while a verification stage was already in flight for this goal. /// Caller must not double-spawn. pub const GOAL_CLASSIFIER_FAIL_CLOSED_CONCURRENT: &str = "concurrent_in_flight"; /// Fail-closed — the deferred-completion queue overflowed and the /// oldest entry was dropped to make room for a newer one. PARSE-class /// analogue of `ConcurrentInFlight` for the runaway-`completed: true` /// path. pub const GOAL_CLASSIFIER_FAIL_CLOSED_PENDING_QUEUE_FULL: &str = "pending_queue_full"; // Compile-time non-empty guard (mirrors the laziness const block). #[allow(clippy::const_is_empty)] const _: () = assert!( !GOAL_CLASSIFIER_FAIL_OPEN_TIMEOUT.is_empty() && !GOAL_CLASSIFIER_FAIL_OPEN_SAMPLER_ERROR.is_empty() && !GOAL_CLASSIFIER_FAIL_OPEN_ABORTED.is_empty() && !GOAL_CLASSIFIER_FAIL_OPEN_FILE_WRITE_FAILED.is_empty() && !GOAL_CLASSIFIER_FAIL_OPEN_GOAL_NOT_ACTIVE.is_empty() && !GOAL_CLASSIFIER_FAIL_CLOSED_CONCURRENT.is_empty() && !GOAL_CLASSIFIER_FAIL_CLOSED_PENDING_QUEUE_FULL.is_empty(), "GoalClassifier discriminator consts must be non-empty", ); /// Closed set of fail-open reasons. Variants are INFRA-class: /// the harness could not verify the verdict and treats the goal as /// achieved to avoid blocking the user on an internal failure. /// /// `Timeout` is a legacy wire string production no longer emits; kept so /// dashboards joining on the historical string still parse cleanly. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GoalClassifierFailOpenReason { /// Legacy wire only — no production emitter today. #[cfg_attr( not(test), expect( dead_code, reason = "legacy wire string for dashboard joins; not emitted" ) )] Timeout, SamplerError, /// Retained for wire/dashboard compatibility; no emitter today. #[expect(dead_code, reason = "wire vocabulary; no production emitter today")] Aborted, FileWriteFailed, GoalNotActiveAtResolve, } impl GoalClassifierFailOpenReason { pub(crate) fn as_const_str(self) -> &'static str { match self { Self::Timeout => GOAL_CLASSIFIER_FAIL_OPEN_TIMEOUT, Self::SamplerError => GOAL_CLASSIFIER_FAIL_OPEN_SAMPLER_ERROR, Self::Aborted => GOAL_CLASSIFIER_FAIL_OPEN_ABORTED, Self::FileWriteFailed => GOAL_CLASSIFIER_FAIL_OPEN_FILE_WRITE_FAILED, Self::GoalNotActiveAtResolve => GOAL_CLASSIFIER_FAIL_OPEN_GOAL_NOT_ACTIVE, } } } /// PARSE-class fail-closed reasons. The verification stage routes /// per-skeptic parse failures (malformed terminal token, missing /// verdict JSON) through synthetic refute votes instead of this event /// vocabulary — so the only live variants here are the drain-path /// race guards (`ConcurrentInFlight`, `PendingQueueFull`). /// /// No longer emitted: `MalformedTerminalResponse`, `MissingDetailsFile`, /// `ChangesCaptureFailed`. A future strict-diff mode (where a `git /// diff` failure must fail-closed instead of degrading to /// `(unavailable)`) would re-add `ChangesCaptureFailed` here plus the /// `GOAL_CLASSIFIER_FAIL_CLOSED_CHANGES_CAPTURE_FAILED` wire constant /// and the matching `as_const_str` arm. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GoalClassifierFailClosedReason { ConcurrentInFlight, PendingQueueFull, } impl GoalClassifierFailClosedReason { pub(crate) fn as_const_str(self) -> &'static str { match self { Self::ConcurrentInFlight => GOAL_CLASSIFIER_FAIL_CLOSED_CONCURRENT, Self::PendingQueueFull => GOAL_CLASSIFIER_FAIL_CLOSED_PENDING_QUEUE_FULL, } } } // ── GoalPlanner discriminator vocabulary ────────────────────────────── // // The planner is fail-CLOSED by design (the opposite of the classifier). // Every reason here represents a path that pauses the goal — there is no // fail-open analogue. /// Planner subagent coordinator channel was unreachable. pub const GOAL_PLANNER_FAIL_CLOSED_TRANSPORT: &str = "transport"; /// Planner subagent reported a runtime failure. pub const GOAL_PLANNER_FAIL_CLOSED_RUNTIME: &str = "runtime"; /// User aborted the planner mid-run. pub const GOAL_PLANNER_FAIL_CLOSED_ABORTED: &str = "aborted"; /// Planner did not write `plan.md` (file missing or empty). pub const GOAL_PLANNER_FAIL_CLOSED_MISSING_PLAN: &str = "missing_plan_file"; /// Harness could not pre-create the plan-file parent directory. pub const GOAL_PLANNER_FAIL_CLOSED_FILE_WRITE_FAILED: &str = "file_write_failed"; #[allow(clippy::const_is_empty)] const _: () = assert!( !GOAL_PLANNER_FAIL_CLOSED_TRANSPORT.is_empty() && !GOAL_PLANNER_FAIL_CLOSED_RUNTIME.is_empty() && !GOAL_PLANNER_FAIL_CLOSED_ABORTED.is_empty() && !GOAL_PLANNER_FAIL_CLOSED_MISSING_PLAN.is_empty() && !GOAL_PLANNER_FAIL_CLOSED_FILE_WRITE_FAILED.is_empty(), "GoalPlanner discriminator consts must be non-empty", ); /// Closed set of planner fail-closed reasons. All variants pause the /// goal — there is no fail-open variant by design. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GoalPlannerFailClosedReason { Transport, Runtime, Aborted, MissingPlan, FileWriteFailed, } impl GoalPlannerFailClosedReason { pub(crate) fn as_const_str(self) -> &'static str { match self { Self::Transport => GOAL_PLANNER_FAIL_CLOSED_TRANSPORT, Self::Runtime => GOAL_PLANNER_FAIL_CLOSED_RUNTIME, Self::Aborted => GOAL_PLANNER_FAIL_CLOSED_ABORTED, Self::MissingPlan => GOAL_PLANNER_FAIL_CLOSED_MISSING_PLAN, Self::FileWriteFailed => GOAL_PLANNER_FAIL_CLOSED_FILE_WRITE_FAILED, } } } // ── GoalStrategist discriminator vocabulary ─────────────────────────── // // The strategist is fail-OPEN by design (the opposite of the planner). // Every reason here represents a path that is logged and then ignored — // the goal keeps running. The strategist is a best-effort advisory // enhancement, never a gate. /// Strategist subagent coordinator channel was unreachable. pub const GOAL_STRATEGIST_FAILED_TRANSPORT: &str = "transport"; /// Strategist subagent reported a runtime failure. pub const GOAL_STRATEGIST_FAILED_RUNTIME: &str = "runtime"; /// User aborted the strategist mid-run. pub const GOAL_STRATEGIST_FAILED_ABORTED: &str = "aborted"; /// Strategist did not write the strategy note (file missing or empty). pub const GOAL_STRATEGIST_FAILED_MISSING_STRATEGY: &str = "missing_strategy_file"; #[allow(clippy::const_is_empty)] const _: () = assert!( !GOAL_STRATEGIST_FAILED_TRANSPORT.is_empty() && !GOAL_STRATEGIST_FAILED_RUNTIME.is_empty() && !GOAL_STRATEGIST_FAILED_ABORTED.is_empty() && !GOAL_STRATEGIST_FAILED_MISSING_STRATEGY.is_empty(), "GoalStrategist discriminator consts must be non-empty", ); /// Closed set of strategist fail-open reasons. All variants are logged /// and ignored — the goal is never paused on strategist failure. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GoalStrategistFailReason { Transport, Runtime, Aborted, MissingStrategy, } impl GoalStrategistFailReason { pub(crate) fn as_const_str(self) -> &'static str { match self { Self::Transport => GOAL_STRATEGIST_FAILED_TRANSPORT, Self::Runtime => GOAL_STRATEGIST_FAILED_RUNTIME, Self::Aborted => GOAL_STRATEGIST_FAILED_ABORTED, Self::MissingStrategy => GOAL_STRATEGIST_FAILED_MISSING_STRATEGY, } } } /// The plan.md restore-guard could not put the contract back. A /// write to plan.md failed. pub const GOAL_STRATEGIST_RESTORE_WRITE_FAILED: &str = "write_failed"; /// Removing a strategist-created plan.md failed. pub const GOAL_STRATEGIST_RESTORE_REMOVE_FAILED: &str = "remove_failed"; /// plan.md is (or became) a symlink — refused to restore through it /// (treated as tampering). pub const GOAL_STRATEGIST_RESTORE_SYMLINK_TAMPER: &str = "symlink_tamper"; #[allow(clippy::const_is_empty)] const _: () = assert!( !GOAL_STRATEGIST_RESTORE_WRITE_FAILED.is_empty() && !GOAL_STRATEGIST_RESTORE_REMOVE_FAILED.is_empty() && !GOAL_STRATEGIST_RESTORE_SYMLINK_TAMPER.is_empty(), "GoalStrategist restore discriminator consts must be non-empty", ); /// Why the plan.md-safety guard could not guarantee the contract. Drives /// the `reason` on `Event::GoalStrategistContractRestoreFailed`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GoalStrategistRestoreFailReason { WriteFailed, RemoveFailed, SymlinkTamper, } impl GoalStrategistRestoreFailReason { pub(crate) fn as_const_str(self) -> &'static str { match self { Self::WriteFailed => GOAL_STRATEGIST_RESTORE_WRITE_FAILED, Self::RemoveFailed => GOAL_STRATEGIST_RESTORE_REMOVE_FAILED, Self::SymlinkTamper => GOAL_STRATEGIST_RESTORE_SYMLINK_TAMPER, } } } // ── GoalSummarizer discriminator vocabulary ─────────────────────────── // // The summarizer is fail-OPEN by design: it runs ONCE after the goal is // already verified-achieved, so every reason here is logged and ignored — // goal completion is never blocked, paused, or un-achieved. /// Summarizer subagent coordinator channel was unreachable. pub const GOAL_SUMMARIZER_FAIL_OPEN_TRANSPORT: &str = "transport"; /// Summarizer subagent reported a runtime failure. pub const GOAL_SUMMARIZER_FAIL_OPEN_RUNTIME: &str = "runtime"; /// User aborted the summarizer mid-run. pub const GOAL_SUMMARIZER_FAIL_OPEN_ABORTED: &str = "aborted"; /// Summarizer returned an empty (whitespace-only) summary — nothing to /// surface, so the closing message is skipped. pub const GOAL_SUMMARIZER_FAIL_OPEN_EMPTY_SUMMARY: &str = "empty_summary"; #[allow(clippy::const_is_empty)] const _: () = assert!( !GOAL_SUMMARIZER_FAIL_OPEN_TRANSPORT.is_empty() && !GOAL_SUMMARIZER_FAIL_OPEN_RUNTIME.is_empty() && !GOAL_SUMMARIZER_FAIL_OPEN_ABORTED.is_empty() && !GOAL_SUMMARIZER_FAIL_OPEN_EMPTY_SUMMARY.is_empty(), "GoalSummarizer discriminator consts must be non-empty", ); /// Closed set of summarizer fail-open reasons. All variants are logged /// and ignored — the goal is never un-achieved on summarizer failure. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GoalSummarizerFailReason { Transport, Runtime, Aborted, EmptySummary, } impl GoalSummarizerFailReason { pub(crate) fn as_const_str(self) -> &'static str { match self { Self::Transport => GOAL_SUMMARIZER_FAIL_OPEN_TRANSPORT, Self::Runtime => GOAL_SUMMARIZER_FAIL_OPEN_RUNTIME, Self::Aborted => GOAL_SUMMARIZER_FAIL_OPEN_ABORTED, Self::EmptySummary => GOAL_SUMMARIZER_FAIL_OPEN_EMPTY_SUMMARY, } } } // ── GoalRoleModel discriminator vocabulary ──────────────────────────── // // Source of truth for the `reason` field on `Event::GoalRoleModelFailOpen`. // Per-role model selection is fail-OPEN by design: a bad/unauthorized // model, an unusable toolset, or a harness whose flavor the subagent system // can't represent degrades that role (or skeptic index) to the current model + // session harness — the goal is never paused. // The producer (the goal spawn wiring / `resolve_goal_role_override` + // `spawn_with_fail_open_retry`) wraps the reason strings via // `GoalRoleModelFailOpenReason::as_const_str`; the consts here are the // wire vocabulary dashboards group by. The four `describe_subagent_type` // outcomes (Unknown / NotAllowed / Disabled / Unavailable) get // distinct wire strings because they distinguish config-bug from // infra-flakiness on the dashboard. /// Fail-open — the configured model id is not in the session's model /// catalog (`find_model_by_id` miss). pub const GOAL_ROLE_MODEL_FAIL_OPEN_MODEL_UNKNOWN: &str = "model_unknown"; /// Fail-open — the model is in the catalog but the session's /// `allowed_models` does not permit it (`ModelInfo.user_selectable == false`). pub const GOAL_ROLE_MODEL_FAIL_OPEN_MODEL_UNAUTHORIZED: &str = "model_unauthorized"; /// Fail-open — the configured `agent_type` did not resolve /// (`describe_subagent_type` ⇒ `Unknown`): either a subagent type, or a `/goal` /// harness name, that does not exist. pub const GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_UNKNOWN: &str = "toolset_unknown"; /// Fail-open — the `agent_type` exists but is not on the parent's /// allow-list (`describe_subagent_type` ⇒ `NotAllowed`). pub const GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_NOT_ALLOWED: &str = "toolset_not_allowed"; /// Fail-open — the `agent_type` is allow-listed but disabled /// (`describe_subagent_type` ⇒ `Disabled`). pub const GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_DISABLED: &str = "toolset_disabled"; /// Fail-open — the coordinator could not describe the toolset (channel /// closed / responder dropped / timeout; `describe_subagent_type` ⇒ /// `Unavailable`). Infra-flakiness, distinct from the config-bug cases. pub const GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_UNAVAILABLE: &str = "toolset_unavailable"; /// Fail-open — the toolset built but lacks the capabilities the role /// requires (e.g. a verifier whose toolset cannot read/grep code). pub const GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_INCAPABLE: &str = "toolset_incapable"; /// Fail-open — spawning the role with the configured pair returned a /// `SpawnError`; the spawn-and-retry-once wrapper retried on the current /// model. pub const GOAL_ROLE_MODEL_FAIL_OPEN_SPAWN_FAILED: &str = "spawn_failed"; /// Fail-open — the configured `agent_type` resolves as a STRICT harness whose /// subagent flavor `resolve_subagent_toolset` can't represent (e.g. `codex`): /// committing it would silently run kigi flavor. Distinct from /// `toolset_unknown` (a name that doesn't resolve at all). pub const GOAL_ROLE_MODEL_FAIL_OPEN_HARNESS_FLAVOR_UNSUPPORTED: &str = "harness_flavor_unsupported"; #[allow(clippy::const_is_empty)] const _: () = assert!( !GOAL_ROLE_MODEL_FAIL_OPEN_MODEL_UNKNOWN.is_empty() && !GOAL_ROLE_MODEL_FAIL_OPEN_MODEL_UNAUTHORIZED.is_empty() && !GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_UNKNOWN.is_empty() && !GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_NOT_ALLOWED.is_empty() && !GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_DISABLED.is_empty() && !GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_UNAVAILABLE.is_empty() && !GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_INCAPABLE.is_empty() && !GOAL_ROLE_MODEL_FAIL_OPEN_SPAWN_FAILED.is_empty() && !GOAL_ROLE_MODEL_FAIL_OPEN_HARNESS_FLAVOR_UNSUPPORTED.is_empty(), "GoalRoleModel discriminator consts must be non-empty", ); /// Closed set of per-role model-selection fail-open reasons (the `reason` /// wire strings above). Constructed by the goal spawn wiring; the wire /// vocabulary is the source of truth dashboards group by. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum GoalRoleModelFailOpenReason { ModelUnknown, ModelUnauthorized, ToolsetUnknown, ToolsetNotAllowed, ToolsetDisabled, ToolsetUnavailable, ToolsetIncapable, SpawnFailed, HarnessFlavorUnsupported, } impl GoalRoleModelFailOpenReason { pub(crate) fn as_const_str(self) -> &'static str { match self { Self::ModelUnknown => GOAL_ROLE_MODEL_FAIL_OPEN_MODEL_UNKNOWN, Self::ModelUnauthorized => GOAL_ROLE_MODEL_FAIL_OPEN_MODEL_UNAUTHORIZED, Self::ToolsetUnknown => GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_UNKNOWN, Self::ToolsetNotAllowed => GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_NOT_ALLOWED, Self::ToolsetDisabled => GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_DISABLED, Self::ToolsetUnavailable => GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_UNAVAILABLE, Self::ToolsetIncapable => GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_INCAPABLE, Self::SpawnFailed => GOAL_ROLE_MODEL_FAIL_OPEN_SPAWN_FAILED, Self::HarnessFlavorUnsupported => GOAL_ROLE_MODEL_FAIL_OPEN_HARNESS_FLAVOR_UNSUPPORTED, } } /// Every variant of this enum, enumerated alongside a compiler- /// enforced exhaustive `match` so adding a variant is a compile error /// until it is listed here (and given an `as_const_str` arm). Mirrors /// [`LazinessCategory::all`]; drives the exhaustiveness test. #[cfg_attr( not(test), expect(dead_code, reason = "exhaustiveness guard, exercised only by tests") )] pub(crate) const fn all() -> &'static [Self] { const fn _assert_exhaustive(r: GoalRoleModelFailOpenReason) { match r { GoalRoleModelFailOpenReason::ModelUnknown => (), GoalRoleModelFailOpenReason::ModelUnauthorized => (), GoalRoleModelFailOpenReason::ToolsetUnknown => (), GoalRoleModelFailOpenReason::ToolsetNotAllowed => (), GoalRoleModelFailOpenReason::ToolsetDisabled => (), GoalRoleModelFailOpenReason::ToolsetUnavailable => (), GoalRoleModelFailOpenReason::ToolsetIncapable => (), GoalRoleModelFailOpenReason::SpawnFailed => (), GoalRoleModelFailOpenReason::HarnessFlavorUnsupported => (), } } &[ Self::ModelUnknown, Self::ModelUnauthorized, Self::ToolsetUnknown, Self::ToolsetNotAllowed, Self::ToolsetDisabled, Self::ToolsetUnavailable, Self::ToolsetIncapable, Self::SpawnFailed, Self::HarnessFlavorUnsupported, ] } } /// Bridge `GoalClassifierVerdict` (shell) → `GoalClassifierVerdictTelemetry`. /// Mirrors the `GoalPauseReason` → `GoalPauseReasonTelemetry` pattern; /// exhaustive match catches drift if either side adds a variant. impl From for GoalClassifierVerdictTelemetry { fn from(verdict: crate::session::goal_tracker::GoalClassifierVerdict) -> Self { use crate::session::goal_tracker::GoalClassifierVerdict; match verdict { GoalClassifierVerdict::Achieved => Self::Achieved, GoalClassifierVerdict::NotAchieved => Self::NotAchieved, } } } /// Bridge `GoalPauseReason` (shell) → `GoalPauseReasonTelemetry` /// (`kigi-file-utils`). The two enums are intentional mirrors and live in /// different crates due to the orphan rule. The exhaustive `match` here /// ensures the compiler catches drift if either side adds a variant. impl From for GoalPauseReasonTelemetry { fn from(reason: crate::session::goal_tracker::GoalPauseReason) -> Self { use crate::session::goal_tracker::GoalPauseReason; match reason { GoalPauseReason::User => Self::User, GoalPauseReason::BackOff => Self::BackOff, GoalPauseReason::NoProgress => Self::NoProgress, GoalPauseReason::Verification => Self::Verification, GoalPauseReason::Infra => Self::Infra, } } } #[cfg(test)] mod tests { use super::*; use crate::session::goal_tracker::GoalPauseReason; #[test] fn prior_turn_interrupt_from_cancellation_maps_user_interrupts_only() { use kigi_sampling_types::PriorTurnInterrupt; // The three user-interrupt causes map to a marker. assert_eq!( prior_turn_interrupt_from_cancellation(CancellationCategory::MidTurnAbort), Some(PriorTurnInterrupt::MidTurnAbort) ); assert_eq!( prior_turn_interrupt_from_cancellation(CancellationCategory::PermissionRejected), Some(PriorTurnInterrupt::PermissionRejected) ); assert_eq!( prior_turn_interrupt_from_cancellation(CancellationCategory::PermissionCancelled), Some(PriorTurnInterrupt::PermissionCancelled) ); // Automatic terminations are NOT user interrupts → no marker. assert_eq!( prior_turn_interrupt_from_cancellation(CancellationCategory::HookDenied), None ); } #[test] fn goal_pause_reason_telemetry_mirrors_all_variants() { assert!(matches!( GoalPauseReasonTelemetry::from(GoalPauseReason::User), GoalPauseReasonTelemetry::User )); assert!(matches!( GoalPauseReasonTelemetry::from(GoalPauseReason::BackOff), GoalPauseReasonTelemetry::BackOff )); assert!(matches!( GoalPauseReasonTelemetry::from(GoalPauseReason::NoProgress), GoalPauseReasonTelemetry::NoProgress )); assert!(matches!( GoalPauseReasonTelemetry::from(GoalPauseReason::Verification), GoalPauseReasonTelemetry::Verification )); assert!(matches!( GoalPauseReasonTelemetry::from(GoalPauseReason::Infra), GoalPauseReasonTelemetry::Infra )); } /// Hand-curated expected pairings — kept here (not in `all()`) so /// the test can detect a desync between `as_const_str` and the /// `pub const` set. If a variant is added to the enum, `all()` /// grows AND `as_const_str` grows (both compiler-enforced); this /// table must also be extended, which the `assert_eq!(len)` below /// catches. const EXPECTED_CATEGORY_CONSTS: &[(LazinessCategory, &str)] = &[ ( LazinessCategory::StalledNarration, LAZINESS_STALLED_NARRATION, ), ( LazinessCategory::StalledPermissionAsking, LAZINESS_STALLED_PERMISSION_ASKING, ), ( LazinessCategory::StalledNoTodosButTaskInFlight, LAZINESS_STALLED_NO_TODOS_BUT_TASK_IN_FLIGHT, ), ( LazinessCategory::StalledFalseCompletion, LAZINESS_STALLED_FALSE_COMPLETION, ), ( LazinessCategory::NotStalledComplete, LAZINESS_NOT_STALLED_COMPLETE, ), ( LazinessCategory::NotStalledWaitingOnBackground, LAZINESS_NOT_STALLED_WAITING_BG, ), ( LazinessCategory::NotStalledWaitingOnUser, LAZINESS_NOT_STALLED_WAITING_USER, ), ]; #[test] fn laziness_category_all_covers_every_variant() { // Closed-set guard: if a 7th variant were added, // `LazinessCategory::all()`'s array would grow (and the // exhaustive `_assert_exhaustive` match in the impl would // force the array to grow too). The test then forces the // expected-table to grow via the length equality, and the // per-variant assertions below verify nothing was missed. let all = LazinessCategory::all(); assert_eq!( all.len(), EXPECTED_CATEGORY_CONSTS.len(), "LazinessCategory::all() and EXPECTED_CATEGORY_CONSTS drifted", ); let all_set: std::collections::BTreeSet<&LazinessCategory> = all.iter().collect(); let expected_set: std::collections::BTreeSet<&LazinessCategory> = EXPECTED_CATEGORY_CONSTS.iter().map(|(c, _)| c).collect(); assert_eq!( all_set, expected_set, "every variant in `all()` must also appear in EXPECTED_CATEGORY_CONSTS", ); } #[test] fn laziness_category_round_trip_through_const_str_and_serde() { // Single source of truth: every variant maps to one and only // one const, and the same const deserializes back to the // variant. Drives off `LazinessCategory::all()` so a new // variant must be added there (compiler-enforced via // `_assert_exhaustive`) for this test to even see it. for &(variant, expected_const) in EXPECTED_CATEGORY_CONSTS { assert_eq!(variant.as_const_str(), expected_const); let json = format!("\"{expected_const}\""); let parsed: LazinessCategory = serde_json::from_str(&json).expect("deserialize const back to variant"); assert_eq!(parsed, variant); } let unique: std::collections::BTreeSet<&'static str> = LazinessCategory::all() .iter() .map(|c| c.as_const_str()) .collect(); assert_eq!(unique.len(), LazinessCategory::all().len()); } #[test] fn laziness_stalled_false_completion_const_value_and_round_trip() { // Pin the wire string for the new category and prove the // const ↔ enum mapping is bijective. assert_eq!( LAZINESS_STALLED_FALSE_COMPLETION, "stalled_false_completion" ); assert_eq!( LazinessCategory::StalledFalseCompletion.as_const_str(), LAZINESS_STALLED_FALSE_COMPLETION, ); let parsed: LazinessCategory = serde_json::from_str("\"stalled_false_completion\"").expect("deserialize"); assert_eq!(parsed, LazinessCategory::StalledFalseCompletion); assert!(LazinessCategory::StalledFalseCompletion.is_stalled()); } #[test] fn laziness_is_stalled_matches_stalled_consts_only() { // Drive off `all()` so a new variant must be classified as // stalled or not-stalled — no variant escapes the test. let stalled_consts: std::collections::BTreeSet<&'static str> = [ LAZINESS_STALLED_NARRATION, LAZINESS_STALLED_PERMISSION_ASKING, LAZINESS_STALLED_NO_TODOS_BUT_TASK_IN_FLIGHT, LAZINESS_STALLED_FALSE_COMPLETION, ] .into_iter() .collect(); for &variant in LazinessCategory::all() { let expected = stalled_consts.contains(variant.as_const_str()); assert_eq!( variant.is_stalled(), expected, "is_stalled() disagrees with stalled-const set for {variant:?}", ); } } #[test] fn laziness_abort_reason_consts_are_distinct() { // Driven off `LazinessAbortReason::all()` so a new variant // must be added there (compiler-enforced via the exhaustive // match in `as_const_str`) before this test can see it. The // closed-set guarantee then lives in code, not in a hand-coded // test array. Note: the const array here is preserved // separately so a desync between `as_const_str` and the // `pub const` set is caught. let from_enum: std::collections::BTreeSet<&'static str> = crate::session::acp_session::LazinessAbortReason::all() .iter() .map(|r| r.as_const_str()) .collect(); let from_consts: std::collections::BTreeSet<&'static str> = [ LAZINESS_ABORT_USER_INPUT, LAZINESS_ABORT_MODEL_SWITCH, LAZINESS_ABORT_TIMEOUT, LAZINESS_ABORT_CLASSIFIER_ERROR, ] .into_iter() .collect(); assert_eq!( from_enum, from_consts, "LazinessAbortReason variants must map 1:1 to LAZINESS_ABORT_* consts", ); } /// Hand-curated `(variant, literal_str, const)` triples. `literal_str` /// is a HARDCODED string independent of the `pub const`, so a typo in /// a const *value* (e.g. `"model_unknown"` → `"model_unkown"`) is /// caught — the test asserts `as_const_str() == literal_str` AND /// `as_const_str() == const`. Kept separate from `all()` (which is /// compiler-enforced exhaustive via its `_assert_exhaustive` match) so /// a new variant forces this table to grow too — caught by the length /// equality below. const EXPECTED_FAIL_OPEN_CONSTS: &[(GoalRoleModelFailOpenReason, &str, &str)] = &[ ( GoalRoleModelFailOpenReason::ModelUnknown, "model_unknown", GOAL_ROLE_MODEL_FAIL_OPEN_MODEL_UNKNOWN, ), ( GoalRoleModelFailOpenReason::ModelUnauthorized, "model_unauthorized", GOAL_ROLE_MODEL_FAIL_OPEN_MODEL_UNAUTHORIZED, ), ( GoalRoleModelFailOpenReason::ToolsetUnknown, "toolset_unknown", GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_UNKNOWN, ), ( GoalRoleModelFailOpenReason::ToolsetNotAllowed, "toolset_not_allowed", GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_NOT_ALLOWED, ), ( GoalRoleModelFailOpenReason::ToolsetDisabled, "toolset_disabled", GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_DISABLED, ), ( GoalRoleModelFailOpenReason::ToolsetUnavailable, "toolset_unavailable", GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_UNAVAILABLE, ), ( GoalRoleModelFailOpenReason::ToolsetIncapable, "toolset_incapable", GOAL_ROLE_MODEL_FAIL_OPEN_TOOLSET_INCAPABLE, ), ( GoalRoleModelFailOpenReason::SpawnFailed, "spawn_failed", GOAL_ROLE_MODEL_FAIL_OPEN_SPAWN_FAILED, ), ( GoalRoleModelFailOpenReason::HarnessFlavorUnsupported, "harness_flavor_unsupported", GOAL_ROLE_MODEL_FAIL_OPEN_HARNESS_FLAVOR_UNSUPPORTED, ), ]; #[test] fn goal_role_model_fail_open_reason_all_covers_every_variant() { // Closed-set guard: `all()` grows (its `_assert_exhaustive` match // is compiler-enforced) when a variant is added, forcing the // expected-table to grow via this length equality. let all = GoalRoleModelFailOpenReason::all(); assert_eq!( all.len(), EXPECTED_FAIL_OPEN_CONSTS.len(), "GoalRoleModelFailOpenReason::all() and EXPECTED_FAIL_OPEN_CONSTS drifted", ); } #[test] fn goal_role_model_fail_open_reason_const_strs_are_snake_case_and_distinct() { // Pin every variant's wire string to a hardcoded literal (catches // a const-value typo), confirm it equals the `pub const`, that // it's snake_case, and that all are distinct. Driven off the // expected table whose length is tied to `all()`, so no variant // escapes the assertions. let mut seen = std::collections::BTreeSet::new(); for &(variant, literal_str, wire_const) in EXPECTED_FAIL_OPEN_CONSTS { // Independent literal pin — fails if a const value drifts. assert_eq!( variant.as_const_str(), literal_str, "{variant:?} wire string drifted from its pinned literal", ); // And the const must agree with the literal. assert_eq!( wire_const, literal_str, "const for {variant:?} drifted from its pinned literal", ); // snake_case wire strings only (lowercase + underscore). assert!( literal_str .chars() .all(|c| c.is_ascii_lowercase() || c == '_'), "wire string {literal_str:?} is not snake_case", ); assert!(seen.insert(variant.as_const_str()), "duplicate wire string"); } assert_eq!( seen.len(), GoalRoleModelFailOpenReason::all().len(), "every fail-open reason must map to a distinct wire string", ); } #[test] fn goal_summarizer_fail_reason_wire_strings() { // Table pins each variant's wire string to a hardcoded literal so a // const-value typo (e.g. on the e2e-exercised `runtime`) is caught. use GoalSummarizerFailReason as R; for (variant, literal) in [ (R::Transport, "transport"), (R::Runtime, "runtime"), (R::Aborted, "aborted"), (R::EmptySummary, "empty_summary"), ] { assert_eq!(variant.as_const_str(), literal, "{variant:?} drifted"); } } }