Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
374 lines
12 KiB
Rust
374 lines
12 KiB
Rust
//! Lock-state machine. Pure data + pure transition function. No I/O.
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Drop transient OS events for this window after a head-changing op;
|
|
/// consumers refresh from scratch anyway.
|
|
pub(crate) const COOLDOWN_MS: u64 = 500;
|
|
|
|
/// After a lock release, wait this long before declaring the operation
|
|
/// complete: a lock reappearing within the window (a rebase/squash cycles
|
|
/// `index.lock` per pick) is the *same* operation, so rapid cycles merge into
|
|
/// one `Started`/`Completed` pair instead of storming consumers.
|
|
pub const SETTLE_MS: u64 = 500;
|
|
|
|
/// Diagnostic threshold — fires a one-time warning when a lock is held
|
|
/// longer than this. `git gc` on huge repos can exceed this legitimately;
|
|
/// the state machine stays locked until the lock file disappears regardless.
|
|
const STALE_LOCK_SECS: u64 = 60;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum LockState {
|
|
Idle,
|
|
Locked {
|
|
head_at_start: Option<String>,
|
|
since: Instant,
|
|
},
|
|
/// Lock released, operation not yet declared complete. `head_at_start`
|
|
/// and `since` are carried from the first `Locked` entry of the merged
|
|
/// operation so re-locks preserve the op-wide HEAD comparison and the
|
|
/// stale-lock clock.
|
|
Settling {
|
|
head_at_start: Option<String>,
|
|
since: Instant,
|
|
until: Instant,
|
|
},
|
|
Cooldown {
|
|
until: Instant,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub(crate) enum LockTransition {
|
|
None,
|
|
Started,
|
|
/// Emitted on any `Locked → !Locked` transition. `head_changed` is the
|
|
/// HEAD comparison; cooldown begins iff true.
|
|
Completed {
|
|
head_changed: bool,
|
|
},
|
|
/// Cooldown timer expired; consumer never sees this — internal only.
|
|
CooldownEnded,
|
|
}
|
|
|
|
/// One step. Pure; mutates `state` from freshly-observed FS facts.
|
|
pub(crate) fn drive(
|
|
state: &mut LockState,
|
|
lock_present: bool,
|
|
head_now: Option<String>,
|
|
now: Instant,
|
|
cooldown: Duration,
|
|
) -> LockTransition {
|
|
match (state.clone(), lock_present) {
|
|
(LockState::Idle, true) | (LockState::Cooldown { .. }, true) => {
|
|
*state = LockState::Locked {
|
|
head_at_start: head_now,
|
|
since: now,
|
|
};
|
|
LockTransition::Started
|
|
}
|
|
// Same operation resumes: keep the op-start HEAD and `since` so the
|
|
// eventual Completed spans the whole merged op. No duplicate Started —
|
|
// consumers never saw a Completed, so their in-op flag never flipped.
|
|
(
|
|
LockState::Settling {
|
|
head_at_start,
|
|
since,
|
|
..
|
|
},
|
|
true,
|
|
) => {
|
|
*state = LockState::Locked {
|
|
head_at_start,
|
|
since,
|
|
};
|
|
LockTransition::None
|
|
}
|
|
// Don't complete yet: give a rapid re-lock the settle window to merge.
|
|
(
|
|
LockState::Locked {
|
|
head_at_start,
|
|
since,
|
|
},
|
|
false,
|
|
) => {
|
|
*state = LockState::Settling {
|
|
head_at_start,
|
|
since,
|
|
until: now + Duration::from_millis(SETTLE_MS),
|
|
};
|
|
LockTransition::None
|
|
}
|
|
(
|
|
LockState::Settling {
|
|
head_at_start,
|
|
until,
|
|
..
|
|
},
|
|
false,
|
|
) if now >= until => {
|
|
let head_changed = head_at_start.as_ref() != head_now.as_ref();
|
|
*state = if head_changed {
|
|
LockState::Cooldown {
|
|
until: now + cooldown,
|
|
}
|
|
} else {
|
|
LockState::Idle
|
|
};
|
|
LockTransition::Completed { head_changed }
|
|
}
|
|
(LockState::Cooldown { until }, false) if now >= until => {
|
|
*state = LockState::Idle;
|
|
LockTransition::CooldownEnded
|
|
}
|
|
_ => LockTransition::None,
|
|
}
|
|
}
|
|
|
|
/// `check` fires once per stale period; resets when the lock releases.
|
|
#[derive(Debug, Default)]
|
|
pub(crate) struct StaleWarn {
|
|
warned: bool,
|
|
}
|
|
|
|
impl StaleWarn {
|
|
pub(crate) fn check(&mut self, state: &LockState, now: Instant) -> Option<Duration> {
|
|
match state {
|
|
// Settling counts as held: `since` spans the merged operation, so
|
|
// a long rebase of short lock cycles still warns (once), and the
|
|
// latch doesn't reset in the sub-second gaps between cycles.
|
|
LockState::Locked { since, .. } | LockState::Settling { since, .. } => {
|
|
let elapsed = now.duration_since(*since);
|
|
if !self.warned && elapsed > Duration::from_secs(STALE_LOCK_SECS) {
|
|
self.warned = true;
|
|
return Some(elapsed);
|
|
}
|
|
None
|
|
}
|
|
_ => {
|
|
self.warned = false;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn cooldown() -> Duration {
|
|
Duration::from_millis(500)
|
|
}
|
|
|
|
#[test]
|
|
fn idle_to_locked_on_lock_appearance() {
|
|
let mut s = LockState::Idle;
|
|
let now = Instant::now();
|
|
assert_eq!(
|
|
drive(&mut s, true, Some("ref: main".into()), now, cooldown()),
|
|
LockTransition::Started
|
|
);
|
|
assert!(matches!(s, LockState::Locked { .. }));
|
|
}
|
|
|
|
/// A lock release no longer completes the operation; it opens the settle
|
|
/// window (rapid re-locks merge) and emits nothing.
|
|
#[test]
|
|
fn locked_to_settling_emits_nothing() {
|
|
let now = Instant::now();
|
|
let mut s = LockState::Locked {
|
|
head_at_start: Some("ref: main".into()),
|
|
since: now,
|
|
};
|
|
assert_eq!(
|
|
drive(&mut s, false, Some("ref: feature".into()), now, cooldown()),
|
|
LockTransition::None
|
|
);
|
|
match &s {
|
|
LockState::Settling {
|
|
head_at_start,
|
|
since,
|
|
until,
|
|
} => {
|
|
assert_eq!(head_at_start.as_deref(), Some("ref: main"));
|
|
assert_eq!(*since, now);
|
|
assert_eq!(*until, now + Duration::from_millis(SETTLE_MS));
|
|
}
|
|
other => panic!("expected Settling, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Re-lock inside the settle window: the same operation continues, so the
|
|
/// op-start HEAD and `since` are preserved and nothing is emitted (no
|
|
/// duplicate Started — the consumer's in-op flag never flipped).
|
|
#[test]
|
|
fn settling_relock_preserves_op_start_and_emits_nothing() {
|
|
let op_start = Instant::now();
|
|
let later = op_start + Duration::from_millis(100);
|
|
let mut s = LockState::Settling {
|
|
head_at_start: Some("ref: main".into()),
|
|
since: op_start,
|
|
until: later + Duration::from_millis(400),
|
|
};
|
|
assert_eq!(
|
|
drive(&mut s, true, Some("pick-1".into()), later, cooldown()),
|
|
LockTransition::None
|
|
);
|
|
assert_eq!(
|
|
s,
|
|
LockState::Locked {
|
|
head_at_start: Some("ref: main".into()),
|
|
since: op_start,
|
|
},
|
|
"op-start HEAD and since must survive the re-lock"
|
|
);
|
|
}
|
|
|
|
/// Settle expiry emits exactly one Completed comparing the first pick's
|
|
/// pre-op HEAD against the final HEAD (head_changed spans the merged op).
|
|
#[test]
|
|
fn settling_expiry_emits_completed_spanning_merged_op() {
|
|
let now = Instant::now();
|
|
let mut s = LockState::Settling {
|
|
head_at_start: Some("ref: main".into()),
|
|
since: now - Duration::from_secs(1),
|
|
until: now,
|
|
};
|
|
assert_eq!(
|
|
drive(&mut s, false, Some("pick-4".into()), now, cooldown()),
|
|
LockTransition::Completed { head_changed: true }
|
|
);
|
|
assert!(matches!(s, LockState::Cooldown { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn settling_expiry_head_unchanged_goes_idle() {
|
|
let now = Instant::now();
|
|
let mut s = LockState::Settling {
|
|
head_at_start: Some("ref: main".into()),
|
|
since: now - Duration::from_secs(1),
|
|
until: now,
|
|
};
|
|
assert_eq!(
|
|
drive(&mut s, false, Some("ref: main".into()), now, cooldown()),
|
|
LockTransition::Completed {
|
|
head_changed: false
|
|
}
|
|
);
|
|
assert_eq!(s, LockState::Idle);
|
|
}
|
|
|
|
#[test]
|
|
fn settling_before_expiry_emits_nothing() {
|
|
let now = Instant::now();
|
|
let mut s = LockState::Settling {
|
|
head_at_start: Some("ref: main".into()),
|
|
since: now,
|
|
until: now + Duration::from_millis(1),
|
|
};
|
|
assert_eq!(
|
|
drive(&mut s, false, Some("pick-1".into()), now, cooldown()),
|
|
LockTransition::None
|
|
);
|
|
assert!(matches!(s, LockState::Settling { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn cooldown_to_idle_after_timer() {
|
|
let start = Instant::now();
|
|
let mut s = LockState::Cooldown { until: start };
|
|
let later = start + Duration::from_millis(1);
|
|
assert_eq!(
|
|
drive(&mut s, false, None, later, cooldown()),
|
|
LockTransition::CooldownEnded
|
|
);
|
|
assert_eq!(s, LockState::Idle);
|
|
}
|
|
|
|
#[test]
|
|
fn cooldown_to_locked_on_re_acquire() {
|
|
let now = Instant::now();
|
|
let mut s = LockState::Cooldown {
|
|
until: now + Duration::from_millis(500),
|
|
};
|
|
assert_eq!(
|
|
drive(&mut s, true, Some("ref: main".into()), now, cooldown()),
|
|
LockTransition::Started
|
|
);
|
|
assert!(matches!(s, LockState::Locked { .. }));
|
|
}
|
|
|
|
/// Regression: timer-arm `drive()` must report Started so the consumer's
|
|
/// `in_op` flag flips; otherwise FilesChanged events skip buffering.
|
|
#[test]
|
|
fn cooldown_to_locked_when_lock_reappears_at_timer_fire() {
|
|
let now = Instant::now();
|
|
let mut s = LockState::Cooldown { until: now };
|
|
assert_eq!(
|
|
drive(&mut s, true, Some("ref: main".into()), now, cooldown()),
|
|
LockTransition::Started,
|
|
);
|
|
assert!(matches!(s, LockState::Locked { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn no_transition_when_idle_and_no_lock() {
|
|
let mut s = LockState::Idle;
|
|
assert_eq!(
|
|
drive(&mut s, false, None, Instant::now(), cooldown()),
|
|
LockTransition::None
|
|
);
|
|
assert_eq!(s, LockState::Idle);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_warn_fires_once_per_stale_period() {
|
|
let now = Instant::now();
|
|
let s = LockState::Locked {
|
|
head_at_start: None,
|
|
since: now - Duration::from_secs(STALE_LOCK_SECS + 1),
|
|
};
|
|
let mut w = StaleWarn::default();
|
|
assert!(w.check(&s, now).is_some());
|
|
// Second check while still Locked: latched, no re-fire.
|
|
assert!(w.check(&s, now).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn stale_warn_resets_when_lock_releases() {
|
|
let now = Instant::now();
|
|
let locked = LockState::Locked {
|
|
head_at_start: None,
|
|
since: now - Duration::from_secs(STALE_LOCK_SECS + 1),
|
|
};
|
|
let mut w = StaleWarn::default();
|
|
assert!(w.check(&locked, now).is_some());
|
|
assert!(w.check(&LockState::Idle, now).is_none());
|
|
// Re-acquire: should fire again.
|
|
assert!(w.check(&locked, now).is_some());
|
|
}
|
|
|
|
/// A long rebase made of short lock cycles: `since` spans the merged op,
|
|
/// so the warning fires once past the threshold and the settle gaps
|
|
/// between cycles neither reset the latch nor re-fire it.
|
|
#[test]
|
|
fn stale_warn_spans_merged_op_and_stays_latched_through_settling() {
|
|
let now = Instant::now();
|
|
let op_start = now - Duration::from_secs(STALE_LOCK_SECS + 1);
|
|
let settling = LockState::Settling {
|
|
head_at_start: None,
|
|
since: op_start,
|
|
until: now + Duration::from_millis(SETTLE_MS),
|
|
};
|
|
let locked = LockState::Locked {
|
|
head_at_start: None,
|
|
since: op_start,
|
|
};
|
|
let mut w = StaleWarn::default();
|
|
assert!(w.check(&settling, now).is_some(), "settling counts as held");
|
|
assert!(w.check(&locked, now).is_none(), "latched across re-lock");
|
|
assert!(w.check(&settling, now).is_none(), "latched across release");
|
|
}
|
|
}
|