M0: compilable skeleton — Kigi 0.1.0 fork surgery
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).
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
#![allow(unused_imports)]
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use agent_client_protocol as acp;
|
||||
use tokio::sync::{Notify, mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use crate::extensions::notification::{SessionNotification, SessionUpdate};
|
||||
use crate::session::{
|
||||
self, SessionCommand, SessionHandle, SessionThread,
|
||||
commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult},
|
||||
fs_watch::FsWatchCapabilities, info::Info as SessionInfo,
|
||||
};
|
||||
use crate::terminal::AsyncTerminalRunner;
|
||||
use crate::tools::ToolContext;
|
||||
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
use kigi_tools::implementations::grok_build::task::types::*;
|
||||
use kigi_workspace::file_system::AsyncFileSystem;
|
||||
use kigi_hunk_tracker::HunkTrackerHandle;
|
||||
use super::*;
|
||||
impl SubagentCoordinator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending: HashMap::new(),
|
||||
active: HashMap::new(),
|
||||
completed: HashMap::new(),
|
||||
completion_notify: Arc::new(Notify::new()),
|
||||
pending_completions: Vec::new(),
|
||||
is_turn_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
running_gauge: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
block_wait_slots: HashMap::new(),
|
||||
subagent_usage_not_applied_prompts: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
pub fn mark_subagent_usage_not_applied(&mut self, prompt_id: &str) {
|
||||
self.subagent_usage_not_applied_prompts.insert(prompt_id.to_string());
|
||||
}
|
||||
pub fn subagent_usage_not_applied(&self, prompt_id: &str) -> bool {
|
||||
self.subagent_usage_not_applied_prompts.contains(prompt_id)
|
||||
}
|
||||
pub fn clear_subagent_usage_not_applied(&mut self, prompt_id: &str) {
|
||||
self.subagent_usage_not_applied_prompts.remove(prompt_id);
|
||||
}
|
||||
pub fn parent_prompt_id_for(&self, subagent_id: &str) -> Option<String> {
|
||||
self.active
|
||||
.get(subagent_id)
|
||||
.and_then(|t| t.parent_prompt_id.clone())
|
||||
.or_else(|| {
|
||||
self.pending.get(subagent_id).and_then(|p| p.parent_prompt_id.clone())
|
||||
})
|
||||
}
|
||||
/// Rebind the running-subagent gauge, copying the current count so a
|
||||
/// late rebind cannot under-report.
|
||||
pub fn set_running_gauge(&mut self, gauge: Arc<std::sync::atomic::AtomicUsize>) {
|
||||
gauge
|
||||
.store(
|
||||
self.pending.len() + self.active.len(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
self.running_gauge = gauge;
|
||||
}
|
||||
/// Recompute the gauge from `pending` + `active` after every mutation of
|
||||
/// either map — recomputing (rather than incrementing) prevents drift.
|
||||
fn sync_running_gauge(&self) {
|
||||
self.running_gauge
|
||||
.store(
|
||||
self.pending.len() + self.active.len(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
/// Returns a handle to the completion [`Notify`].
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(
|
||||
dead_code,
|
||||
reason = "used from tests only; remove expect when wired in production"
|
||||
)
|
||||
)]
|
||||
pub fn completion_notify(&self) -> Arc<Notify> {
|
||||
Arc::clone(&self.completion_notify)
|
||||
}
|
||||
/// Returns a shared handle to the turn-active flag.
|
||||
pub fn turn_active_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
|
||||
Arc::clone(&self.is_turn_active)
|
||||
}
|
||||
/// Whether the model's turn is currently active.
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(
|
||||
dead_code,
|
||||
reason = "used from tests only; remove expect when wired in production"
|
||||
)
|
||||
)]
|
||||
pub fn is_turn_active(&self) -> bool {
|
||||
self.is_turn_active.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// Pending + active turn-blocking subagent IDs for `prompt_id`.
|
||||
/// Background children are excluded: they outlive the turn by design, so
|
||||
/// the freeze drain must not wait on them (their spend reaches the session
|
||||
/// ledger when they finish; the prompt report flags them via
|
||||
/// `background_live`).
|
||||
pub fn outstanding_for_prompt(&self, prompt_id: &str) -> Vec<String> {
|
||||
let mut ids: Vec<String> = self
|
||||
.pending
|
||||
.values()
|
||||
.filter(|p| {
|
||||
p.parent_prompt_id.as_deref() == Some(prompt_id) && !p.run_in_background
|
||||
})
|
||||
.map(|p| p.subagent_id.clone())
|
||||
.chain(
|
||||
self
|
||||
.active
|
||||
.values()
|
||||
.filter(|t| {
|
||||
t.parent_prompt_id.as_deref() == Some(prompt_id)
|
||||
&& !t.run_in_background
|
||||
})
|
||||
.map(|t| t.subagent_id.clone()),
|
||||
)
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
/// True while any background child of `prompt_id` is pending or active.
|
||||
/// Their spend is missing from the prompt report (it lands on the session
|
||||
/// ledger at completion), so the report is incomplete — without waiting.
|
||||
pub fn background_live_for_prompt(&self, prompt_id: &str) -> bool {
|
||||
self
|
||||
.pending
|
||||
.values()
|
||||
.any(|p| {
|
||||
p.parent_prompt_id.as_deref() == Some(prompt_id) && p.run_in_background
|
||||
})
|
||||
|| self
|
||||
.active
|
||||
.values()
|
||||
.any(|t| {
|
||||
t.parent_prompt_id.as_deref() == Some(prompt_id)
|
||||
&& t.run_in_background
|
||||
})
|
||||
}
|
||||
/// Record that a foreground child was auto-backgrounded (await budget
|
||||
/// expired): it no longer blocks the turn, so the freeze drain must stop
|
||||
/// waiting on it.
|
||||
pub fn mark_backgrounded(&mut self, subagent_id: &str) {
|
||||
if let Some(t) = self.active.values_mut().find(|t| t.subagent_id == subagent_id)
|
||||
{
|
||||
t.run_in_background = true;
|
||||
}
|
||||
if let Some(p) = self.pending.values_mut().find(|p| p.subagent_id == subagent_id)
|
||||
{
|
||||
p.run_in_background = true;
|
||||
}
|
||||
}
|
||||
pub fn outstanding_reply_for_prompt(
|
||||
&self,
|
||||
prompt_id: &str,
|
||||
) -> kigi_tools::implementations::grok_build::task::types::SubagentOutstandingReply {
|
||||
kigi_tools::implementations::grok_build::task::types::SubagentOutstandingReply {
|
||||
live_ids: self.outstanding_for_prompt(prompt_id),
|
||||
background_live: self.background_live_for_prompt(prompt_id),
|
||||
subagent_usage_not_applied: self.subagent_usage_not_applied(prompt_id),
|
||||
}
|
||||
}
|
||||
/// Drain all buffered completion summaries, returning them and clearing the buffer.
|
||||
pub fn drain_pending_completions(&mut self) -> Vec<SubagentCompletionSummary> {
|
||||
std::mem::take(&mut self.pending_completions)
|
||||
}
|
||||
/// Register a subagent as pending (initializing). Call this early,
|
||||
/// before any blocking work like worktree creation, so that
|
||||
/// `get_task_output` can report the subagent as initializing instead
|
||||
/// of "not found".
|
||||
pub fn insert_pending(&mut self, entry: PendingSubagent) {
|
||||
self.pending.insert(entry.subagent_id.clone(), entry);
|
||||
self.sync_running_gauge();
|
||||
}
|
||||
/// Remove a pending subagent without recording a failure.
|
||||
/// Used by cancel flows where the subagent was intentionally stopped.
|
||||
#[cfg(test)]
|
||||
pub fn remove_pending(&mut self, id: &str) {
|
||||
self.pending.remove(id);
|
||||
self.sync_running_gauge();
|
||||
}
|
||||
/// Move a pending subagent directly to `completed` so it stays queryable via
|
||||
/// `get_task_output`. `cancelled` stamps `"cancelled"` vs `"failed"`.
|
||||
fn move_pending_to_terminal(&mut self, id: &str, error: &str, cancelled: bool) {
|
||||
let Some(pending) = self.pending.remove(id) else {
|
||||
return;
|
||||
};
|
||||
self.record_failure_completion(FailureCompletion {
|
||||
subagent_id: pending.subagent_id,
|
||||
subagent_type: pending.subagent_type,
|
||||
description: pending.description,
|
||||
parent_prompt_id: pending.parent_prompt_id,
|
||||
parent_session_id: pending.parent_session_id,
|
||||
persona: pending.persona,
|
||||
started_at: pending.started_at,
|
||||
error,
|
||||
surface_completion: pending.surface_completion,
|
||||
cancelled,
|
||||
});
|
||||
}
|
||||
/// Move a pending subagent to `completed` as a failure so it stays queryable
|
||||
/// via `get_task_output`.
|
||||
pub fn move_pending_to_failed(&mut self, id: &str, error: &str) {
|
||||
self.move_pending_to_terminal(id, error, false);
|
||||
}
|
||||
/// Like [`Self::move_pending_to_failed`] but stamps `"cancelled"` — a pending
|
||||
/// subagent killed while initializing.
|
||||
pub fn move_pending_to_cancelled(&mut self, id: &str, error: &str) {
|
||||
self.move_pending_to_terminal(id, error, true);
|
||||
}
|
||||
/// Record a synthetic failure for a subagent that never reached `pending`.
|
||||
pub fn record_pre_spawn_failure(
|
||||
&mut self,
|
||||
subagent_id: String,
|
||||
subagent_type: String,
|
||||
description: String,
|
||||
parent_prompt_id: Option<String>,
|
||||
parent_session_id: String,
|
||||
error: &str,
|
||||
surface_completion: bool,
|
||||
) {
|
||||
self.record_failure_completion(FailureCompletion {
|
||||
subagent_id,
|
||||
subagent_type,
|
||||
description,
|
||||
parent_prompt_id,
|
||||
parent_session_id,
|
||||
persona: None,
|
||||
started_at: std::time::Instant::now(),
|
||||
error,
|
||||
surface_completion,
|
||||
cancelled: false,
|
||||
});
|
||||
}
|
||||
/// Insert a synthetic failed entry, push a completion summary, notify waiters.
|
||||
/// Clears any stale pending entry for the same id.
|
||||
fn record_failure_completion(&mut self, c: FailureCompletion<'_>) {
|
||||
self.pending.remove(&c.subagent_id);
|
||||
self.sync_running_gauge();
|
||||
let FailureCompletion {
|
||||
subagent_id,
|
||||
subagent_type,
|
||||
description,
|
||||
parent_prompt_id,
|
||||
parent_session_id,
|
||||
persona,
|
||||
started_at,
|
||||
error,
|
||||
surface_completion,
|
||||
cancelled,
|
||||
} = c;
|
||||
let result = SubagentResult {
|
||||
success: false,
|
||||
cancelled,
|
||||
error: Some(error.to_string()),
|
||||
subagent_id: subagent_id.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let summary_output = result.output.clone();
|
||||
self.completed
|
||||
.insert(
|
||||
subagent_id.clone(),
|
||||
CompletedSubagent {
|
||||
subagent_id: subagent_id.clone(),
|
||||
parent_session_id,
|
||||
parent_prompt_id,
|
||||
child_session_id: String::new(),
|
||||
description: description.clone(),
|
||||
subagent_type: subagent_type.clone(),
|
||||
persona,
|
||||
started_at,
|
||||
completed_at: std::time::Instant::now(),
|
||||
result,
|
||||
resumed_from: None,
|
||||
child_cwd: String::new(),
|
||||
worktree_path: None,
|
||||
snapshot_ref: None,
|
||||
effective_model_id: String::new(),
|
||||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
},
|
||||
);
|
||||
if surface_completion {
|
||||
self.pending_completions
|
||||
.push(SubagentCompletionSummary {
|
||||
subagent_id,
|
||||
subagent_type,
|
||||
description,
|
||||
success: false,
|
||||
duration_ms: 0,
|
||||
tool_calls: 0,
|
||||
turns: 0,
|
||||
output: summary_output,
|
||||
});
|
||||
}
|
||||
self.completion_notify.notify_waiters();
|
||||
}
|
||||
pub fn insert(&mut self, tracker: SubagentTracker) {
|
||||
self.pending.remove(&tracker.subagent_id);
|
||||
self.active.insert(tracker.subagent_id.clone(), tracker);
|
||||
self.sync_running_gauge();
|
||||
}
|
||||
/// Move a finished subagent from `active` to `completed`.
|
||||
/// Returns the tracker if it was active.
|
||||
pub fn move_to_completed(
|
||||
&mut self,
|
||||
id: &str,
|
||||
description: String,
|
||||
subagent_type: String,
|
||||
result: SubagentResult,
|
||||
) -> Option<SubagentTracker> {
|
||||
let tracker = self.active.remove(id);
|
||||
self.sync_running_gauge();
|
||||
let started_at = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.started_at)
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
let parent_session_id = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.parent_session_id.clone())
|
||||
.unwrap_or_default();
|
||||
let child_session_id = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.child_session_id.0.to_string())
|
||||
.unwrap_or_default();
|
||||
let parent_prompt_id = tracker.as_ref().and_then(|t| t.parent_prompt_id.clone());
|
||||
let persona = tracker.as_ref().and_then(|t| t.persona.clone());
|
||||
let child_cwd = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.child_cwd.clone())
|
||||
.unwrap_or_default();
|
||||
let worktree_path = tracker.as_ref().and_then(|t| t.worktree_path.clone());
|
||||
let resumed_from = tracker.as_ref().and_then(|t| t.resumed_from.clone());
|
||||
let effective_model_id = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.effective_model_id.clone())
|
||||
.unwrap_or_default();
|
||||
let block_waited = tracker.as_ref().is_some_and(|t| t.block_waited);
|
||||
let explicitly_killed = tracker.as_ref().is_some_and(|t| t.explicitly_killed);
|
||||
let surface_completion = tracker.as_ref().is_none_or(|t| t.surface_completion);
|
||||
self.completed
|
||||
.insert(
|
||||
id.to_string(),
|
||||
CompletedSubagent {
|
||||
subagent_id: id.to_string(),
|
||||
parent_session_id,
|
||||
parent_prompt_id,
|
||||
child_session_id,
|
||||
description,
|
||||
subagent_type,
|
||||
persona,
|
||||
started_at,
|
||||
completed_at: std::time::Instant::now(),
|
||||
result,
|
||||
resumed_from,
|
||||
child_cwd,
|
||||
worktree_path,
|
||||
snapshot_ref: None,
|
||||
effective_model_id,
|
||||
block_waited,
|
||||
explicitly_killed,
|
||||
},
|
||||
);
|
||||
let completed = self.completed.get(id).expect("just inserted");
|
||||
let success = completed.result.success && !completed.result.cancelled;
|
||||
{
|
||||
let preview = crate::util::truncate(&completed.result.output, 200);
|
||||
let level_fn = if success {
|
||||
kigi_log::unified_log::info
|
||||
} else {
|
||||
kigi_log::unified_log::error
|
||||
};
|
||||
level_fn(
|
||||
if success { "subagent completed" } else { "subagent failed" },
|
||||
None,
|
||||
Some(
|
||||
serde_json::json!(
|
||||
{ "subagent_id" : & completed.subagent_id, "subagent_type" : &
|
||||
completed.subagent_type, "effective_model" : & completed
|
||||
.effective_model_id, "success" : success, "cancelled" : completed
|
||||
.result.cancelled, "duration_ms" : completed.result.duration_ms,
|
||||
"turns" : completed.result.turns, "tool_calls" : completed.result
|
||||
.tool_calls, "output_preview" : preview, "error" : & completed
|
||||
.result.error, }
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if surface_completion {
|
||||
self.pending_completions
|
||||
.push(SubagentCompletionSummary {
|
||||
subagent_id: id.to_string(),
|
||||
subagent_type: completed.subagent_type.clone(),
|
||||
description: completed.description.clone(),
|
||||
success,
|
||||
duration_ms: completed.result.duration_ms,
|
||||
tool_calls: completed.result.tool_calls,
|
||||
turns: completed.result.turns,
|
||||
output: completed.result.output.clone(),
|
||||
});
|
||||
}
|
||||
self.completion_notify.notify_waiters();
|
||||
tracker
|
||||
}
|
||||
/// Record the durable worktree snapshot ref on a completed subagent so
|
||||
/// in-memory `resume_from` resolution can rehydrate the disposed worktree.
|
||||
/// No-op if the entry was already evicted (the on-disk meta.json still has it).
|
||||
pub fn set_completed_snapshot_ref(&mut self, id: &str, snapshot_ref: String) {
|
||||
if let Some(completed) = self.completed.get_mut(id) {
|
||||
completed.snapshot_ref = Some(snapshot_ref);
|
||||
}
|
||||
}
|
||||
/// Cancel all active subagents that were launched by a specific parent turn,
|
||||
/// including `run_in_background: true` subagents.
|
||||
pub fn cancel_by_parent_prompt_id(&mut self, parent_prompt_id: &str) {
|
||||
for tracker in self.active.values() {
|
||||
if tracker.parent_prompt_id.as_deref() == Some(parent_prompt_id) {
|
||||
Self::cancel_tracker(tracker);
|
||||
}
|
||||
}
|
||||
for pending in self.pending.values() {
|
||||
if pending.parent_prompt_id.as_deref() == Some(parent_prompt_id) {
|
||||
pending.cancel_token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Attempt to cancel a subagent. Returns a typed outcome covering all cases:
|
||||
/// - Active → cancel it, return Cancelled
|
||||
/// - Pending (initializing) → fire its spawn token, return Cancelled
|
||||
/// - Already finished → return AlreadyFinished with terminal status
|
||||
/// - Unknown ID → return NotFound
|
||||
pub fn cancel_with_outcome(&mut self, subagent_id: &str) -> SubagentCancelOutcome {
|
||||
if let Some(tracker) = self.active.get(subagent_id) {
|
||||
Self::cancel_tracker(tracker);
|
||||
return SubagentCancelOutcome::Cancelled;
|
||||
}
|
||||
if let Some(pending) = self.pending.get(subagent_id) {
|
||||
pending.cancel_token.cancel();
|
||||
return SubagentCancelOutcome::Cancelled;
|
||||
}
|
||||
if let Some(entry) = self.completed.get(subagent_id) {
|
||||
return SubagentCancelOutcome::AlreadyFinished {
|
||||
status: entry.result.status().to_string(),
|
||||
};
|
||||
}
|
||||
SubagentCancelOutcome::NotFound
|
||||
}
|
||||
/// Internal: send Cancel + Shutdown to a tracked subagent.
|
||||
fn cancel_tracker(tracker: &SubagentTracker) {
|
||||
tracker.cancel_token.cancel();
|
||||
let _ = tracker
|
||||
.child_handle
|
||||
.cmd_tx
|
||||
.send(SessionCommand::Cancel {
|
||||
cancel_subagents: true,
|
||||
kill_background_tasks: true,
|
||||
rewind_if_pristine: false,
|
||||
trigger: None,
|
||||
});
|
||||
let _ = tracker.child_handle.cmd_tx.send(SessionCommand::Shutdown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
#![allow(unused_imports)]
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use agent_client_protocol as acp;
|
||||
use tokio::sync::{Notify, mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use crate::extensions::notification::{SessionNotification, SessionUpdate};
|
||||
use crate::session::{
|
||||
self, SessionCommand, SessionHandle, SessionThread,
|
||||
commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult},
|
||||
fs_watch::FsWatchCapabilities, info::Info as SessionInfo,
|
||||
};
|
||||
use crate::terminal::AsyncTerminalRunner;
|
||||
use crate::tools::ToolContext;
|
||||
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
use kigi_tools::implementations::grok_build::task::types::*;
|
||||
use kigi_workspace::file_system::AsyncFileSystem;
|
||||
use kigi_hunk_tracker::HunkTrackerHandle;
|
||||
use super::*;
|
||||
impl SubagentCoordinator {
|
||||
/// Synchronous lookup of a subagent by ID.
|
||||
///
|
||||
/// Returns a three-way result so the caller can drop the `RefCell` borrow
|
||||
/// before awaiting the signals handle for running subagents.
|
||||
///
|
||||
/// - `Ready` — completed/failed/cancelled snapshot, no async work needed.
|
||||
/// - `NeedsSignals` — subagent is running; caller must await
|
||||
/// `resolve_snapshot()` after dropping the coordinator borrow.
|
||||
/// - `None` — ID not found in active, completed, or pending maps.
|
||||
pub(crate) fn lookup(&self, id: &str) -> Option<SnapshotLookup> {
|
||||
if let Some(tracker) = self.active.get(id) {
|
||||
return Some(
|
||||
SnapshotLookup::NeedsSignals(RunningSnapshotSeed {
|
||||
subagent_id: tracker.subagent_id.clone(),
|
||||
description: tracker.description.clone(),
|
||||
subagent_type: tracker.subagent_type.clone(),
|
||||
started_at_epoch_ms: instant_to_epoch_ms(tracker.started_at),
|
||||
duration_ms: tracker.started_at.elapsed().as_millis() as u64,
|
||||
persona: tracker.persona.clone(),
|
||||
signals_handle: tracker.child_handle.signals_handle.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(completed) = self.completed.get(id) {
|
||||
let status = if completed.result.cancelled {
|
||||
SubagentSnapshotStatus::Cancelled {
|
||||
reason: completed.result.error.clone(),
|
||||
}
|
||||
} else if completed.result.success {
|
||||
SubagentSnapshotStatus::Completed {
|
||||
output: completed.result.output.to_string(),
|
||||
tool_calls: completed.result.tool_calls,
|
||||
turns: completed.result.turns,
|
||||
worktree_path: completed.result.worktree_path.clone(),
|
||||
}
|
||||
} else {
|
||||
SubagentSnapshotStatus::Failed {
|
||||
error: completed
|
||||
.result
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown error".to_string()),
|
||||
}
|
||||
};
|
||||
return Some(
|
||||
SnapshotLookup::Ready(SubagentSnapshot {
|
||||
subagent_id: completed.subagent_id.clone(),
|
||||
description: completed.description.clone(),
|
||||
subagent_type: completed.subagent_type.clone(),
|
||||
status,
|
||||
started_at_epoch_ms: instant_to_epoch_ms(completed.started_at),
|
||||
duration_ms: completed.result.duration_ms,
|
||||
persona: completed.persona.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(pending) = self.pending.get(id) {
|
||||
return Some(
|
||||
SnapshotLookup::Ready(SubagentSnapshot {
|
||||
subagent_id: pending.subagent_id.clone(),
|
||||
description: pending.description.clone(),
|
||||
subagent_type: pending.subagent_type.clone(),
|
||||
status: SubagentSnapshotStatus::Initializing,
|
||||
started_at_epoch_ms: instant_to_epoch_ms(pending.started_at),
|
||||
duration_ms: pending.started_at.elapsed().as_millis() as u64,
|
||||
persona: pending.persona.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Return `(parent_session_id, child_session_id)` for a given subagent.
|
||||
///
|
||||
/// Checks active first, then completed. Returns `None` if not found.
|
||||
pub(crate) fn session_ids_for(&self, id: &str) -> Option<(String, String)> {
|
||||
if let Some(t) = self.active.get(id) {
|
||||
return Some((t.parent_session_id.clone(), t.child_session_id.0.to_string()));
|
||||
}
|
||||
if let Some(c) = self.completed.get(id) {
|
||||
return Some((c.parent_session_id.clone(), c.child_session_id.clone()));
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Mark a subagent as block-waited so auto-wake is suppressed on completion.
|
||||
pub(crate) fn mark_block_waited(&mut self, id: &str) {
|
||||
if let Some(t) = self.active.get_mut(id) {
|
||||
t.block_waited = true;
|
||||
} else if let Some(c) = self.completed.get_mut(id) {
|
||||
c.block_waited = true;
|
||||
}
|
||||
}
|
||||
/// Clear the block-waited flag after a block timed out without receiving
|
||||
/// the completion, so auto-wake can still fire when the subagent finishes.
|
||||
pub(crate) fn clear_block_waited(&mut self, id: &str) {
|
||||
if let Some(t) = self.active.get_mut(id) {
|
||||
t.block_waited = false;
|
||||
} else if let Some(c) = self.completed.get_mut(id) {
|
||||
c.block_waited = false;
|
||||
}
|
||||
}
|
||||
/// Whether a block-waiter already consumed this subagent's result.
|
||||
pub(crate) fn is_block_waited(&self, id: &str) -> bool {
|
||||
self.active.get(id).is_some_and(|t| t.block_waited)
|
||||
|| self.completed.get(id).is_some_and(|c| c.block_waited)
|
||||
}
|
||||
/// Register a live blocking-query reply slot and mark `block_waited`.
|
||||
///
|
||||
/// The slot lets `block_wait_delivered_or_live` verify at completion
|
||||
/// time that the waiter can still receive the result — the flag alone
|
||||
/// can be stale when the waiting turn was cancelled moments before the
|
||||
/// subagent finished.
|
||||
pub(crate) fn register_block_wait(&mut self, id: &str, slot: BlockWaitSlot) {
|
||||
self.mark_block_waited(id);
|
||||
self.block_wait_slots.entry(id.to_string()).or_default().push(slot);
|
||||
}
|
||||
/// Drop a previously registered reply slot (query poll loop exited).
|
||||
pub(crate) fn unregister_block_wait(&mut self, id: &str, slot: &BlockWaitSlot) {
|
||||
if let Some(slots) = self.block_wait_slots.get_mut(id) {
|
||||
slots.retain(|s| !std::rc::Rc::ptr_eq(s, slot));
|
||||
if slots.is_empty() {
|
||||
self.block_wait_slots.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Decision-time gate for the completion auto-wake: returns true when
|
||||
/// the result was already delivered to a blocking waiter, or a live
|
||||
/// waiter is still parked and will receive it. When every registered
|
||||
/// waiter is gone (receivers dropped by a cancelled turn), clears
|
||||
/// `block_waited` and returns false so the auto-wake fires.
|
||||
///
|
||||
/// This closes the race where the query poll loop clears the flag up to
|
||||
/// one poll interval *after* the caller cancelled — the completion
|
||||
/// handler could read the stale flag in that window and skip the wake.
|
||||
/// Consumes the id's slot registrations (completion is terminal).
|
||||
pub(crate) fn block_wait_delivered_or_live(&mut self, id: &str) -> bool {
|
||||
let slots = self.block_wait_slots.remove(id).unwrap_or_default();
|
||||
if !self.is_block_waited(id) {
|
||||
return false;
|
||||
}
|
||||
let delivered_or_live = slots.is_empty()
|
||||
|| slots
|
||||
.iter()
|
||||
.any(|s| s.borrow().as_ref().is_none_or(|tx| !tx.is_closed()));
|
||||
if !delivered_or_live {
|
||||
self.clear_block_waited(id);
|
||||
}
|
||||
delivered_or_live
|
||||
}
|
||||
/// Mark a subagent as explicitly killed so auto-wake is suppressed on completion.
|
||||
pub(crate) fn mark_explicitly_killed(&mut self, id: &str) {
|
||||
if let Some(t) = self.active.get_mut(id) {
|
||||
t.explicitly_killed = true;
|
||||
} else if let Some(c) = self.completed.get_mut(id) {
|
||||
c.explicitly_killed = true;
|
||||
}
|
||||
}
|
||||
/// Whether the model explicitly killed this subagent via the kill tool.
|
||||
pub(crate) fn is_explicitly_killed(&self, id: &str) -> bool {
|
||||
self.active.get(id).is_some_and(|t| t.explicitly_killed)
|
||||
|| self.completed.get(id).is_some_and(|c| c.explicitly_killed)
|
||||
}
|
||||
/// Return fork provenance for a given subagent.
|
||||
pub(crate) fn provenance_for(&self, id: &str) -> SubagentProvenance {
|
||||
if let Some(t) = self.active.get(id) {
|
||||
return SubagentProvenance {
|
||||
fork_parent_prompt_id: t.parent_prompt_id.clone(),
|
||||
resumed_from: t.resumed_from.clone(),
|
||||
};
|
||||
}
|
||||
if let Some(c) = self.completed.get(id) {
|
||||
return SubagentProvenance {
|
||||
fork_parent_prompt_id: c.parent_prompt_id.clone(),
|
||||
resumed_from: c.resumed_from.clone(),
|
||||
};
|
||||
}
|
||||
SubagentProvenance::default()
|
||||
}
|
||||
/// Resolve a completed subagent scoped to the requesting parent session.
|
||||
///
|
||||
/// Returns `None` if the subagent is not found, still active, or belongs
|
||||
/// to a different parent session (prevents cross-session context bleed).
|
||||
///
|
||||
/// Fast path: checks the in-memory `completed` map first. When that
|
||||
/// misses (e.g. after TTL eviction), falls back to on-disk metadata
|
||||
/// in `{parent_session_dir}/subagents/{id}/meta.json`.
|
||||
pub(crate) fn resumable_source_for(
|
||||
&self,
|
||||
id: &str,
|
||||
parent_session_id: &str,
|
||||
parent_cwd: &Path,
|
||||
) -> Option<ResumeSourceData> {
|
||||
if let Some(completed) = self.completed.get(id) {
|
||||
if completed.parent_session_id != parent_session_id {
|
||||
return None;
|
||||
}
|
||||
return Some(ResumeSourceData {
|
||||
subagent_id: completed.subagent_id.clone(),
|
||||
child_session_id: completed.child_session_id.clone(),
|
||||
child_cwd: completed.child_cwd.clone(),
|
||||
worktree_path: completed.worktree_path.clone(),
|
||||
snapshot_ref: completed.snapshot_ref.clone(),
|
||||
subagent_type: completed.subagent_type.clone(),
|
||||
persona: completed.persona.clone(),
|
||||
model_id: Some(completed.effective_model_id.clone()),
|
||||
});
|
||||
}
|
||||
let parent_info = SessionInfo {
|
||||
id: acp::SessionId::new(parent_session_id),
|
||||
cwd: parent_cwd.to_string_lossy().to_string(),
|
||||
};
|
||||
let meta_path = session::persistence::session_dir(&parent_info)
|
||||
.join("subagents")
|
||||
.join(id)
|
||||
.join("meta.json");
|
||||
let data = std::fs::read_to_string(&meta_path).ok()?;
|
||||
let meta: SubagentMeta = serde_json::from_str(&data).ok()?;
|
||||
if meta.parent_session_id != parent_session_id {
|
||||
return None;
|
||||
}
|
||||
match meta.status.as_str() {
|
||||
"completed" | "failed" | "cancelled" => {}
|
||||
_ => return None,
|
||||
}
|
||||
Some(ResumeSourceData {
|
||||
subagent_id: meta.subagent_id,
|
||||
child_session_id: meta.child_session_id,
|
||||
child_cwd: meta.child_cwd.unwrap_or_default(),
|
||||
worktree_path: meta.worktree_path.map(PathBuf::from),
|
||||
snapshot_ref: meta.snapshot_ref,
|
||||
subagent_type: meta.subagent_type,
|
||||
persona: meta.persona,
|
||||
model_id: meta.effective_model_id,
|
||||
})
|
||||
}
|
||||
/// Check whether an ID refers to a currently-active (running) subagent.
|
||||
pub(crate) fn is_active(&self, id: &str) -> bool {
|
||||
self.active.contains_key(id)
|
||||
}
|
||||
/// Whether the coordinator still has this id in flight (spawning or running).
|
||||
/// Orphan reconcile skips these — there is nothing stuck to heal.
|
||||
pub(crate) fn is_active_or_pending(&self, id: &str) -> bool {
|
||||
self.active.contains_key(id) || self.pending.contains_key(id)
|
||||
}
|
||||
/// The terminal `SubagentFinished` for an id the coordinator already holds in
|
||||
/// `completed`, else `None`. Lets orphan reconcile re-emit a subagent's real
|
||||
/// outcome when only its terminal meta write was lost (reconnect race: entry
|
||||
/// in `completed` but the on-disk meta is still `running`) instead of
|
||||
/// force-cancelling it and discarding the result.
|
||||
pub(crate) fn completed_finish(&self, id: &str) -> Option<SessionUpdate> {
|
||||
let c = self.completed.get(id)?;
|
||||
let duration_ms = c
|
||||
.completed_at
|
||||
.saturating_duration_since(c.started_at)
|
||||
.as_millis() as u64;
|
||||
Some(SessionUpdate::SubagentFinished {
|
||||
subagent_id: c.subagent_id.clone(),
|
||||
child_session_id: c.child_session_id.clone(),
|
||||
status: c.result.status().to_string(),
|
||||
error: c.result.error.clone(),
|
||||
tool_calls: c.result.tool_calls,
|
||||
turns: c.result.turns,
|
||||
duration_ms,
|
||||
tokens_used: 0,
|
||||
output: None,
|
||||
will_wake: false,
|
||||
})
|
||||
}
|
||||
/// TTL cleanup: remove completed entries older than 30 minutes.
|
||||
pub fn evict_stale_completed(&mut self) {
|
||||
let cutoff = std::time::Duration::from_secs(30 * 60);
|
||||
self.completed.retain(|_, entry| entry.completed_at.elapsed() < cutoff);
|
||||
}
|
||||
/// Snapshot all currently-running subagents for compaction state context.
|
||||
///
|
||||
/// Returns one `ActiveSubagentSummary` per entry in the `active` map.
|
||||
/// Completed/failed/cancelled subagents are NOT included — they live in
|
||||
/// the `completed` map and are irrelevant for post-compaction reminders
|
||||
/// (the model already saw their tool results before compaction).
|
||||
///
|
||||
/// The `elapsed_ms` field is computed from `started_at.elapsed()` at call
|
||||
/// time, so the values are a snapshot of "right now" — appropriate for
|
||||
/// compaction since it happens once and the reminder is static.
|
||||
#[cfg(test)]
|
||||
pub fn active_summaries(&self) -> Vec<ActiveSubagentSummary> {
|
||||
self.active.values().map(tracker_to_summary).collect()
|
||||
}
|
||||
pub fn active_summaries_for(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> Vec<ActiveSubagentSummary> {
|
||||
self.active
|
||||
.values()
|
||||
.filter(|t| t.parent_session_id == parent_session_id)
|
||||
.map(tracker_to_summary)
|
||||
.collect()
|
||||
}
|
||||
/// Return seeds for all running subagents belonging to `parent_session_id`.
|
||||
///
|
||||
/// Each seed carries copied identity metadata plus a cloned
|
||||
/// `SessionSignalsHandle` so the caller can resolve live progress
|
||||
/// asynchronously after dropping the coordinator borrow.
|
||||
///
|
||||
/// Returns an empty `Vec` if no active subagents match the given
|
||||
/// parent session ID. Callers (e.g. the `x.ai/subagent/list_running`
|
||||
/// ACP handler) should treat an empty result as a normal "no running
|
||||
/// subagents" response, not an error.
|
||||
pub(crate) fn list_running_for_parent(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> Vec<RunningSubagentListSeed> {
|
||||
self.active
|
||||
.values()
|
||||
.filter(|t| t.parent_session_id == parent_session_id)
|
||||
.map(|t| RunningSubagentListSeed {
|
||||
subagent_id: t.subagent_id.clone(),
|
||||
parent_session_id: t.parent_session_id.clone(),
|
||||
child_session_id: t.child_session_id.0.to_string(),
|
||||
subagent_type: t.subagent_type.clone(),
|
||||
description: t.description.clone(),
|
||||
started_at_epoch_ms: instant_to_epoch_ms(t.started_at),
|
||||
duration_ms: t.started_at.elapsed().as_millis() as u64,
|
||||
signals_handle: t.child_handle.signals_handle.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user