Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/events.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

1008 lines
43 KiB
Rust

//! 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<kigi_sampling_types::PriorTurnInterrupt> {
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<crate::session::goal_tracker::GoalClassifierVerdict> 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<crate::session::goal_tracker::GoalPauseReason> 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");
}
}
}