Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6f216facd | ||
|
|
4d2cf7ffd4 | ||
|
|
02cf5deebd | ||
|
|
db05ed0751 | ||
|
|
771de1822d | ||
|
|
bb5cbff62d | ||
|
|
1579558b56 | ||
|
|
4d1e4fdc52 | ||
|
|
4361b03259 | ||
|
|
2c29d3ecd5 | ||
|
|
de534582c1 | ||
|
|
54c9177ce2 | ||
|
|
7bdf50a716 | ||
|
|
10d0c0816f | ||
|
|
6e26d15428 | ||
|
|
82ebcc38b7 | ||
|
|
0dc98a34a6 | ||
|
|
0692198719 | ||
|
|
77fd457627 |
@@ -1,79 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
name: check / clippy / fmt / deny (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-14, ubuntu-24.04]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install toolchain (rust-toolchain.toml)
|
||||
run: rustup show
|
||||
- name: Install dotslash (protoc launcher)
|
||||
run: cargo install dotslash --locked
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: cargo fmt
|
||||
run: cargo fmt --all --check
|
||||
- name: cargo check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
- name: Install cargo-deny
|
||||
run: cargo install cargo-deny --locked
|
||||
- name: cargo deny advisories
|
||||
run: cargo deny check advisories
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-14, ubuntu-24.04]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install toolchain (rust-toolchain.toml)
|
||||
run: rustup show
|
||||
- name: Install dotslash (protoc launcher)
|
||||
run: cargo install dotslash --locked
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: cargo test
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
perf:
|
||||
name: performance budgets (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-14, ubuntu-24.04]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install toolchain (rust-toolchain.toml)
|
||||
run: rustup show
|
||||
- name: Install dotslash (protoc launcher)
|
||||
run: cargo install dotslash --locked
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install hyperfine (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install hyperfine
|
||||
- name: Install hyperfine (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y hyperfine
|
||||
- name: Build release binaries
|
||||
run: |
|
||||
cargo build --release -p kigi-bin --locked
|
||||
cargo build --release -p kigi-pager-pty-harness --bin pty-scenario --locked
|
||||
- name: Enforce performance budgets
|
||||
run: scripts/bench.sh target/release/kigi
|
||||
@@ -49,6 +49,17 @@ import) or any `KIMI_*` env var.
|
||||
- `third_party/` — vendored Mermaid rendering stack (untouched policy).
|
||||
- `bin/protoc` — dotslash launcher used by proto codegen.
|
||||
|
||||
## Storage discipline
|
||||
|
||||
- Tests that touch the filesystem MUST use `tempfile::TempDir` (drop
|
||||
cleans up) — never bare `std::env::temp_dir()` + `create_dir_all`,
|
||||
which leaks directories into the OS temp root forever.
|
||||
- `target/` grows past 150GB across repeated full-workspace builds
|
||||
(incremental is already off); run `cargo clean` when it exceeds
|
||||
~50GB and at milestone boundaries.
|
||||
- Graph node worktrees are removed right after a successful merge-back;
|
||||
only FAILED nodes keep theirs for postmortem.
|
||||
|
||||
## Test seams
|
||||
|
||||
Cross-crate test hooks are behind the `test-support` cargo feature
|
||||
@@ -56,6 +67,89 @@ Cross-crate test hooks are behind the `test-support` cargo feature
|
||||
dependents' `[dev-dependencies]`. Don't expose new test seams as plain
|
||||
`#[cfg(test)]` items across crate boundaries.
|
||||
|
||||
## Graph mode (`/graph`, post-0.1.x — plan.md in the parent dir)
|
||||
|
||||
A deterministic DAG scheduler layered over the goal engine: `/graph
|
||||
<objective>` decomposes the objective into nodes (graph planner subagent
|
||||
→ Agentproof-style static gate in `graph_plan.rs`), then executes each
|
||||
node as one ordinary goal — the agentic loop lives INSIDE the node; the
|
||||
edges stay deterministic Rust. The harness appends a terminal
|
||||
`gn-final` verification node depending on every planner node.
|
||||
|
||||
- Feature flag `KIGI_GRAPH=1` (default off); availability additionally
|
||||
requires the goal harness (`BuiltinGate::Graph`).
|
||||
- Key modules (kigi-shell): `session/graph_tracker.rs` (pure state
|
||||
machine; reuses `GoalStatus`/`GoalPhase`/`GoalPauseReason`),
|
||||
`session/graph_plan.rs` (planner-JSON contract + validation + fnv id
|
||||
canonicalization), `session/graph_planner.rs` (planner runner, reuses
|
||||
the goal planner spawn plumbing),
|
||||
`session/acp_session_impl/graph.rs` (orchestration seam).
|
||||
- Seam points: `handle_prompt` intercepts GraphSet/GraphResume; the
|
||||
in-turn loop's `EndTurn` arm calls `run_graph_round_end()` to advance
|
||||
nodes within the same turn; goal auto-pauses cascade to the graph in
|
||||
`auto_pause_goal_if_active_inner`; node goals are armed with the
|
||||
REMAINING graph budget so `enforce_goal_token_budget` cascades trips.
|
||||
- Persistence: `PersistenceMsg::GraphModeState(Option<..>)` →
|
||||
`<session_dir>/graph/state.json` (`None` tombstones after clear);
|
||||
immutable per-version baselines `graph/graph.baseline.v{N}.json`;
|
||||
per-node goal artifacts archived to `graph/<node_id>/`. Restore
|
||||
demotes `Active`→`UserPaused` and `Running`→`Ready` (re-run is safe:
|
||||
the verifier gates completion).
|
||||
- `/goal` and `/graph` are mutually exclusive while the graph owns the
|
||||
engine; e2e suite: `acp_session_tests/graph/graph_e2e_tests.rs`.
|
||||
- Parallel fan-out (G1): with `KIGI_GRAPH_CONCURRENCY > 1` (default 3,
|
||||
clamp [1,8]) and ≥2 `Ready` nodes, `drive_graph` runs batches via
|
||||
`acp_session_impl/graph_workers.rs` — per node a bounded
|
||||
worker↔verifier subagent loop (`KIGI_GRAPH_NODE_ROUNDS`, default 3;
|
||||
`general-purpose` children; worktree isolation on round 1, resume
|
||||
keeps context+worktree on later rounds; `NODE_RESULT:` /
|
||||
`NODE_VERDICT:` terminal contracts parsed fail-closed), then
|
||||
SEQUENTIAL merge-back via `kigi_workspace::worktree::apply_worktree`
|
||||
(`ApplyMode::Merge`); a conflict fails the node and blocks its
|
||||
dependents while other chains continue. `gn-final` always runs
|
||||
serially on the full goal engine. Concurrency=1 is byte-identical to
|
||||
the serial G0 path. Ceiling: a worker round exceeding the foreground
|
||||
subagent await budget (600s) is cancelled and retried via resume.
|
||||
- G2: `BudgetLimited` is resumable — a budget trip demotes in-flight
|
||||
nodes to `Ready` (resource stop, not a verdict) and
|
||||
`/graph resume --budget <tokens>` re-arms with fresh headroom. The
|
||||
pager shows a graph status chip driven by the `GraphUpdated` wire
|
||||
variant (`extensions/notification.rs`), emitted from the single
|
||||
`persist_graph_state` chokepoint (checkpoint ⇔ badge tick); old
|
||||
pagers degrade via `#[serde(other)] Unknown`. PTY scenarios:
|
||||
`graph_slash_presession{,_disabled}.yaml`.
|
||||
- G3: dynamic replan. `DISCOVERED: <text>` line markers (fence-stripped,
|
||||
placeholder-filtered) from workers/verifiers/the serial node's final
|
||||
text queue as `pending_discoveries`; at each dispatch boundary
|
||||
`maybe_replan_graph` (`acp_session_impl/graph_replan.rs`) runs a
|
||||
replanner subagent producing an APPEND-ONLY appendix
|
||||
(`validate_replan`: existing-id deps allowed, edges onto `gn-final`
|
||||
rejected — they would cycle after the final-gating extension),
|
||||
bumps `plan_version`, freezes `graph.baseline.v{N}.json`, and regates
|
||||
`gn-final` (Ready→Waiting). Bounded by `KIGI_GRAPH_REPLAN_CAP`
|
||||
(default 3, 0 = off); past the cap — and after the final node has
|
||||
achieved — discoveries drain to history only. Replan failure degrades
|
||||
(history + notice); it never pauses a working graph.
|
||||
- G4: the graph follows the repo. Every checkpoint projects to
|
||||
`.kigi/graph.jsonl` at the git root (`session/graph_project.rs`,
|
||||
header line + one node per line, atomic write); single writer via an
|
||||
fs2 flock sidecar; other instances get read-only `/graph status`.
|
||||
Fresh sessions revive via `/graph resume` (load UNDER the lock,
|
||||
from_snapshot demotions apply). All lock-then-mutate sites
|
||||
identity-check the projected `graph_id`; kigi never commits the file.
|
||||
- G5: `/graph show` renders box-drawing DAG art
|
||||
(`session/graph_render.rs`, Sugiyama-lite: longest-path layers, dummy
|
||||
pass-throughs, barycenter ordering, bus lanes). Wider than 120 cols
|
||||
degrades to the status tree.
|
||||
- G6: plan-boundary topology optimizer
|
||||
(`acp_session_impl/graph_optimize.rs`; `KIGI_GRAPH_OPTIMIZER=0`
|
||||
disables). Restricted ops (`remove_dep`/`reorder`/`merge`/`split`)
|
||||
validated by `graph_plan::apply_optimization`: pending-only targets,
|
||||
immutable nodes byte-identical in the result, terminal gate rebuilt,
|
||||
whole-graph acyclicity. Applied passes bump `plan_version` and share
|
||||
the replan cap; `{"ops": []}` is a respected free no-op; failures
|
||||
degrade.
|
||||
|
||||
## Milestones (PRD §8.3)
|
||||
|
||||
- M0 (done): rename, deletions (voice/telemetry/announcements/marketplace/
|
||||
|
||||
Generated
+62
-62
@@ -5442,7 +5442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-acp-lib"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent-lifecycle"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"tokio",
|
||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-auth"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"http 1.4.2",
|
||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-bin"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-chat-state"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"kigi-compaction",
|
||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-codebase-graph"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"clap",
|
||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-compaction"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blake3",
|
||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config-types"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"indexmap",
|
||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-crash-handler"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"libc",
|
||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-env"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fast-worktree"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-file-utils"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-config",
|
||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fsnotify"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"dunce",
|
||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-gix-status"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"gix",
|
||||
"kigi-test-utils",
|
||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"kigi-config",
|
||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks-plugins-types"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-http"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"kigi-auth",
|
||||
"kigi-log",
|
||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hunk-tracker"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-interjection-core"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-log"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-lossy",
|
||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown-core"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"pulldown-cmark",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-memory"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mermaid"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"fontdb",
|
||||
"image",
|
||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-models"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"kigi-env",
|
||||
"serde",
|
||||
@@ -5969,7 +5969,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-minimal"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crossterm",
|
||||
@@ -5986,7 +5986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-pty-harness"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -6011,7 +6011,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-render"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anstyle",
|
||||
@@ -6063,7 +6063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-paths"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"serde",
|
||||
@@ -6073,7 +6073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-prompt-queue"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6081,7 +6081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-proto-build"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pbjson-build",
|
||||
@@ -6092,7 +6092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-inline"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"ansi-width",
|
||||
"anstyle-parse 0.2.7",
|
||||
@@ -6109,7 +6109,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-textarea"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6130,7 +6130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampler"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"async-openai",
|
||||
"async-stream",
|
||||
@@ -6153,7 +6153,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampling-types"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"assert_matches",
|
||||
"async-openai",
|
||||
@@ -6169,7 +6169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sandbox"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6190,7 +6190,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-secrets"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde_json",
|
||||
@@ -6228,7 +6228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6365,7 +6365,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell-base"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6390,7 +6390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sqlite-journal"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rusqlite",
|
||||
@@ -6401,7 +6401,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-subagent-resolution"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"kigi-sampling-types",
|
||||
"kigi-tool-types",
|
||||
@@ -6416,7 +6416,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-system-power"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"zbus",
|
||||
@@ -6424,7 +6424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-support"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6446,7 +6446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-utils"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"runfiles",
|
||||
"tracing",
|
||||
@@ -6455,11 +6455,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-token-estimation"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-protocol"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"kigi-tool-types",
|
||||
"serde",
|
||||
@@ -6470,7 +6470,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-runtime"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -6488,7 +6488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-types"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"minijinja",
|
||||
"schemars 1.2.1",
|
||||
@@ -6498,7 +6498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -6575,7 +6575,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools-api"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"kigi-proto-build",
|
||||
"kigi-tool-protocol",
|
||||
@@ -6588,11 +6588,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tracing-macros"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tty-utils"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"nix 0.30.1",
|
||||
@@ -6602,7 +6602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tui"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"ansi-to-tui",
|
||||
@@ -6689,7 +6689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-update"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
@@ -6718,14 +6718,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-version"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6804,7 +6804,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace-types"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
@@ -8838,7 +8838,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -8856,7 +8856,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ irm https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.ps1
|
||||
```
|
||||
|
||||
```sh
|
||||
kigi --version # kigi 0.1.0 … unofficial Kimi Code CLI community build
|
||||
kigi --version # kigi 0.1.1 … unofficial Kimi Code CLI community build
|
||||
kigi login # sign in with your Kimi Code subscription (device-code flow)
|
||||
kigi # start the TUI
|
||||
```
|
||||
|
||||
@@ -218,10 +218,12 @@ pub fn diamond_hollow_char() -> char {
|
||||
/// 1-column ASCII spinner (`|`, `/`, `-`, `\`) on legacy ConHost.
|
||||
///
|
||||
/// The U+2800 Braille Patterns block is not part of CP437 and renders as
|
||||
/// tofu on the legacy console raster font, so the turn-status line, the
|
||||
/// MCP-connecting chip, the image-viewer loader, and the `/btw` overlay
|
||||
/// all fall back to the classic ASCII spinner there. Every frame in both
|
||||
/// sets is exactly 1 column so the surrounding layout never shifts.
|
||||
/// tofu on the legacy console raster font, so the starting-session row,
|
||||
/// the MCP-connecting chip, the image-viewer loader, and the `/btw`
|
||||
/// overlay all fall back to the classic ASCII spinner there. Every frame
|
||||
/// in both sets is exactly 1 column so the surrounding layout never
|
||||
/// shifts. (The turn-status "Waiting for response…" line uses
|
||||
/// [`moon_spinner_frames`] instead.)
|
||||
pub fn braille_spinner_frames() -> &'static [&'static str] {
|
||||
const FANCY: &[&str] = &[
|
||||
"\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}",
|
||||
@@ -235,6 +237,34 @@ pub fn braille_spinner_frames() -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Moon-phase spinner frames (`🌑🌒🌓🌔🌕🌖🌗🌘`) normally; the ASCII
|
||||
/// spinner on legacy ConHost.
|
||||
///
|
||||
/// The turn-status "Waiting for response…" line reuses the official
|
||||
/// kimi-cli's lunation animation (rich's `moon` spinner) — one full
|
||||
/// lunation per cycle, matching the welcome-screen moon logo. Emoji
|
||||
/// frames are 2 columns wide; the caller measures the rendered frame
|
||||
/// (`spinner_str.width()`) so the layout adapts. Legacy ConHost's raster
|
||||
/// font has no emoji, so it falls back to the 1-column ASCII spinner.
|
||||
pub fn moon_spinner_frames() -> &'static [&'static str] {
|
||||
const FANCY: &[&str] = &[
|
||||
"\u{1f311}",
|
||||
"\u{1f312}",
|
||||
"\u{1f313}",
|
||||
"\u{1f314}",
|
||||
"\u{1f315}",
|
||||
"\u{1f316}",
|
||||
"\u{1f317}",
|
||||
"\u{1f318}",
|
||||
];
|
||||
const FALLBACK: &[&str] = &["|", "/", "-", "\\"];
|
||||
if is_legacy_windows_console() {
|
||||
FALLBACK
|
||||
} else {
|
||||
FANCY
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulsing dot progress-spinner frames (`⋅ : ⸬ ⁙`) normally; a quiet
|
||||
/// 1-column dot cycle (`.`, `:`, `·`) on legacy ConHost.
|
||||
///
|
||||
|
||||
@@ -13,7 +13,7 @@ default-bazel = []
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
//! Changelog fetching from CDN with local disk cache.
|
||||
//!
|
||||
//! Both markdown (`*.external.md`) and JSON (`*.external.json`) changelogs
|
||||
//! are published per-version alongside the Kigi GitHub distribution.
|
||||
//!
|
||||
//! `ChangelogManager::fetch()` retrieves both formats in parallel and
|
||||
//! returns a `Changelog` with optional markdown + structured entries.
|
||||
//! Consumers pick the format they need:
|
||||
//! - `/release-notes` uses `changelog.markdown` for rich scrollback display
|
||||
//! - Welcome screen uses `changelog.entries` for bullet rendering
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Base URL for published changelogs. Kigi distributes via GitHub, so
|
||||
/// per-version changelogs live in the release repository. Unreachable or
|
||||
/// missing files degrade gracefully to the on-disk cache (see `fetch_with`).
|
||||
const CHANGELOG_BASE: &str =
|
||||
"https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/changelogs";
|
||||
const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
/// A single structured changelog entry from the published JSON changelog.
|
||||
///
|
||||
/// Shape must match the output of `render_external_json` in `changelog.sh`:
|
||||
/// `{category, description, breaking_change}`
|
||||
/// If you change fields here, update `changelog.sh:render_external_json` too.
|
||||
///
|
||||
/// All fields use `#[serde(default)]` so a single malformed entry doesn't
|
||||
/// kill the entire array parse. Entries with an empty description are
|
||||
/// filtered out by `bullets_from_entries`.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct ChangelogEntry {
|
||||
/// Category label (e.g. "features", "fixes", "breaking", "performance").
|
||||
#[serde(default)]
|
||||
pub category: String,
|
||||
/// Human-readable description (may contain `**bold**` or backticks).
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Whether this entry represents a breaking change.
|
||||
#[serde(default)]
|
||||
pub breaking_change: bool,
|
||||
}
|
||||
|
||||
/// Both formats of a version's changelog, fetched together.
|
||||
pub struct Changelog {
|
||||
/// Rendered markdown (for `/release-notes` display).
|
||||
pub markdown: Option<String>,
|
||||
/// Structured entries (for welcome screen bullets).
|
||||
pub entries: Option<Vec<ChangelogEntry>>,
|
||||
}
|
||||
|
||||
/// Manages changelog retrieval from CDN with local disk caching.
|
||||
///
|
||||
/// Single entry point: `fetch()` returns both markdown and JSON in one
|
||||
/// `Changelog` struct. Each format is fetched independently with its own
|
||||
/// cache file, so a failure in one doesn't block the other.
|
||||
pub struct ChangelogManager {
|
||||
md_cache: PathBuf,
|
||||
json_cache: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for ChangelogManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChangelogManager {
|
||||
pub fn new() -> Self {
|
||||
// Prefer live `$KIGI_SHARE_DIR` so harness-injected homes (PTY e2e) always
|
||||
// win over a OnceLock that may have been initialised earlier with a
|
||||
// different path in the same process graph.
|
||||
Self::from_env_home()
|
||||
}
|
||||
|
||||
/// Resolve cache paths from the live process environment (not the
|
||||
/// `kigi_home()` OnceLock). A seeded `$KIGI_SHARE_DIR` set on the pager
|
||||
/// process is always honoured even if some earlier init path cached a
|
||||
/// different home.
|
||||
fn from_env_home() -> Self {
|
||||
let home = std::env::var_os("KIGI_SHARE_DIR")
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or_else(crate::util::kigi_home::kigi_home);
|
||||
Self {
|
||||
md_cache: home.join("CHANGELOG.md"),
|
||||
json_cache: home.join("CHANGELOG.json"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch both markdown and JSON changelogs for the current version.
|
||||
///
|
||||
/// Each format is fetched independently (CDN, 3 s timeout) and cached
|
||||
/// to disk. On failure, falls back to the cached copy. Either field
|
||||
/// may be `None` if offline with no cache.
|
||||
///
|
||||
/// When `KIGI_CHANGELOG_OFFLINE` is set (PTY / integration tests), skip
|
||||
/// the CDN entirely and read only the disk cache so seeded fixtures win
|
||||
/// deterministically without network races. Paths are re-resolved from
|
||||
/// `$KIGI_SHARE_DIR` so harness-injected env always applies.
|
||||
///
|
||||
/// JSON is only cached after a successful parse to avoid poisoning the
|
||||
/// disk cache with malformed content (the markdown cache is write-through
|
||||
/// since it's consumed as raw text).
|
||||
pub fn fetch(&self) -> Changelog {
|
||||
// Always re-resolve from env so a caller holding an older manager
|
||||
// (or OnceLock lag) still reads the live harness home.
|
||||
Self::from_env_home().fetch_with(changelog_offline(), CHANGELOG_BASE)
|
||||
}
|
||||
|
||||
/// Fetch using this manager's already-resolved cache paths, an explicit
|
||||
/// offline flag, and an explicit CDN base.
|
||||
///
|
||||
/// Split out of [`fetch`] so unit tests can drive it against a temp home
|
||||
/// without mutating process-global env (`KIGI_SHARE_DIR` /
|
||||
/// `KIGI_CHANGELOG_OFFLINE`), which races across the parallel test
|
||||
/// harness. Passing an unreachable `base` lets a test force a
|
||||
/// deterministic CDN miss instead of depending on whether the sandbox
|
||||
/// happens to block network. Production callers always go through
|
||||
/// [`fetch`], so behaviour is unchanged.
|
||||
fn fetch_with(&self, offline: bool, base: &str) -> Changelog {
|
||||
if offline {
|
||||
return Changelog {
|
||||
markdown: read_cache(&self.md_cache),
|
||||
entries: self.read_json_cache(),
|
||||
};
|
||||
}
|
||||
|
||||
let version = kigi_version::VERSION;
|
||||
let md_url = format!("{}/{}.external.md", base, version);
|
||||
|
||||
// Fetch both formats in parallel (3s timeout each → 3s total, not 6s).
|
||||
let mut markdown = None;
|
||||
let mut entries = None;
|
||||
std::thread::scope(|s| {
|
||||
let md_handle = s.spawn(|| self.fetch_and_cache(&md_url, &self.md_cache));
|
||||
let json_handle = s.spawn(|| self.fetch_json(base, version));
|
||||
markdown = md_handle.join().ok().flatten();
|
||||
entries = json_handle.join().ok().flatten();
|
||||
});
|
||||
|
||||
// If CDN is unreachable (CI sandboxes, airplane mode), fall back to
|
||||
// any on-disk seed under `$KIGI_SHARE_DIR` even when offline mode was not
|
||||
// explicitly requested — keeps PTY/integration tests deterministic.
|
||||
if markdown.is_none() {
|
||||
markdown = read_cache(&self.md_cache);
|
||||
}
|
||||
if entries.is_none() {
|
||||
entries = self.read_json_cache();
|
||||
}
|
||||
|
||||
Changelog { markdown, entries }
|
||||
}
|
||||
|
||||
/// Fetch and parse JSON changelog, caching only after successful parse.
|
||||
fn fetch_json(&self, base: &str, version: &str) -> Option<Vec<ChangelogEntry>> {
|
||||
let url = format!("{}/{}.external.json", base, version);
|
||||
|
||||
// Try remote first — only cache after successful parse.
|
||||
if let Ok(raw) = fetch_blocking(&url)
|
||||
&& !raw.trim().is_empty()
|
||||
{
|
||||
match serde_json::from_str::<Vec<ChangelogEntry>>(&raw) {
|
||||
Ok(entries) => {
|
||||
if let Err(e) = std::fs::write(&self.json_cache, &raw) {
|
||||
tracing::debug!(error = %e, "JSON changelog cache write failed");
|
||||
}
|
||||
return Some(entries);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "failed to parse JSON changelog from CDN");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.read_json_cache()
|
||||
}
|
||||
|
||||
fn read_json_cache(&self) -> Option<Vec<ChangelogEntry>> {
|
||||
let cached = read_cache(&self.json_cache)?;
|
||||
match serde_json::from_str(&cached) {
|
||||
Ok(entries) => Some(entries),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "failed to parse cached JSON changelog");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared fetch-and-cache: try remote (3 s timeout), cache on success,
|
||||
/// fall back to disk cache on failure.
|
||||
fn fetch_and_cache(&self, url: &str, cache_path: &std::path::Path) -> Option<String> {
|
||||
if let Ok(content) = fetch_blocking(url)
|
||||
&& !content.trim().is_empty()
|
||||
{
|
||||
if let Err(e) = std::fs::write(cache_path, &content) {
|
||||
tracing::debug!(error = %e, path = %cache_path.display(), "cache write failed");
|
||||
}
|
||||
return Some(content);
|
||||
}
|
||||
read_cache(cache_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// When set, `ChangelogManager::fetch` skips the CDN and only reads disk cache.
|
||||
/// Used by PTY harness tests that seed `CHANGELOG.{md,json}` under a temp home.
|
||||
fn changelog_offline() -> bool {
|
||||
std::env::var_os("KIGI_CHANGELOG_OFFLINE").is_some_and(|v| !v.is_empty() && v != "0")
|
||||
}
|
||||
|
||||
fn read_cache(path: &std::path::Path) -> Option<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Strip `**bold**` markers and backticks from a description string.
|
||||
fn strip_markdown_inline(s: &str) -> String {
|
||||
s.replace("**", "").replace('`', "")
|
||||
}
|
||||
|
||||
/// Convert changelog entries to plain-text bullet strings.
|
||||
///
|
||||
/// Strips `**bold**` and backtick formatting from each description,
|
||||
/// skips entries with empty descriptions (from tolerant deserialization),
|
||||
/// and returns at most `max` entries.
|
||||
pub fn bullets_from_entries(entries: &[ChangelogEntry], max: usize) -> Vec<String> {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|e| !e.description.is_empty())
|
||||
.take(max)
|
||||
.map(|e| strip_markdown_inline(&e.description))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Blocking HTTP fetch. Callers (`std::thread::scope` threads) are already
|
||||
/// off the tokio runtime, so no extra thread spawn is needed.
|
||||
fn fetch_blocking(url: &str) -> anyhow::Result<String> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.build()?;
|
||||
let resp = client.get(url).send()?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("HTTP {}", resp.status());
|
||||
}
|
||||
Ok(resp.text()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a manager pointing at `home` directly, bypassing the global
|
||||
/// `$KIGI_SHARE_DIR` env so tests never race the parallel harness.
|
||||
fn manager_for(home: &std::path::Path) -> ChangelogManager {
|
||||
ChangelogManager {
|
||||
md_cache: home.join("CHANGELOG.md"),
|
||||
json_cache: home.join("CHANGELOG.json"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_mode_reads_seeded_disk_cache_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("kigi-home");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::write(home.join("CHANGELOG.md"), "# seeded offline md\n").unwrap();
|
||||
std::fs::write(
|
||||
home.join("CHANGELOG.json"),
|
||||
r#"[{"category":"features","description":"seeded entry","breaking_change":false}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Offline path: read only the seeded disk cache, no network.
|
||||
let changelog = manager_for(&home).fetch_with(true, CHANGELOG_BASE);
|
||||
assert_eq!(
|
||||
changelog.markdown.as_deref(),
|
||||
Some("# seeded offline md\n"),
|
||||
"offline mode must return seeded markdown"
|
||||
);
|
||||
let entries = changelog.entries.expect("seeded json entries");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].description, "seeded entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdn_miss_falls_back_to_env_home_disk_cache() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("kigi-home-fallback");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::write(home.join("CHANGELOG.md"), "# fallback md\n").unwrap();
|
||||
|
||||
// Non-offline path with an unreachable CDN base: the remote fetch
|
||||
// fails deterministically (no dependency on the sandbox blocking
|
||||
// network), so the on-disk cache must win.
|
||||
let changelog = manager_for(&home).fetch_with(false, "http://127.0.0.1:1");
|
||||
assert_eq!(
|
||||
changelog.markdown.as_deref(),
|
||||
Some("# fallback md\n"),
|
||||
"CDN miss must fall back to the seeded CHANGELOG.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullets_strips_markdown_and_respects_max() {
|
||||
let entries = vec![
|
||||
ChangelogEntry {
|
||||
category: "features".into(),
|
||||
description: "Added **dark mode** support".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: "fixes".into(),
|
||||
description: "Fixed `crash` on startup".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: "performance".into(),
|
||||
description: "Faster **rendering** of `code` blocks".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
];
|
||||
|
||||
let bullets = bullets_from_entries(&entries, 2);
|
||||
assert_eq!(bullets.len(), 2);
|
||||
assert_eq!(bullets[0], "Added dark mode support");
|
||||
assert_eq!(bullets[1], "Fixed crash on startup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullets_skips_empty_descriptions() {
|
||||
let entries = vec![
|
||||
ChangelogEntry {
|
||||
category: "features".into(),
|
||||
description: "Good entry".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: String::new(),
|
||||
description: String::new(), // bad entry from tolerant deser
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: "fixes".into(),
|
||||
description: "Another good one".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
];
|
||||
let bullets = bullets_from_entries(&entries, 10);
|
||||
assert_eq!(bullets, vec!["Good entry", "Another good one"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerant_deserialization_partial_entry() {
|
||||
// Missing description field → defaults to empty string, not a parse error
|
||||
let json = r#"[{"category":"features"},{"description":"ok"}]"#;
|
||||
let entries: Vec<ChangelogEntry> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].description, "");
|
||||
assert_eq!(entries[1].category, "");
|
||||
assert_eq!(entries[1].description, "ok");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod changelog;
|
||||
pub mod event_id;
|
||||
pub mod kigi_home;
|
||||
pub mod secure_file;
|
||||
|
||||
@@ -1912,6 +1912,51 @@ impl Config {
|
||||
.default(true)
|
||||
.resolve()
|
||||
}
|
||||
/// Graph mode (`/graph`) master switch. Default OFF — gray-released via
|
||||
/// `KIGI_GRAPH=1` only (plan.md G0 gate). Graph mode additionally
|
||||
/// requires the goal harness (nodes execute as goals), enforced at
|
||||
/// availability time, not here.
|
||||
pub(crate) fn resolve_graph(&self) -> Resolved<bool> {
|
||||
BoolFlag::env("KIGI_GRAPH").default(false).resolve()
|
||||
}
|
||||
/// Max graph nodes running concurrently (`KIGI_GRAPH_CONCURRENCY`).
|
||||
/// 1 = serial (G0-identical); clamped to [1, 8] — the coordinator has
|
||||
/// no cap of its own, so this is the only brake on worker fan-out.
|
||||
pub(crate) fn resolve_graph_concurrency(&self) -> u32 {
|
||||
std::env::var("KIGI_GRAPH_CONCURRENCY")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(3)
|
||||
.clamp(1, 8)
|
||||
}
|
||||
/// Graph topology optimizer master switch (`KIGI_GRAPH_OPTIMIZER`;
|
||||
/// default on, `0` disables). Runs at plan boundaries only.
|
||||
pub(crate) fn resolve_graph_optimizer_enabled(&self) -> bool {
|
||||
!matches!(
|
||||
std::env::var("KIGI_GRAPH_OPTIMIZER").ok().as_deref(),
|
||||
Some("0") | Some("false")
|
||||
)
|
||||
}
|
||||
/// Max replan passes per graph (`KIGI_GRAPH_REPLAN_CAP`); 0 turns
|
||||
/// dynamic replanning off. Past the cap, discoveries drain to
|
||||
/// history only — the graph must still converge. Clamped to [0, 10].
|
||||
pub(crate) fn resolve_graph_replan_cap(&self) -> u32 {
|
||||
std::env::var("KIGI_GRAPH_REPLAN_CAP")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(3)
|
||||
.clamp(0, 10)
|
||||
}
|
||||
/// Max worker↔verifier rounds per parallel graph node
|
||||
/// (`KIGI_GRAPH_NODE_ROUNDS`); exhausting them fails the node.
|
||||
/// Clamped to [1, 8].
|
||||
pub(crate) fn resolve_graph_node_rounds(&self) -> u32 {
|
||||
std::env::var("KIGI_GRAPH_NODE_ROUNDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(3)
|
||||
.clamp(1, 8)
|
||||
}
|
||||
/// Classifier, planner, and summary all default to goal mode itself: when
|
||||
/// `/goal` is on they are on unless config/env/remote says otherwise.
|
||||
/// `goal_enabled` is the session's already-resolved master switch (the same
|
||||
|
||||
@@ -754,6 +754,7 @@ impl acp::Agent for MvpAgent {
|
||||
persisted_signals: None,
|
||||
persisted_plan_mode: None,
|
||||
persisted_goal_mode: None,
|
||||
persisted_graph_mode: None,
|
||||
persisted_announcement_state: None,
|
||||
session_meta: arguments.meta.as_ref(),
|
||||
model_agent_type: model_agent_type.as_deref(),
|
||||
@@ -985,6 +986,7 @@ impl acp::Agent for MvpAgent {
|
||||
signals: persisted_signals,
|
||||
announcement_state: persisted_announcement_state,
|
||||
goal_mode_state: _persisted_goal_mode,
|
||||
graph_mode_state: _persisted_graph_mode,
|
||||
} = persistence_info;
|
||||
let restored_awaiting_plan_approval = persisted_plan_mode
|
||||
.as_ref()
|
||||
@@ -1207,6 +1209,7 @@ impl acp::Agent for MvpAgent {
|
||||
persisted_signals,
|
||||
persisted_plan_mode,
|
||||
persisted_goal_mode: _persisted_goal_mode,
|
||||
persisted_graph_mode: _persisted_graph_mode,
|
||||
persisted_announcement_state,
|
||||
session_meta: request_meta.as_ref(),
|
||||
model_agent_type: persisted_agent_name.as_deref(),
|
||||
|
||||
@@ -310,8 +310,13 @@ impl MvpAgent {
|
||||
pub(crate) fn command_availability(
|
||||
&self,
|
||||
) -> crate::session::slash_commands::CommandAvailability {
|
||||
let goal = self.cfg.borrow().resolve_goal().value;
|
||||
crate::session::slash_commands::CommandAvailability {
|
||||
goal: self.cfg.borrow().resolve_goal().value,
|
||||
goal,
|
||||
// Same convention as /goal: the flag is known at initialize
|
||||
// time, so advertise pre-session; the in-session path
|
||||
// re-checks the live toolset.
|
||||
graph: goal && self.cfg.borrow().resolve_graph().value,
|
||||
..crate::session::slash_commands::CommandAvailability::default()
|
||||
}
|
||||
}
|
||||
@@ -1677,6 +1682,7 @@ impl MvpAgent {
|
||||
persisted_signals,
|
||||
persisted_plan_mode,
|
||||
persisted_goal_mode,
|
||||
persisted_graph_mode,
|
||||
persisted_announcement_state,
|
||||
session_meta,
|
||||
model_agent_type,
|
||||
@@ -2111,6 +2117,7 @@ impl MvpAgent {
|
||||
let web_fetch_config = self.prepare_web_fetch_config();
|
||||
let write_file_enabled = self.cfg.borrow().resolve_write_file().value;
|
||||
let goal_enabled = self.cfg.borrow().resolve_goal().value;
|
||||
let graph_enabled = self.cfg.borrow().resolve_graph().value;
|
||||
let subagents_enabled = self.cfg.borrow().subagents_enabled;
|
||||
let ask_user_question_enabled = parse_ask_user_question_from_meta(session_meta)
|
||||
.unwrap_or_else(|| self.cfg.borrow().resolve_ask_user_question().value);
|
||||
@@ -2314,6 +2321,7 @@ impl MvpAgent {
|
||||
persisted_signals,
|
||||
persisted_plan_mode,
|
||||
persisted_goal_mode,
|
||||
persisted_graph_mode,
|
||||
persisted_announcement_state,
|
||||
self.memory_config.clone(),
|
||||
feedback_flags,
|
||||
@@ -2328,6 +2336,7 @@ impl MvpAgent {
|
||||
app_builder_deployer_config,
|
||||
write_file_enabled,
|
||||
goal_enabled,
|
||||
graph_enabled,
|
||||
subagents_enabled,
|
||||
ask_user_question_enabled,
|
||||
client_hooks,
|
||||
|
||||
@@ -120,6 +120,7 @@ pub(crate) struct SessionSpawnOptions<'a> {
|
||||
pub persisted_signals: Option<crate::session::signals::SessionSignals>,
|
||||
pub persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
||||
pub persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
pub persisted_graph_mode: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
pub persisted_announcement_state: Option<
|
||||
crate::session::announcement_state::AnnouncementState,
|
||||
>,
|
||||
@@ -257,6 +258,7 @@ pub(crate) fn chat_session_spawn_options<'a>(
|
||||
persisted_signals: None,
|
||||
persisted_plan_mode: None,
|
||||
persisted_goal_mode: None,
|
||||
persisted_graph_mode: None,
|
||||
persisted_announcement_state: None,
|
||||
session_meta,
|
||||
model_agent_type,
|
||||
|
||||
@@ -1036,6 +1036,7 @@ pub(crate) async fn handle_subagent_request(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
if verbatim_mirror_fork {
|
||||
None
|
||||
} else if let Some(scope) = agent_memory_scope {
|
||||
@@ -1069,6 +1070,8 @@ pub(crate) async fn handle_subagent_request(
|
||||
ctx.app_builder_deployer_config.clone(),
|
||||
ctx.write_file_enabled,
|
||||
ctx.goal_enabled,
|
||||
// Graph mode is a depth-0 harness; child sessions never drive it.
|
||||
false,
|
||||
true,
|
||||
ctx.ask_user_question_enabled,
|
||||
ctx.client_hooks.clone(),
|
||||
|
||||
@@ -116,8 +116,8 @@ pub fn extract_bundled_files(kigi_home: &std::path::Path) {
|
||||
|
||||
let _ = std::fs::create_dir_all(kigi_home);
|
||||
|
||||
// Clean up cached changelog files from previous version so
|
||||
// /release-notes fetches fresh content for the new version.
|
||||
// Clean up changelog caches written by the removed changelog feature
|
||||
// (kigi <= 0.1.0 cached CDN release notes in the kigi home).
|
||||
for stale in &["CHANGELOG.json", "CHANGELOG.md"] {
|
||||
let _ = std::fs::remove_file(kigi_home.join(stale));
|
||||
}
|
||||
|
||||
@@ -897,6 +897,36 @@ pub enum SessionUpdate {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
usage: Option<PromptUsage>,
|
||||
},
|
||||
/// Graph mode (`/graph`) progress for the pager's status chip.
|
||||
/// Wire tag `graph_updated`; `status: "cleared"` tells the pager to
|
||||
/// drop its graph state (same sentinel convention as `GoalUpdated`).
|
||||
/// Old pagers degrade to [`Self::Unknown`] silently.
|
||||
GraphUpdated {
|
||||
graph_id: String,
|
||||
objective: String,
|
||||
/// Goal-status vocabulary (`active`, paused family,
|
||||
/// `budget_limited`, `complete`) plus `cleared`.
|
||||
status: String,
|
||||
/// `idle` | `planning` | `executing`.
|
||||
phase: String,
|
||||
plan_version: u32,
|
||||
total_nodes: u32,
|
||||
achieved_nodes: u32,
|
||||
failed_nodes: u32,
|
||||
running_nodes: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_node: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_node_title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
token_budget: Option<i64>,
|
||||
#[serde(default)]
|
||||
tokens_spent: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
last_event: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pause_message: Option<String>,
|
||||
},
|
||||
/// Catch-all for unrecognized session update types.
|
||||
/// Allows forward/backward compatibility when variants are added or removed.
|
||||
/// All fields from the unrecognized variant are discarded during deserialization.
|
||||
@@ -2293,3 +2323,88 @@ mod tests {
|
||||
assert!(serde_json::from_str::<SessionUpdate>(missing_stop_reason).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod graph_updated_wire_tests {
|
||||
use super::*;
|
||||
|
||||
fn full_graph_updated() -> SessionUpdate {
|
||||
SessionUpdate::GraphUpdated {
|
||||
graph_id: "g-1".into(),
|
||||
objective: "ship it".into(),
|
||||
status: "active".into(),
|
||||
phase: "executing".into(),
|
||||
plan_version: 2,
|
||||
total_nodes: 5,
|
||||
achieved_nodes: 2,
|
||||
failed_nodes: 1,
|
||||
running_nodes: 1,
|
||||
current_node: Some("gn-abc".into()),
|
||||
current_node_title: Some("Node C".into()),
|
||||
token_budget: Some(10_000),
|
||||
tokens_spent: 4_200,
|
||||
last_event: Some("node_achieved".into()),
|
||||
pause_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_updated_round_trips_with_snake_case_tag() {
|
||||
let update = full_graph_updated();
|
||||
let json = serde_json::to_value(&update).unwrap();
|
||||
assert_eq!(json["sessionUpdate"], "graph_updated");
|
||||
assert_eq!(json["achieved_nodes"], 2);
|
||||
assert_eq!(json["current_node_title"], "Node C");
|
||||
// Omitted optionals must not serialize at all.
|
||||
assert!(json.get("pause_message").is_none());
|
||||
let back: SessionUpdate = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back, update);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_updated_minimal_payload_fills_defaults() {
|
||||
// Only the required fields on the wire: every optional absent,
|
||||
// `tokens_spent` relies on #[serde(default)].
|
||||
let json = serde_json::json!({
|
||||
"sessionUpdate": "graph_updated",
|
||||
"graph_id": "g-2",
|
||||
"objective": "o",
|
||||
"status": "cleared",
|
||||
"phase": "idle",
|
||||
"plan_version": 0,
|
||||
"total_nodes": 0,
|
||||
"achieved_nodes": 0,
|
||||
"failed_nodes": 0,
|
||||
"running_nodes": 0,
|
||||
});
|
||||
let update: SessionUpdate = serde_json::from_value(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::GraphUpdated {
|
||||
status,
|
||||
tokens_spent,
|
||||
current_node,
|
||||
token_budget,
|
||||
pause_message,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(status, "cleared");
|
||||
assert_eq!(tokens_spent, 0, "#[serde(default)] must backfill");
|
||||
assert!(current_node.is_none());
|
||||
assert!(token_budget.is_none());
|
||||
assert!(pause_message.is_none());
|
||||
}
|
||||
other => panic!("expected GraphUpdated, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An OLD pager (this enum before the variant existed) must degrade
|
||||
/// a graph_updated payload to `Unknown` — pinned by feeding an
|
||||
/// unknown-tag payload through today's enum, which uses the same
|
||||
/// #[serde(other)] mechanism.
|
||||
#[test]
|
||||
fn unknown_tags_still_degrade_gracefully() {
|
||||
let json = serde_json::json!({ "sessionUpdate": "graph_updated_v99", "x": 1 });
|
||||
let update: SessionUpdate = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(update, SessionUpdate::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,14 @@ pub(crate) use types::*;
|
||||
pub use types::{TodoGateDecision, TodoGateReason};
|
||||
#[path = "acp_session_impl/goal.rs"]
|
||||
mod goal;
|
||||
#[path = "acp_session_impl/graph.rs"]
|
||||
mod graph;
|
||||
#[path = "acp_session_impl/graph_optimize.rs"]
|
||||
mod graph_optimize;
|
||||
#[path = "acp_session_impl/graph_replan.rs"]
|
||||
mod graph_replan;
|
||||
#[path = "acp_session_impl/graph_workers.rs"]
|
||||
mod graph_workers;
|
||||
#[path = "acp_session_impl/interjection.rs"]
|
||||
mod interjection;
|
||||
#[path = "acp_session_impl/tool_calls.rs"]
|
||||
@@ -589,6 +597,34 @@ pub(crate) struct SessionActor {
|
||||
/// Goal mode orchestration tracker. Session-scoped state for the
|
||||
/// Design-Execute-Verify loop. Modeled after `plan_mode` above.
|
||||
pub(crate) goal_tracker: Arc<parking_lot::Mutex<crate::session::goal_tracker::GoalTracker>>,
|
||||
/// Whether graph mode (`/graph`) is enabled for this session (feature
|
||||
/// flag `KIGI_GRAPH`). Availability additionally requires the goal
|
||||
/// harness — graph nodes execute as goals.
|
||||
pub(crate) graph_enabled: bool,
|
||||
/// Graph mode orchestration tracker: the deterministic DAG scheduler
|
||||
/// layered over the goal engine. Modeled after `goal_tracker` above;
|
||||
/// all graph state logic lives in `graph_tracker.rs`.
|
||||
pub(crate) graph_tracker: Arc<parking_lot::Mutex<crate::session::graph_tracker::GraphTracker>>,
|
||||
/// Max graph nodes running concurrently (1 = serial G0 behavior).
|
||||
/// Cached at actor construction from `resolve_graph_concurrency`.
|
||||
pub(crate) graph_concurrency: u32,
|
||||
/// Max worker↔verifier rounds per parallel graph node before the
|
||||
/// node fails. Cached at actor construction.
|
||||
pub(crate) graph_node_rounds: u32,
|
||||
/// Max replan passes per graph (0 = replanning off). Cached at
|
||||
/// actor construction.
|
||||
pub(crate) graph_replan_cap: u32,
|
||||
/// Topology optimizer switch (plan-boundary passes; shares the
|
||||
/// replan cap). Cached at actor construction.
|
||||
pub(crate) graph_optimizer_enabled: bool,
|
||||
/// `.kigi` dir at the git root, when the session cwd is in a git
|
||||
/// repo — home of the project-level shared graph projection.
|
||||
pub(crate) graph_project_dir: Option<std::path::PathBuf>,
|
||||
/// Held single-writer lock on the project graph. `Some` while this
|
||||
/// session owns the graph (created or resumed it); dropped on
|
||||
/// `/graph clear`.
|
||||
pub(crate) graph_project_lock:
|
||||
std::cell::RefCell<Option<crate::session::graph_project::ProjectGraphLock>>,
|
||||
/// `task_id`s of background tasks (and monitors) that originated during
|
||||
/// the goal turn — either spawned by the goal model itself or reparented
|
||||
/// from a harness verifier/planner subagent on its exit. Their late
|
||||
@@ -985,6 +1021,9 @@ impl SessionActor {
|
||||
hooks: self.hook_registry.borrow().is_some(),
|
||||
plugins: self.plugin_registry.borrow().is_some(),
|
||||
goal,
|
||||
// Graph rides the goal harness: nodes execute as goals, so
|
||||
// `/graph` is only real when `/goal` is.
|
||||
graph: self.graph_enabled && goal,
|
||||
}
|
||||
}
|
||||
/// Names of every tool registered with the session's tool bridge.
|
||||
@@ -1423,6 +1462,9 @@ mod goal_strategist_e2e_tests;
|
||||
#[path = "acp_session_tests/goal/goal_summarizer_e2e_tests.rs"]
|
||||
mod goal_summarizer_e2e_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "acp_session_tests/graph/graph_e2e_tests.rs"]
|
||||
mod graph_e2e_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "acp_session_tests/idle_resume_tests.rs"]
|
||||
mod idle_resume_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1904,6 +1904,29 @@ impl SessionActor {
|
||||
tokens_used,
|
||||
finished_marginal,
|
||||
);
|
||||
// Graph cascade: node goals are armed with the REMAINING graph
|
||||
// budget, so a node-level budget trip is the graph-level trip.
|
||||
if self.graph_harness_enabled() && self.graph_tracker.lock().is_active() {
|
||||
tracing::warn!("graph: node goal budget trip cascades to graph BudgetLimited");
|
||||
{
|
||||
let mut tracker = self.graph_tracker.lock();
|
||||
// Budget integrity: charge the tripped node's partial burn
|
||||
// BEFORE budget_limit clears current_node — otherwise the
|
||||
// top-up arithmetic runs on an under-counted ledger.
|
||||
if let Some(node_id) = tracker.current_node_id().map(str::to_owned) {
|
||||
tracker.charge_node_tokens(&node_id, tokens_used);
|
||||
}
|
||||
tracker.budget_limit();
|
||||
}
|
||||
self.persist_graph_state();
|
||||
self.send_slash_command_output(&format!(
|
||||
"Graph token budget reached ({tokens_used} tokens this node) — graph \
|
||||
stopped. Top up with /graph resume --budget <tokens>, or /graph clear \
|
||||
to abandon."
|
||||
))
|
||||
.await;
|
||||
return true;
|
||||
}
|
||||
self.send_slash_command_output(&format!(
|
||||
"Goal token budget reached ({tokens_used} of {budget} tokens) — goal \
|
||||
stopped. Use /goal clear, then /goal <objective> to start a new one."
|
||||
@@ -2265,30 +2288,57 @@ impl SessionActor {
|
||||
message: Option<String>,
|
||||
) -> bool {
|
||||
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
|
||||
{
|
||||
let graph_message = message.clone();
|
||||
let goal_paused = {
|
||||
let mut tracker = self.goal_tracker.lock();
|
||||
if tracker.status() != Some(crate::session::goal_tracker::GoalStatus::Active) {
|
||||
return false;
|
||||
if tracker.status() == Some(crate::session::goal_tracker::GoalStatus::Active) {
|
||||
// Active is guaranteed here, so the transition succeeds.
|
||||
match message {
|
||||
Some(msg) => tracker.pause_with_message(reason, msg),
|
||||
None => tracker.pause(reason),
|
||||
};
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
// The early-return above guarantees `Active`, so the pause
|
||||
// transition always succeeds here.
|
||||
match message {
|
||||
Some(msg) => tracker.pause_with_message(reason, msg),
|
||||
None => tracker.pause(reason),
|
||||
};
|
||||
};
|
||||
if goal_paused {
|
||||
self.clear_pending_classifier_completions();
|
||||
let (tokens_used, finished_marginal) = self.goal_tokens(current_tokens);
|
||||
let notify = self.goal_notify_sender();
|
||||
notify.emit_goal_updated(
|
||||
&mut self.goal_tracker.lock(),
|
||||
tokens_used,
|
||||
finished_marginal,
|
||||
);
|
||||
self.emit_event(crate::session::events::Event::GoalAutoPaused {
|
||||
reason: reason.into(),
|
||||
});
|
||||
}
|
||||
self.clear_pending_classifier_completions();
|
||||
let (tokens_used, finished_marginal) = self.goal_tokens(current_tokens);
|
||||
let notify = self.goal_notify_sender();
|
||||
notify.emit_goal_updated(
|
||||
&mut self.goal_tracker.lock(),
|
||||
tokens_used,
|
||||
finished_marginal,
|
||||
);
|
||||
self.emit_event(crate::session::events::Event::GoalAutoPaused {
|
||||
reason: reason.into(),
|
||||
});
|
||||
true
|
||||
// Graph cascade chokepoint: every goal auto-pause path funnels
|
||||
// through here. It runs REGARDLESS of whether a goal pause
|
||||
// applied — a cancel can land while the graph is Active with no
|
||||
// node goal in the engine (during graph planning, or on the node
|
||||
// boundary between engine reset and goal creation), and the
|
||||
// graph must still lose its self-driving status. `pause` is
|
||||
// Active-only, so double cascades are idempotent.
|
||||
if self.graph_harness_enabled() && self.graph_tracker.lock().is_active() {
|
||||
let node = self
|
||||
.graph_tracker
|
||||
.lock()
|
||||
.current_node_id()
|
||||
.map(str::to_owned);
|
||||
tracing::info!(reason = ?reason, node = ?node, goal_paused, "graph: cascading pause to graph");
|
||||
let detail = match (&node, &graph_message) {
|
||||
(Some(n), Some(msg)) => format!("Node {n} paused: {msg}"),
|
||||
(Some(n), None) => format!("Node {n} paused ({reason:?})"),
|
||||
(None, Some(msg)) => format!("Paused with no node goal in flight: {msg}"),
|
||||
(None, None) => format!("Paused with no node goal in flight ({reason:?})"),
|
||||
};
|
||||
self.graph_tracker.lock().pause_with_message(reason, detail);
|
||||
self.persist_graph_state();
|
||||
}
|
||||
goal_paused
|
||||
}
|
||||
|
||||
/// Match the last assistant message text (via
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
//! Topology optimizer (G6): a plan-boundary review pass that may issue
|
||||
//! a RESTRICTED set of graph edits — remove false deps (restoring
|
||||
//! parallelism), reorder pending priority, merge tiny nodes, split
|
||||
//! oversized ones — over Waiting/Ready nodes only.
|
||||
//!
|
||||
//! The optimizer changes GRAPH DATA only; the executor stays pure
|
||||
//! deterministic Rust. It fires ① right after initial planning and
|
||||
//! ② at each replan boundary (piggybacked), never mid-execution.
|
||||
//! Applied (non-empty) passes bump `plan_version`, freeze a baseline,
|
||||
//! and consume a slot of the SHARED replan cap; an explicit `[]` is a
|
||||
//! respected no-op consuming nothing. Failure degrades — the current
|
||||
//! graph keeps running. `KIGI_GRAPH_OPTIMIZER=0` disables entirely.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::super::goal_planner::{ChannelSpawner, GoalPlannerSpawner};
|
||||
use super::super::graph_plan;
|
||||
use super::super::graph_planner::{ArtifactPassSpec, run_graph_artifact_pass};
|
||||
use super::SessionActor;
|
||||
|
||||
const OPTIMIZER_PROMPT_TEMPLATE: &str = include_str!("../templates/graph_optimizer_prompt.md");
|
||||
|
||||
impl SessionActor {
|
||||
/// One optimizer pass at a plan boundary. No-op when disabled, when
|
||||
/// the shared cap is exhausted, or when the graph is not Active.
|
||||
pub(super) async fn maybe_optimize_graph(&self) {
|
||||
if !self.graph_optimizer_enabled {
|
||||
return;
|
||||
}
|
||||
let (replan_runs, current_graph, history_text, graph_file, next_version) = {
|
||||
let tracker = self.graph_tracker.lock();
|
||||
let Some(state) = tracker.snapshot() else {
|
||||
return;
|
||||
};
|
||||
if state.status != crate::session::goal_tracker::GoalStatus::Active {
|
||||
return;
|
||||
}
|
||||
let compact: Vec<serde_json::Value> = state
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
serde_json::json!({
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"spec": n.spec,
|
||||
"status": format!("{:?}", n.status),
|
||||
"deps": n.deps.iter().map(|d| d.on.clone()).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let history_text = state
|
||||
.history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(12)
|
||||
.map(|h| {
|
||||
format!(
|
||||
"- {:?} {} {}",
|
||||
h.event,
|
||||
h.node_id.as_deref().unwrap_or("-"),
|
||||
h.detail.as_deref().unwrap_or("")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(
|
||||
state.replan_runs,
|
||||
serde_json::to_string(&compact).unwrap_or_default(),
|
||||
history_text,
|
||||
tracker
|
||||
.artifacts_dir()
|
||||
.join(format!("optimize.v{}.json", state.plan_version + 1)),
|
||||
state.plan_version + 1,
|
||||
)
|
||||
};
|
||||
if self.graph_replan_cap == 0 || replan_runs >= self.graph_replan_cap {
|
||||
tracing::info!(
|
||||
replan_runs,
|
||||
cap = self.graph_replan_cap,
|
||||
"graph optimizer: shared cap exhausted; skipping pass"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(event_tx) = self.tool_context.subagent_event_tx.clone() else {
|
||||
return;
|
||||
};
|
||||
let objective = self
|
||||
.graph_tracker
|
||||
.lock()
|
||||
.objective()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_default();
|
||||
let parent_prompt_id = self
|
||||
.current_prompt_id
|
||||
.lock()
|
||||
.expect("current_prompt_id mutex poisoned")
|
||||
.clone();
|
||||
let spawner: Arc<dyn GoalPlannerSpawner> = Arc::new(ChannelSpawner {
|
||||
event_tx,
|
||||
parent_session_id: self.session_id_string(),
|
||||
parent_prompt_id,
|
||||
cwd: Some(self.tool_context.cwd.as_str().to_owned()),
|
||||
role_override: Default::default(),
|
||||
events: Some(self.events.writer()),
|
||||
});
|
||||
let tool_names = self.resolve_inherit_role_tool_names().await;
|
||||
let sections = format!(
|
||||
"OBJECTIVE:\n{objective}\n\nCURRENT GRAPH:\n{current_graph}\n\n\
|
||||
EXECUTION HISTORY:\n{history_text}\n"
|
||||
);
|
||||
tracing::info!(next_version, "graph optimizer: firing");
|
||||
let json = match run_graph_artifact_pass(
|
||||
spawner,
|
||||
ArtifactPassSpec {
|
||||
template: OPTIMIZER_PROMPT_TEMPLATE,
|
||||
sections: §ions,
|
||||
graph_file: &graph_file,
|
||||
tool_names: &tool_names,
|
||||
role: "graph optimizer",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(json) => json,
|
||||
Err(reason) => {
|
||||
// Degrade: an enhancement pass never blocks the graph.
|
||||
tracing::warn!(%reason, "graph optimizer: pass failed; keeping current plan");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let existing = self
|
||||
.graph_tracker
|
||||
.lock()
|
||||
.snapshot()
|
||||
.map(|s| s.nodes.clone())
|
||||
.unwrap_or_default();
|
||||
match graph_plan::apply_optimization(&existing, &json) {
|
||||
Ok(None) => {
|
||||
tracing::info!("graph optimizer: no ops (already good)");
|
||||
}
|
||||
Ok(Some(optimized)) => {
|
||||
let n_before = existing.len();
|
||||
let n_after = optimized.len();
|
||||
self.graph_tracker.lock().install_optimized_nodes(optimized);
|
||||
let all_nodes = self
|
||||
.graph_tracker
|
||||
.lock()
|
||||
.snapshot()
|
||||
.map(|s| s.nodes.clone())
|
||||
.unwrap_or_default();
|
||||
if let Err(err) = self.write_graph_baseline(&all_nodes).await {
|
||||
tracing::warn!(%err, "graph optimizer: baseline write failed (audit gap only)");
|
||||
}
|
||||
self.persist_graph_state();
|
||||
tracing::info!(
|
||||
next_version,
|
||||
n_before,
|
||||
n_after,
|
||||
"graph optimizer: plan optimized"
|
||||
);
|
||||
self.send_slash_command_output(&format!(
|
||||
"Graph optimized (v{next_version}): {n_before} → {n_after} node(s)."
|
||||
))
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "graph optimizer: ops rejected; keeping current plan");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Dynamic replan (G3): fold `DISCOVERED:` items surfaced during node
|
||||
//! execution into the graph at dispatch boundaries.
|
||||
//!
|
||||
//! SGH version discipline: the running plan is immutable inside a
|
||||
//! version — a replan appends new nodes (never edits existing ones),
|
||||
//! bumps `plan_version`, and freezes a new immutable baseline. The pass
|
||||
//! is BOUNDED by `KIGI_GRAPH_REPLAN_CAP` (default 3, 0 = off): past the
|
||||
//! cap, discoveries drain to history only, so the graph always
|
||||
//! converges. Replan failure DEGRADES (discoveries kept in history, the
|
||||
//! graph keeps running) — unlike initial planning, a working graph is
|
||||
//! never paused because an enhancement pass failed.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::super::goal_planner::{ChannelSpawner, GoalPlannerSpawner};
|
||||
use super::super::graph_planner::{
|
||||
GRAPH_REPLANNER_SUBAGENT_DESCRIPTION, GraphPlannerOutcome, GraphReplannerInputs,
|
||||
run_graph_replanner,
|
||||
};
|
||||
use super::SessionActor;
|
||||
|
||||
impl SessionActor {
|
||||
/// Replan boundary, called at the top of every `drive_graph`
|
||||
/// iteration. No-op without pending discoveries.
|
||||
pub(super) async fn maybe_replan_graph(&self) {
|
||||
let (pending, replan_runs) = {
|
||||
let tracker = self.graph_tracker.lock();
|
||||
let Some(state) = tracker.snapshot() else {
|
||||
return;
|
||||
};
|
||||
(state.pending_discoveries.clone(), state.replan_runs)
|
||||
};
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Budget first: a budget-dead graph must not spend two replanner
|
||||
// runs right before the dispatch loop trips BudgetLimited. The
|
||||
// discoveries STAY QUEUED (persisted) — a later
|
||||
// `/graph resume --budget` top-up re-enters and replans with
|
||||
// budget actually available.
|
||||
if self.graph_tracker.lock().remaining_budget() == Some(0) {
|
||||
tracing::info!("graph replan: budget exhausted; keeping discoveries queued");
|
||||
return;
|
||||
}
|
||||
let final_achieved = self
|
||||
.graph_tracker
|
||||
.lock()
|
||||
.node(super::super::graph_tracker::FINAL_NODE_ID)
|
||||
.is_some_and(|n| n.status == super::super::graph_tracker::NodeStatus::Achieved);
|
||||
if final_achieved {
|
||||
// The whole-objective gate already passed; late discoveries
|
||||
// (typically from the final verification itself) are
|
||||
// advisory — appending nodes now would ship work the
|
||||
// terminal gate never re-verified.
|
||||
let n = self.graph_tracker.lock().drain_discoveries_to_history();
|
||||
self.persist_graph_state();
|
||||
tracing::info!(
|
||||
drained = n,
|
||||
"graph replan: final already achieved; history only"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if self.graph_replan_cap == 0 {
|
||||
// Feature off: quiet drain (history keeps the audit trail).
|
||||
let n = self.graph_tracker.lock().drain_discoveries_to_history();
|
||||
self.persist_graph_state();
|
||||
tracing::info!(drained = n, "graph replan: disabled (cap 0); history only");
|
||||
return;
|
||||
}
|
||||
if replan_runs >= self.graph_replan_cap {
|
||||
let n = self.graph_tracker.lock().drain_discoveries_to_history();
|
||||
self.persist_graph_state();
|
||||
tracing::warn!(
|
||||
drained = n,
|
||||
replan_runs,
|
||||
cap = self.graph_replan_cap,
|
||||
"graph replan: cap exhausted; discoveries recorded in history only"
|
||||
);
|
||||
self.send_slash_command_output(&format!(
|
||||
"Graph replan cap reached ({replan_runs}/{}); {n} discover{} recorded in \
|
||||
history only — the graph will converge on the current plan.",
|
||||
self.graph_replan_cap,
|
||||
if n == 1 { "y" } else { "ies" },
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(event_tx) = self.tool_context.subagent_event_tx.clone() else {
|
||||
tracing::warn!("graph replan: no subagent coordinator; keeping discoveries queued");
|
||||
return;
|
||||
};
|
||||
let (existing, objective, current_graph, discoveries_text, graph_file, next_version) = {
|
||||
let tracker = self.graph_tracker.lock();
|
||||
let Some(state) = tracker.snapshot() else {
|
||||
return;
|
||||
};
|
||||
let compact: Vec<serde_json::Value> = state
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
serde_json::json!({
|
||||
"id": n.id,
|
||||
"title": n.title,
|
||||
"status": format!("{:?}", n.status),
|
||||
"deps": n.deps.iter().map(|d| d.on.clone()).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let discoveries_text = pending
|
||||
.iter()
|
||||
.map(|d| format!("- (from {}) {}", d.from_node, d.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(
|
||||
state.nodes.clone(),
|
||||
state.objective.clone(),
|
||||
serde_json::to_string(&compact).unwrap_or_default(),
|
||||
discoveries_text,
|
||||
tracker
|
||||
.artifacts_dir()
|
||||
.join(format!("replan.v{}.json", state.plan_version + 1)),
|
||||
state.plan_version + 1,
|
||||
)
|
||||
};
|
||||
let parent_prompt_id = self
|
||||
.current_prompt_id
|
||||
.lock()
|
||||
.expect("current_prompt_id mutex poisoned")
|
||||
.clone();
|
||||
let spawner: Arc<dyn GoalPlannerSpawner> = Arc::new(ChannelSpawner {
|
||||
event_tx,
|
||||
parent_session_id: self.session_id_string(),
|
||||
parent_prompt_id,
|
||||
cwd: Some(self.tool_context.cwd.as_str().to_owned()),
|
||||
role_override: Default::default(),
|
||||
events: Some(self.events.writer()),
|
||||
});
|
||||
let tool_names = self.resolve_inherit_role_tool_names().await;
|
||||
|
||||
let mut feedback = String::new();
|
||||
for attempt in 1..=2u32 {
|
||||
tracing::info!(
|
||||
attempt,
|
||||
next_version,
|
||||
role = GRAPH_REPLANNER_SUBAGENT_DESCRIPTION,
|
||||
pending = pending.len(),
|
||||
"graph replan: firing"
|
||||
);
|
||||
match run_graph_replanner(
|
||||
spawner.clone(),
|
||||
&existing,
|
||||
GraphReplannerInputs {
|
||||
objective: &objective,
|
||||
current_graph: ¤t_graph,
|
||||
discoveries: &discoveries_text,
|
||||
feedback: &feedback,
|
||||
graph_file: &graph_file,
|
||||
tool_names: &tool_names,
|
||||
inherit_tool_names: &tool_names,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
GraphPlannerOutcome::Planned(appendix) if appendix.is_empty() => {
|
||||
// Escape hatch: everything already covered. The pass
|
||||
// still counts against the cap.
|
||||
tracing::info!("graph replan: empty appendix (already covered)");
|
||||
{
|
||||
let mut tracker = self.graph_tracker.lock();
|
||||
tracker.drain_discoveries_to_history();
|
||||
if let Some(state) = tracker.snapshot_mut() {
|
||||
state.replan_runs += 1;
|
||||
}
|
||||
}
|
||||
self.persist_graph_state();
|
||||
return;
|
||||
}
|
||||
GraphPlannerOutcome::Planned(appendix) => {
|
||||
let added = appendix.len();
|
||||
self.graph_tracker.lock().append_replan_nodes(appendix);
|
||||
// Freeze the new version's immutable baseline (full
|
||||
// node set post-append; create_new keeps v{N-1}
|
||||
// byte-identical forever).
|
||||
let all_nodes = self
|
||||
.graph_tracker
|
||||
.lock()
|
||||
.snapshot()
|
||||
.map(|s| s.nodes.clone())
|
||||
.unwrap_or_default();
|
||||
if let Err(err) = self.write_graph_baseline(&all_nodes).await {
|
||||
tracing::warn!(%err, "graph replan: baseline write failed (audit gap only)");
|
||||
}
|
||||
self.persist_graph_state();
|
||||
tracing::info!(added, next_version, "graph replan: appendix installed");
|
||||
self.send_slash_command_output(&format!(
|
||||
"Graph replanned (v{next_version}): {added} node(s) added from \
|
||||
discovered work."
|
||||
))
|
||||
.await;
|
||||
// Plan-boundary optimizer pass ② (piggybacked on the
|
||||
// replan version boundary).
|
||||
self.maybe_optimize_graph().await;
|
||||
return;
|
||||
}
|
||||
GraphPlannerOutcome::Invalid { reason } if attempt == 1 => {
|
||||
tracing::warn!(%reason, "graph replan: invalid appendix; retrying with feedback");
|
||||
feedback = format!(
|
||||
"Your previous replan JSON failed validation:\n{reason}\n\
|
||||
Rewrite the file fixing exactly this."
|
||||
);
|
||||
}
|
||||
GraphPlannerOutcome::Invalid { reason }
|
||||
| GraphPlannerOutcome::FailClosed { reason } => {
|
||||
// Degrade, never pause a working graph for a failed
|
||||
// enhancement pass. The run still counts.
|
||||
tracing::warn!(%reason, "graph replan: failed; draining discoveries to history");
|
||||
{
|
||||
let mut tracker = self.graph_tracker.lock();
|
||||
tracker.drain_discoveries_to_history();
|
||||
if let Some(state) = tracker.snapshot_mut() {
|
||||
state.replan_runs += 1;
|
||||
}
|
||||
}
|
||||
self.persist_graph_state();
|
||||
self.send_slash_command_output(&format!(
|
||||
"Graph replan failed ({reason}); discovered work recorded in \
|
||||
history only."
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
//! Parallel graph-node execution: worker/verifier subagent pairs.
|
||||
//!
|
||||
//! In parallel mode (`KIGI_GRAPH_CONCURRENCY > 1` with ≥2 `Ready`
|
||||
//! nodes) a node does NOT run on the session goal engine — it runs as a
|
||||
//! harness-internal `general-purpose` subagent (the implementer toolset)
|
||||
//! in its OWN git worktree, adversarially checked by a read-only
|
||||
//! verifier subagent, with a bounded worker↔verifier round loop
|
||||
//! (`graph_node_rounds`). Achieved nodes merge back into the main tree
|
||||
//! SEQUENTIALLY via `kigi_workspace`'s 3-way `apply_worktree`; a merge
|
||||
//! conflict fails the node (its dependents block; other chains
|
||||
//! continue). The terminal `gn-final` node always runs serially on the
|
||||
//! full goal engine because it depends on every other node.
|
||||
//!
|
||||
//! Known ceiling: a worker round that outlives the foreground subagent
|
||||
//! await budget (default 600s) is cancelled and counted as a failed
|
||||
//! round with an explicit gap; the next round resumes the same child
|
||||
//! session. Fetching results from auto-backgrounded children would need
|
||||
//! completed-store plumbing — deferred until real usage demands it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kigi_tools::implementations::kigi::task::types::{
|
||||
SubagentEvent, SubagentRequest, SubagentRuntimeOverrides,
|
||||
};
|
||||
|
||||
use super::SessionActor;
|
||||
|
||||
const WORKER_PROMPT_TEMPLATE: &str = include_str!("../templates/graph_node_worker_prompt.md");
|
||||
const VERIFIER_PROMPT_TEMPLATE: &str = include_str!("../templates/graph_node_verifier_prompt.md");
|
||||
|
||||
// Terminal-contract parsing
|
||||
|
||||
/// The worker's parsed claim, from the trailing `NODE_RESULT:` line.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum WorkerClaim {
|
||||
Done { summary: String },
|
||||
Blocked { reason: String },
|
||||
Unparseable,
|
||||
}
|
||||
|
||||
/// Drop ``` fenced code blocks so a QUOTED marker (the templates
|
||||
/// themselves contain fenced `NODE_RESULT:`/`NODE_VERDICT:` examples a
|
||||
/// child may echo) can never be parsed as the real terminal line.
|
||||
fn strip_fenced_blocks(output: &str) -> String {
|
||||
let mut kept = String::with_capacity(output.len());
|
||||
let mut in_fence = false;
|
||||
for line in output.lines() {
|
||||
if line.trim_start().starts_with("```") {
|
||||
in_fence = !in_fence;
|
||||
continue;
|
||||
}
|
||||
if !in_fence {
|
||||
kept.push_str(line);
|
||||
kept.push('\n');
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// The LAST line that STARTS with `marker` (line-anchored — a
|
||||
/// mid-sentence mention never matches), plus everything after it.
|
||||
fn last_marker_line(output: &str, marker: &str) -> Option<(String, String)> {
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
let idx = lines
|
||||
.iter()
|
||||
.rposition(|l| l.trim_start().trim_start_matches('`').starts_with(marker))?;
|
||||
let value = lines[idx]
|
||||
.trim_start()
|
||||
.trim_start_matches('`')
|
||||
.trim_start_matches(marker)
|
||||
.trim()
|
||||
.trim_matches('`')
|
||||
.to_owned();
|
||||
let tail = lines[idx + 1..].join("\n").trim().to_owned();
|
||||
Some((value, tail))
|
||||
}
|
||||
|
||||
/// Parse the last line-anchored `NODE_RESULT:` marker outside fenced
|
||||
/// blocks; text after the marker line is the summary/reason.
|
||||
/// Fail-closed: no marker ⇒ `Unparseable`.
|
||||
pub(crate) fn parse_worker_claim(output: &str) -> WorkerClaim {
|
||||
let stripped = strip_fenced_blocks(output);
|
||||
let Some((value, tail)) = last_marker_line(&stripped, "NODE_RESULT:") else {
|
||||
return WorkerClaim::Unparseable;
|
||||
};
|
||||
match value.as_str() {
|
||||
"done" => WorkerClaim::Done { summary: tail },
|
||||
"blocked" => WorkerClaim::Blocked {
|
||||
reason: if tail.is_empty() {
|
||||
"no reason given".to_owned()
|
||||
} else {
|
||||
tail
|
||||
},
|
||||
},
|
||||
_ => WorkerClaim::Unparseable,
|
||||
}
|
||||
}
|
||||
|
||||
/// The verifier's parsed verdict, from the trailing `NODE_VERDICT:` line.
|
||||
/// Fail-closed: anything unparseable is `NotAchieved` with that fact as
|
||||
/// the gap.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum NodeVerdict {
|
||||
Achieved,
|
||||
NotAchieved { gaps: Vec<String> },
|
||||
}
|
||||
|
||||
pub(crate) fn parse_node_verdict(output: &str) -> NodeVerdict {
|
||||
let stripped = strip_fenced_blocks(output);
|
||||
let Some((value, tail)) = last_marker_line(&stripped, "NODE_VERDICT:") else {
|
||||
return NodeVerdict::NotAchieved {
|
||||
gaps: vec!["verifier response lacked a NODE_VERDICT line".to_owned()],
|
||||
};
|
||||
};
|
||||
match value.as_str() {
|
||||
"achieved" => NodeVerdict::Achieved,
|
||||
"not_achieved" => {
|
||||
let gaps: Vec<String> = tail
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && *l != "GAPS:")
|
||||
.map(|l| l.trim_start_matches('-').trim().to_owned())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
NodeVerdict::NotAchieved {
|
||||
gaps: if gaps.is_empty() {
|
||||
vec!["verifier rejected without naming gaps".to_owned()]
|
||||
} else {
|
||||
gaps
|
||||
},
|
||||
}
|
||||
}
|
||||
other => NodeVerdict::NotAchieved {
|
||||
gaps: vec![format!("unrecognized verdict token {other:?}")],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Line-anchored `DISCOVERED:` items outside fenced blocks. The
|
||||
/// placeholder filter (`<`) drops template echoes ("<one-line
|
||||
/// description …>") a child may parrot back.
|
||||
pub(crate) fn parse_discovered_lines(output: &str) -> Vec<String> {
|
||||
strip_fenced_blocks(output)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
l.trim_start()
|
||||
.trim_start_matches('`')
|
||||
.strip_prefix("DISCOVERED:")
|
||||
})
|
||||
.map(str::trim)
|
||||
// Placeholder-echo defense: drop only the templates' literal
|
||||
// "<one-line description …>" shape, not every '<' (legit Rust
|
||||
// discoveries mention generics like Vec<String>).
|
||||
.filter(|d| !d.is_empty() && !d.starts_with('<'))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Spawner seam (mockable in tests)
|
||||
|
||||
pub(crate) struct WorkerSpawnSpec {
|
||||
pub prompt: String,
|
||||
pub description: String,
|
||||
/// Explicit child cwd (verifiers run in the worker's worktree).
|
||||
pub cwd: Option<String>,
|
||||
/// Mint an isolated worktree for the child (first worker round).
|
||||
pub isolation_worktree: bool,
|
||||
/// Resume a prior child session (later worker rounds keep context
|
||||
/// AND the worktree).
|
||||
pub resume_from: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct WorkerSpawnOutcome {
|
||||
pub success: bool,
|
||||
pub cancelled: bool,
|
||||
pub backgrounded: bool,
|
||||
pub output: String,
|
||||
pub error: Option<String>,
|
||||
pub child_session_id: String,
|
||||
pub tokens_used: u64,
|
||||
pub worktree_path: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub(crate) trait GraphWorkerSpawner: Send + Sync {
|
||||
/// Spawn one child and await its terminal result. `Err` = transport
|
||||
/// failure (coordinator gone).
|
||||
async fn spawn(&self, id: &str, spec: WorkerSpawnSpec) -> Result<WorkerSpawnOutcome, String>;
|
||||
/// Best-effort cancel of a still-running child (budget overrun).
|
||||
async fn cancel(&self, subagent_id: &str);
|
||||
}
|
||||
|
||||
/// Production spawner: raw harness-internal `SubagentEvent::Spawn`,
|
||||
/// exactly the goal-classifier wire (`surface_completion: false`, no
|
||||
/// fork), plus worktree isolation / cwd override for node work.
|
||||
pub(crate) struct GraphWorkerChannelSpawner {
|
||||
pub event_tx: tokio::sync::mpsc::UnboundedSender<SubagentEvent>,
|
||||
pub parent_session_id: String,
|
||||
pub parent_prompt_id: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl GraphWorkerSpawner for GraphWorkerChannelSpawner {
|
||||
async fn spawn(&self, id: &str, spec: WorkerSpawnSpec) -> Result<WorkerSpawnOutcome, String> {
|
||||
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
|
||||
let request = SubagentRequest {
|
||||
id: id.to_string(),
|
||||
prompt: spec.prompt,
|
||||
description: spec.description,
|
||||
// The implementer toolset (full read/edit/bash inventory);
|
||||
// verifier read-only-ness is prompt-enforced, same as the
|
||||
// goal skeptic panel.
|
||||
subagent_type: "general-purpose".to_string(),
|
||||
parent_session_id: self.parent_session_id.clone(),
|
||||
parent_prompt_id: self.parent_prompt_id.clone(),
|
||||
resume_from: spec.resume_from,
|
||||
cwd: spec.cwd,
|
||||
runtime_overrides: SubagentRuntimeOverrides {
|
||||
isolation: spec
|
||||
.isolation_worktree
|
||||
.then_some(kigi_tool_types::SubagentIsolationMode::Worktree),
|
||||
..Default::default()
|
||||
},
|
||||
run_in_background: false,
|
||||
// Harness-internal: never surfaces to the model's idle reminder.
|
||||
surface_completion: false,
|
||||
fork_context: false,
|
||||
result_tx,
|
||||
};
|
||||
if self
|
||||
.event_tx
|
||||
.send(SubagentEvent::Spawn(Box::new(request)))
|
||||
.is_err()
|
||||
{
|
||||
return Err("subagent coordinator channel closed".to_owned());
|
||||
}
|
||||
let result = result_rx
|
||||
.await
|
||||
.map_err(|_| "subagent result channel dropped".to_owned())?;
|
||||
Ok(WorkerSpawnOutcome {
|
||||
success: result.success,
|
||||
cancelled: result.cancelled,
|
||||
backgrounded: result.backgrounded,
|
||||
output: result.output.to_string(),
|
||||
error: result.error.clone(),
|
||||
child_session_id: result.child_session_id.clone(),
|
||||
tokens_used: result.tokens_used,
|
||||
worktree_path: result.worktree_path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn cancel(&self, subagent_id: &str) {
|
||||
use kigi_tools::implementations::kigi::task::types::{
|
||||
SubagentCancelRequest, SubagentCancelTarget,
|
||||
};
|
||||
let (respond_to, ack) = tokio::sync::oneshot::channel();
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(SubagentEvent::Cancel(SubagentCancelRequest {
|
||||
target: SubagentCancelTarget::SubagentId(subagent_id.to_string()),
|
||||
respond_to,
|
||||
}));
|
||||
let _ = ack.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-node bounded closed loop
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct NodeRunReport {
|
||||
pub node_id: String,
|
||||
pub achieved: bool,
|
||||
/// Worker summary on success; failure reason otherwise.
|
||||
pub detail: String,
|
||||
pub rounds: u32,
|
||||
pub tokens_used: i64,
|
||||
pub worktree_path: Option<String>,
|
||||
/// Last worker child session id (audit link, stored on the node).
|
||||
pub worker_session_id: Option<String>,
|
||||
/// `DISCOVERED:` items surfaced by the workers/verifiers (deduped).
|
||||
pub discoveries: Vec<String>,
|
||||
}
|
||||
|
||||
fn worker_prompt(node_objective: &str, gaps: &[String]) -> String {
|
||||
let mut p = String::with_capacity(WORKER_PROMPT_TEMPLATE.len() + node_objective.len() + 256);
|
||||
p.push_str(WORKER_PROMPT_TEMPLATE);
|
||||
p.push_str("\n\nNODE OBJECTIVE:\n");
|
||||
p.push_str(node_objective);
|
||||
if !gaps.is_empty() {
|
||||
p.push_str("\n\nGAPS (from the previous verification round — close exactly these):\n");
|
||||
for gap in gaps {
|
||||
p.push_str("- ");
|
||||
p.push_str(gap);
|
||||
p.push('\n');
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn verifier_prompt(node_objective: &str, worker_summary: &str) -> String {
|
||||
// Neutralize terminal-contract tokens in the worker-controlled
|
||||
// summary so a lazy/adversarial claim cannot smuggle marker lines
|
||||
// into the verifier's context.
|
||||
let safe_summary = worker_summary
|
||||
.replace("NODE_VERDICT", "NODE-VERDICT")
|
||||
.replace("NODE_RESULT", "NODE-RESULT")
|
||||
.replace("DISCOVERED", "DISCOVERED-");
|
||||
format!(
|
||||
"{VERIFIER_PROMPT_TEMPLATE}\n\nNODE OBJECTIVE (the contract to judge):\n{node_objective}\n\n\
|
||||
IMPLEMENTER'S CLAIM (audit it, do not trust it):\n{safe_summary}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Drive one node through bounded worker↔verifier rounds. Never panics;
|
||||
/// every failure path returns a `NodeRunReport` with a precise reason.
|
||||
pub(crate) async fn run_node_to_verdict(
|
||||
spawner: &Arc<dyn GraphWorkerSpawner>,
|
||||
node_id: &str,
|
||||
node_objective: &str,
|
||||
rounds_cap: u32,
|
||||
) -> NodeRunReport {
|
||||
let mut tokens: i64 = 0;
|
||||
let mut discoveries: Vec<String> = Vec::new();
|
||||
let mut gaps: Vec<String> = Vec::new();
|
||||
let mut resume_from: Option<String> = None;
|
||||
let mut worktree_path: Option<String> = None;
|
||||
let mut last_gaps_summary = String::new();
|
||||
|
||||
for round in 1..=rounds_cap {
|
||||
let spawn_id = format!("graph-{node_id}-w{round}-{}", uuid::Uuid::now_v7());
|
||||
let spec_was_isolated = resume_from.is_none();
|
||||
let spec = WorkerSpawnSpec {
|
||||
prompt: worker_prompt(node_objective, &gaps),
|
||||
description: format!("graph node worker ({node_id})"),
|
||||
cwd: None,
|
||||
// Fresh worktree only on the first round; resumes reuse it.
|
||||
isolation_worktree: spec_was_isolated,
|
||||
resume_from: resume_from.clone(),
|
||||
};
|
||||
tracing::info!(%node_id, round, resumed = resume_from.is_some(), "graph worker: round start");
|
||||
let outcome = match spawner.spawn(&spawn_id, spec).await {
|
||||
Ok(o) => o,
|
||||
Err(err) => {
|
||||
return NodeRunReport {
|
||||
node_id: node_id.to_owned(),
|
||||
achieved: false,
|
||||
detail: format!("worker transport failure: {err}"),
|
||||
rounds: round,
|
||||
tokens_used: tokens,
|
||||
worktree_path,
|
||||
worker_session_id: resume_from,
|
||||
discoveries: discoveries.clone(),
|
||||
};
|
||||
}
|
||||
};
|
||||
let round_requested_isolation = spec_was_isolated;
|
||||
tokens = tokens.saturating_add(outcome.tokens_used as i64);
|
||||
if outcome.worktree_path.is_some() {
|
||||
worktree_path = outcome.worktree_path.clone();
|
||||
}
|
||||
// Adopt-guard: an in-band spawn failure carries an EMPTY child id;
|
||||
// adopting it would make the next round a fresh UNISOLATED spawn
|
||||
// in the shared tree while verify/merge still target the stale
|
||||
// worktree. Keep the last valid id (or None ⇒ re-mint isolation).
|
||||
if !outcome.child_session_id.is_empty() {
|
||||
resume_from = Some(outcome.child_session_id.clone());
|
||||
}
|
||||
for d in parse_discovered_lines(&outcome.output) {
|
||||
if !discoveries.contains(&d) {
|
||||
discoveries.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
if outcome.cancelled {
|
||||
return NodeRunReport {
|
||||
node_id: node_id.to_owned(),
|
||||
achieved: false,
|
||||
detail: "worker cancelled".to_owned(),
|
||||
rounds: round,
|
||||
tokens_used: tokens,
|
||||
worktree_path,
|
||||
worker_session_id: resume_from,
|
||||
discoveries,
|
||||
};
|
||||
}
|
||||
if outcome.backgrounded {
|
||||
// Ceiling (see module doc): cancel the runaway child and
|
||||
// burn the round; the resume keeps its context.
|
||||
tracing::warn!(%node_id, round, "graph worker: exceeded foreground await budget; cancelling round");
|
||||
// Cancel by the SPAWN REQUEST id — the coordinator's cancel
|
||||
// maps are keyed by it, not by the child session id.
|
||||
spawner.cancel(&spawn_id).await;
|
||||
gaps = vec![
|
||||
"the previous round exceeded the foreground time budget and was cancelled; \
|
||||
split the remaining work into smaller, faster steps"
|
||||
.to_owned(),
|
||||
];
|
||||
last_gaps_summary = gaps.join("; ");
|
||||
continue;
|
||||
}
|
||||
if !outcome.success {
|
||||
let err = outcome.error.unwrap_or_else(|| "unknown error".to_owned());
|
||||
tracing::warn!(%node_id, round, %err, "graph worker: round failed");
|
||||
gaps = vec![format!("the previous round failed with an error: {err}")];
|
||||
last_gaps_summary = gaps.join("; ");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Isolation guard: a SUCCESSFUL first round that came back with
|
||||
// no worktree means isolation silently degraded (non-git dir,
|
||||
// worktree creation failure, or the snapshot-disposal flag
|
||||
// deleted it before we saw it). Running parallel writers in the
|
||||
// shared tree — or merging a disposed tree — is never OK.
|
||||
if round_requested_isolation && outcome.worktree_path.is_none() {
|
||||
return NodeRunReport {
|
||||
node_id: node_id.to_owned(),
|
||||
achieved: false,
|
||||
detail: "worktree isolation unavailable for this node (non-git directory, \
|
||||
worktree creation failure, or KIGI_SUBAGENT_WORKTREE_SNAPSHOT \
|
||||
disposal); parallel execution requires isolation"
|
||||
.to_owned(),
|
||||
rounds: round,
|
||||
tokens_used: tokens,
|
||||
worktree_path: None,
|
||||
worker_session_id: resume_from,
|
||||
discoveries: discoveries.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
match parse_worker_claim(&outcome.output) {
|
||||
WorkerClaim::Blocked { reason } => {
|
||||
return NodeRunReport {
|
||||
node_id: node_id.to_owned(),
|
||||
achieved: false,
|
||||
detail: format!("worker reported blocked: {reason}"),
|
||||
rounds: round,
|
||||
tokens_used: tokens,
|
||||
worktree_path,
|
||||
worker_session_id: resume_from,
|
||||
discoveries: discoveries.clone(),
|
||||
};
|
||||
}
|
||||
WorkerClaim::Unparseable => {
|
||||
gaps = vec![
|
||||
"the previous round's final message lacked the required NODE_RESULT line"
|
||||
.to_owned(),
|
||||
];
|
||||
last_gaps_summary = gaps.join("; ");
|
||||
continue;
|
||||
}
|
||||
WorkerClaim::Done { summary } => {
|
||||
let verify_id = format!("graph-{node_id}-v{round}-{}", uuid::Uuid::now_v7());
|
||||
let verify_spec = WorkerSpawnSpec {
|
||||
prompt: verifier_prompt(node_objective, &summary),
|
||||
description: format!("graph node verifier ({node_id})"),
|
||||
// The verifier inspects the worker's worktree.
|
||||
cwd: worktree_path.clone(),
|
||||
isolation_worktree: false,
|
||||
resume_from: None,
|
||||
};
|
||||
let verdict = match spawner.spawn(&verify_id, verify_spec).await {
|
||||
Ok(v) => {
|
||||
tokens = tokens.saturating_add(v.tokens_used as i64);
|
||||
for d in parse_discovered_lines(&v.output) {
|
||||
if !discoveries.contains(&d) {
|
||||
discoveries.push(d);
|
||||
}
|
||||
}
|
||||
if v.success {
|
||||
parse_node_verdict(&v.output)
|
||||
} else {
|
||||
// Fail CLOSED: an unverified claim never passes.
|
||||
NodeVerdict::NotAchieved {
|
||||
gaps: vec![format!(
|
||||
"verifier run failed ({}); the claim is unverified",
|
||||
v.error.unwrap_or_else(|| "unknown error".to_owned())
|
||||
)],
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => NodeVerdict::NotAchieved {
|
||||
gaps: vec![format!("verifier transport failure: {err}")],
|
||||
},
|
||||
};
|
||||
match verdict {
|
||||
NodeVerdict::Achieved => {
|
||||
tracing::info!(%node_id, round, tokens, "graph worker: node verified achieved");
|
||||
return NodeRunReport {
|
||||
node_id: node_id.to_owned(),
|
||||
achieved: true,
|
||||
detail: summary,
|
||||
rounds: round,
|
||||
tokens_used: tokens,
|
||||
worktree_path,
|
||||
worker_session_id: resume_from,
|
||||
discoveries: discoveries.clone(),
|
||||
};
|
||||
}
|
||||
NodeVerdict::NotAchieved { gaps: new_gaps } => {
|
||||
tracing::info!(%node_id, round, gap_count = new_gaps.len(), "graph worker: verifier rejected round");
|
||||
last_gaps_summary = new_gaps.join("; ");
|
||||
gaps = new_gaps;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeRunReport {
|
||||
node_id: node_id.to_owned(),
|
||||
achieved: false,
|
||||
detail: format!(
|
||||
"verification rejected after {rounds_cap} rounds; last gaps: {last_gaps_summary}"
|
||||
),
|
||||
rounds: rounds_cap,
|
||||
tokens_used: tokens,
|
||||
worktree_path,
|
||||
worker_session_id: resume_from,
|
||||
discoveries,
|
||||
}
|
||||
}
|
||||
|
||||
/// `git rev-parse HEAD` of `dir`, `None` outside a git repo.
|
||||
async fn git_head(dir: &std::path::Path) -> Option<String> {
|
||||
let output = tokio::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
// SessionActor integration
|
||||
|
||||
impl SessionActor {
|
||||
/// Production worker spawner wired to this session's coordinator.
|
||||
fn graph_worker_spawner(&self) -> Option<Arc<dyn GraphWorkerSpawner>> {
|
||||
let event_tx = self.tool_context.subagent_event_tx.clone()?;
|
||||
let parent_prompt_id = self
|
||||
.current_prompt_id
|
||||
.lock()
|
||||
.expect("current_prompt_id mutex poisoned")
|
||||
.clone();
|
||||
Some(Arc::new(GraphWorkerChannelSpawner {
|
||||
event_tx,
|
||||
parent_session_id: self.session_id_string(),
|
||||
parent_prompt_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Run one parallel batch of `Ready` nodes to their verdicts, then
|
||||
/// merge achieved worktrees back SEQUENTIALLY in batch order. All
|
||||
/// tracker mutations + persistence happen here; the caller re-reads
|
||||
/// the tracker afterwards.
|
||||
pub(super) async fn run_graph_parallel_batch(&self, node_ids: Vec<String>) {
|
||||
let Some(spawner) = self.graph_worker_spawner() else {
|
||||
tracing::error!("graph batch: no subagent coordinator; pausing graph");
|
||||
self.graph_tracker.lock().pause_with_message(
|
||||
crate::session::goal_tracker::GoalPauseReason::Infra,
|
||||
"No subagent coordinator available for parallel execution".to_owned(),
|
||||
);
|
||||
self.persist_graph_state();
|
||||
return;
|
||||
};
|
||||
// Compose objectives + mark Running under one lock pass.
|
||||
let mut jobs: Vec<(String, String)> = Vec::with_capacity(node_ids.len());
|
||||
{
|
||||
let mut tracker = self.graph_tracker.lock();
|
||||
let Some(snapshot) = tracker.snapshot() else {
|
||||
return;
|
||||
};
|
||||
let total = snapshot.nodes.len();
|
||||
let objective = snapshot.objective.clone();
|
||||
for id in &node_ids {
|
||||
if let Some(pos) = snapshot.nodes.iter().position(|n| n.id == *id) {
|
||||
let node = &snapshot.nodes[pos];
|
||||
jobs.push((
|
||||
id.clone(),
|
||||
super::graph::node_goal_objective(&objective, node, pos + 1, total),
|
||||
));
|
||||
}
|
||||
}
|
||||
for (id, _) in &jobs {
|
||||
tracker.mark_node_running(id, String::new());
|
||||
}
|
||||
// `current_node` means "the node on the serial goal engine";
|
||||
// batch nodes are tracked by their own Running status.
|
||||
if let Some(s) = tracker.snapshot_mut() {
|
||||
s.current_node = None;
|
||||
}
|
||||
}
|
||||
self.persist_graph_state();
|
||||
// Merge-base integrity: if the main repo HEAD moves during the
|
||||
// batch (external commit), apply_worktree would diff against the
|
||||
// wrong base and silently reverse-apply those commits. Capture
|
||||
// HEAD now; every merge re-checks it.
|
||||
let head_at_fanout = git_head(self.tool_context.cwd.as_path()).await;
|
||||
let rounds_cap = self.graph_node_rounds;
|
||||
tracing::info!(batch = jobs.len(), rounds_cap, "graph batch: fan-out");
|
||||
|
||||
let reports = futures::future::join_all(jobs.iter().map(|(id, objective)| {
|
||||
let spawner = spawner.clone();
|
||||
async move { run_node_to_verdict(&spawner, id, objective, rounds_cap).await }
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Sequential merge + tracker resolution in batch order.
|
||||
let mut achieved = 0usize;
|
||||
let mut failed = 0usize;
|
||||
for report in reports {
|
||||
// Stamp the worker session id for audit (goal_id slot).
|
||||
if let Some(worker_id) = &report.worker_session_id
|
||||
&& let Some(node) = self
|
||||
.graph_tracker
|
||||
.lock()
|
||||
.snapshot_mut()
|
||||
.and_then(|s| s.nodes.iter_mut().find(|n| n.id == report.node_id))
|
||||
{
|
||||
node.goal_id = Some(worker_id.clone());
|
||||
}
|
||||
if !report.discoveries.is_empty() {
|
||||
// A failed node's discoveries are still real work.
|
||||
let ds: Vec<crate::session::graph_tracker::Discovery> = report
|
||||
.discoveries
|
||||
.iter()
|
||||
.map(|d| crate::session::graph_tracker::Discovery {
|
||||
from_node: report.node_id.clone(),
|
||||
description: d.clone(),
|
||||
})
|
||||
.collect();
|
||||
self.graph_tracker.lock().queue_discoveries(ds);
|
||||
}
|
||||
if !report.achieved {
|
||||
failed += 1;
|
||||
{
|
||||
let mut tracker = self.graph_tracker.lock();
|
||||
// Budget integrity: a failed node's tokens were still
|
||||
// spent — charge them before failing the node.
|
||||
tracker.charge_node_tokens(&report.node_id, report.tokens_used);
|
||||
tracker.mark_node_failed(&report.node_id, report.detail.clone());
|
||||
}
|
||||
self.persist_graph_state();
|
||||
continue;
|
||||
}
|
||||
match self
|
||||
.merge_node_worktree(
|
||||
&report.node_id,
|
||||
report.worktree_path.as_deref(),
|
||||
head_at_fanout.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
achieved += 1;
|
||||
self.graph_tracker.lock().mark_node_achieved(
|
||||
&report.node_id,
|
||||
report.rounds,
|
||||
report.tokens_used,
|
||||
);
|
||||
}
|
||||
Err(detail) => {
|
||||
failed += 1;
|
||||
self.graph_tracker
|
||||
.lock()
|
||||
.mark_node_failed(&report.node_id, detail);
|
||||
}
|
||||
}
|
||||
self.persist_graph_state();
|
||||
}
|
||||
tracing::info!(achieved, failed, "graph batch: settled");
|
||||
self.send_slash_command_output(&format!(
|
||||
"Graph batch settled: {achieved} node(s) achieved, {failed} failed."
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Merge one achieved node's worktree back into the main tree with
|
||||
/// the 3-way apply. `None` worktree (isolation soft-fallback) means
|
||||
/// the worker already wrote in the shared tree — nothing to merge.
|
||||
pub(super) async fn merge_node_worktree(
|
||||
&self,
|
||||
node_id: &str,
|
||||
worktree_path: Option<&str>,
|
||||
expected_main_head: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let Some(worktree_path) = worktree_path else {
|
||||
tracing::warn!(
|
||||
%node_id,
|
||||
"graph merge: worker ran without worktree isolation (soft fallback); nothing to merge"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
use kigi_workspace::worktree::{
|
||||
ApplyMode, ApplyWorktreeRequest, ApplyWorktreeResponse, apply_worktree,
|
||||
};
|
||||
// apply_worktree diffs against the main repo HEAD AT APPLY TIME;
|
||||
// if HEAD moved since fan-out, that diff would silently
|
||||
// reverse-apply the external commits. Fail the node loudly.
|
||||
if let Some(expected) = expected_main_head {
|
||||
let current = git_head(self.tool_context.cwd.as_path()).await;
|
||||
if current.as_deref() != Some(expected) {
|
||||
return Err(format!(
|
||||
"main repository HEAD moved during the batch (was {expected}, now {}); \
|
||||
merge aborted for safety — /graph resume re-runs the node",
|
||||
current.as_deref().unwrap_or("unknown")
|
||||
));
|
||||
}
|
||||
}
|
||||
let request = ApplyWorktreeRequest {
|
||||
session_id: self.session_id_string(),
|
||||
worktree_path: worktree_path.to_owned(),
|
||||
mode: ApplyMode::Merge,
|
||||
};
|
||||
match apply_worktree(&request).await {
|
||||
Ok(ApplyWorktreeResponse::Success { files, .. }) => {
|
||||
tracing::info!(%node_id, files = files.len(), "graph merge: applied");
|
||||
// Storage discipline: the changes now live in the main
|
||||
// tree, so the worktree is dead weight — remove it.
|
||||
// Best-effort (a failed removal only leaks disk, never
|
||||
// progress) but always logged. Failed nodes KEEP their
|
||||
// worktree for postmortem.
|
||||
if let Err(err) = kigi_workspace::worktree::remove_subagent_worktree(
|
||||
std::path::Path::new(worktree_path),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%node_id, %err, "graph merge: worktree cleanup failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(ApplyWorktreeResponse::Conflicts { conflicts, .. }) => {
|
||||
let names: Vec<String> = conflicts.iter().map(|c| c.path.clone()).collect();
|
||||
tracing::warn!(%node_id, ?names, "graph merge: conflicts; failing node");
|
||||
Err(format!("merge conflict in: {}", names.join(", ")))
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(%node_id, %err, "graph merge: apply failed");
|
||||
Err(format!("worktree apply failed: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn worker_claim_parses_done_blocked_and_garbage() {
|
||||
assert_eq!(
|
||||
parse_worker_claim("work...\nNODE_RESULT: done\nBuilt X; tests pass."),
|
||||
WorkerClaim::Done {
|
||||
summary: "Built X; tests pass.".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_worker_claim("NODE_RESULT: blocked\nno compiler available"),
|
||||
WorkerClaim::Blocked {
|
||||
reason: "no compiler available".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_worker_claim("all done, promise!"),
|
||||
WorkerClaim::Unparseable
|
||||
);
|
||||
// Last marker wins (a quoted earlier marker cannot spoof).
|
||||
assert_eq!(
|
||||
parse_worker_claim(
|
||||
"NODE_RESULT: done\nold\n...more work...\nNODE_RESULT: blocked\nreal"
|
||||
),
|
||||
WorkerClaim::Blocked {
|
||||
reason: "real".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verdict_parses_achieved_gaps_and_fails_closed() {
|
||||
assert_eq!(
|
||||
parse_node_verdict("checked\nNODE_VERDICT: achieved"),
|
||||
NodeVerdict::Achieved
|
||||
);
|
||||
assert_eq!(
|
||||
parse_node_verdict(
|
||||
"NODE_VERDICT: not_achieved\nGAPS:\n- test suite not run\n- claim B unverified"
|
||||
),
|
||||
NodeVerdict::NotAchieved {
|
||||
gaps: vec![
|
||||
"test suite not run".to_owned(),
|
||||
"claim B unverified".to_owned()
|
||||
]
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
parse_node_verdict("looks good to me"),
|
||||
NodeVerdict::NotAchieved { gaps } if gaps[0].contains("lacked a NODE_VERDICT")
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_node_verdict("NODE_VERDICT: maybe"),
|
||||
NodeVerdict::NotAchieved { gaps } if gaps[0].contains("unrecognized verdict")
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_node_verdict("NODE_VERDICT: not_achieved"),
|
||||
NodeVerdict::NotAchieved { gaps } if gaps[0].contains("without naming gaps")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fenced_template_echo_cannot_spoof_markers() {
|
||||
// A worker echoing the template's fenced examples must stay
|
||||
// unparseable; only its own line-anchored terminal marker counts.
|
||||
let echoed = "Here is my plan:\n```\nNODE_RESULT: done\n```\nstill working...";
|
||||
assert_eq!(parse_worker_claim(echoed), WorkerClaim::Unparseable);
|
||||
let real = "```\nNODE_RESULT: blocked\n```\n...work...\nNODE_RESULT: done\nall built";
|
||||
assert_eq!(
|
||||
parse_worker_claim(real),
|
||||
WorkerClaim::Done {
|
||||
summary: "all built".to_owned()
|
||||
}
|
||||
);
|
||||
// Mid-sentence mention is not a marker (line-anchored scan).
|
||||
assert_eq!(
|
||||
parse_worker_claim("I will print NODE_RESULT: done when finished"),
|
||||
WorkerClaim::Unparseable
|
||||
);
|
||||
// Same discipline for the verifier.
|
||||
assert!(matches!(
|
||||
parse_node_verdict("quoting:\n```\nNODE_VERDICT: achieved\n```\nhmm"),
|
||||
NodeVerdict::NotAchieved { .. }
|
||||
));
|
||||
}
|
||||
|
||||
struct MockSpawner {
|
||||
replies: std::sync::Mutex<std::collections::VecDeque<WorkerSpawnOutcome>>,
|
||||
specs: std::sync::Mutex<Vec<(String, Option<String>, bool)>>,
|
||||
cancels: std::sync::Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl GraphWorkerSpawner for MockSpawner {
|
||||
async fn spawn(
|
||||
&self,
|
||||
id: &str,
|
||||
spec: WorkerSpawnSpec,
|
||||
) -> Result<WorkerSpawnOutcome, String> {
|
||||
self.specs.lock().unwrap().push((
|
||||
id.to_owned(),
|
||||
spec.resume_from.clone(),
|
||||
spec.isolation_worktree,
|
||||
));
|
||||
Ok(self
|
||||
.replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected extra spawn"))
|
||||
}
|
||||
async fn cancel(&self, subagent_id: &str) {
|
||||
self.cancels.lock().unwrap().push(subagent_id.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome(output: &str) -> WorkerSpawnOutcome {
|
||||
WorkerSpawnOutcome {
|
||||
success: true,
|
||||
cancelled: false,
|
||||
backgrounded: false,
|
||||
output: output.to_owned(),
|
||||
error: None,
|
||||
child_session_id: "child-1".to_owned(),
|
||||
tokens_used: 5,
|
||||
worktree_path: Some("/wt".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backgrounded_round_cancels_by_spawn_id_and_resumes_next_round() {
|
||||
let mut bg = outcome("");
|
||||
bg.backgrounded = true;
|
||||
let replies = std::collections::VecDeque::from(vec![
|
||||
bg, // round 1: budget overrun
|
||||
outcome("NODE_RESULT: done\nfinished"), // round 2: worker done
|
||||
outcome("NODE_VERDICT: achieved"), // round 2: verifier
|
||||
]);
|
||||
// Keep a concrete handle for assertions; hand the trait object in.
|
||||
let mock = Arc::new(MockSpawner {
|
||||
replies: std::sync::Mutex::new(replies),
|
||||
specs: std::sync::Mutex::new(Vec::new()),
|
||||
cancels: std::sync::Mutex::new(Vec::new()),
|
||||
});
|
||||
let spawner: Arc<dyn GraphWorkerSpawner> = mock.clone();
|
||||
let report = run_node_to_verdict(&spawner, "gn-x", "do x", 3).await;
|
||||
assert!(report.achieved, "{}", report.detail);
|
||||
assert_eq!(report.rounds, 2, "backgrounded round burned, retry won");
|
||||
assert_eq!(report.tokens_used, 15, "all three spawns charged");
|
||||
|
||||
let cancels = mock.cancels.lock().unwrap().clone();
|
||||
assert_eq!(cancels.len(), 1, "runaway child cancelled once");
|
||||
assert!(
|
||||
cancels[0].starts_with("graph-gn-x-w1-"),
|
||||
"cancel must target the SPAWN REQUEST id (coordinator map key), got {}",
|
||||
cancels[0]
|
||||
);
|
||||
let specs = mock.specs.lock().unwrap().clone();
|
||||
assert_eq!(specs.len(), 3);
|
||||
assert!(
|
||||
specs[0].1.is_none() && specs[0].2,
|
||||
"round 1: fresh + isolated"
|
||||
);
|
||||
assert_eq!(
|
||||
specs[1].1.as_deref(),
|
||||
Some("child-1"),
|
||||
"round 2 resumes the backgrounded child's session"
|
||||
);
|
||||
assert!(!specs[1].2, "resume never re-mints isolation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_child_id_is_never_adopted_as_resume_target() {
|
||||
// In-band spawn failure: success=false, child_session_id="".
|
||||
let failed = WorkerSpawnOutcome {
|
||||
success: false,
|
||||
cancelled: false,
|
||||
backgrounded: false,
|
||||
output: String::new(),
|
||||
error: Some("boom".to_owned()),
|
||||
child_session_id: String::new(),
|
||||
tokens_used: 0,
|
||||
worktree_path: None,
|
||||
};
|
||||
let replies = std::collections::VecDeque::from(vec![
|
||||
failed, // round 1: in-band failure
|
||||
outcome("NODE_RESULT: done\nfinished"), // round 2: worker (fresh, isolated)
|
||||
outcome("NODE_VERDICT: achieved"), // round 2: verifier
|
||||
]);
|
||||
let mock = Arc::new(MockSpawner {
|
||||
replies: std::sync::Mutex::new(replies),
|
||||
specs: std::sync::Mutex::new(Vec::new()),
|
||||
cancels: std::sync::Mutex::new(Vec::new()),
|
||||
});
|
||||
let spawner: Arc<dyn GraphWorkerSpawner> = mock.clone();
|
||||
let report = run_node_to_verdict(&spawner, "gn-y", "do y", 3).await;
|
||||
assert!(report.achieved, "{}", report.detail);
|
||||
let specs = mock.specs.lock().unwrap().clone();
|
||||
assert!(
|
||||
specs[1].1.is_none(),
|
||||
"an empty child id must NOT be adopted; retry is a fresh spawn"
|
||||
);
|
||||
assert!(
|
||||
specs[1].2,
|
||||
"fresh retry re-mints worktree isolation (no unisolated escape)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_isolated_round_without_worktree_fails_the_node() {
|
||||
let mut no_wt = outcome("NODE_RESULT: done\nfinished");
|
||||
no_wt.worktree_path = None;
|
||||
let replies = std::collections::VecDeque::from(vec![no_wt]);
|
||||
let mock = Arc::new(MockSpawner {
|
||||
replies: std::sync::Mutex::new(replies),
|
||||
specs: std::sync::Mutex::new(Vec::new()),
|
||||
cancels: std::sync::Mutex::new(Vec::new()),
|
||||
});
|
||||
let spawner: Arc<dyn GraphWorkerSpawner> = mock.clone();
|
||||
let report = run_node_to_verdict(&spawner, "gn-z", "do z", 3).await;
|
||||
assert!(!report.achieved);
|
||||
assert!(
|
||||
report.detail.contains("isolation unavailable"),
|
||||
"{}",
|
||||
report.detail
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -747,16 +747,24 @@ impl SessionActor {
|
||||
ok_end_turn(0, None)
|
||||
}
|
||||
BuiltinAction::GoalPause => {
|
||||
if self.graph_owns_goal_engine() {
|
||||
self.send_slash_command_output(
|
||||
"A graph owns the goal engine. Use /graph pause instead.",
|
||||
)
|
||||
.await;
|
||||
return ok_end_turn(0, None);
|
||||
}
|
||||
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
|
||||
use crate::session::goal_tracker::{GoalPauseReason, GoalStatus};
|
||||
let (msg, changed) = {
|
||||
let mut tracker = self.goal_tracker.lock();
|
||||
match tracker.status() {
|
||||
Some(GoalStatus::Active) => {
|
||||
debug_assert!(
|
||||
tracker.pause(GoalPauseReason::User),
|
||||
"Active goal must pause"
|
||||
);
|
||||
// Side effect OUTSIDE the assert: debug_assert!
|
||||
// strips its condition in release builds, which
|
||||
// would silently skip the pause itself.
|
||||
let paused = tracker.pause(GoalPauseReason::User);
|
||||
debug_assert!(paused, "Active goal must pause");
|
||||
("Goal paused. Use /goal resume to continue.", true)
|
||||
}
|
||||
Some(
|
||||
@@ -790,27 +798,149 @@ impl SessionActor {
|
||||
unreachable!("GoalResume is intercepted in handle_prompt")
|
||||
}
|
||||
BuiltinAction::GoalClear => {
|
||||
self.goal_tracker.lock().clear();
|
||||
// `/goal clear` is a deliberate user reset — drop both
|
||||
// streaks so stale counters from the previous goal
|
||||
// can't leak into the next one.
|
||||
self.goal_continuation_streak
|
||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
self.goal_blocked_streak
|
||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
// Drop goal-turn-origin task ids so a future goal's drain
|
||||
// doesn't suppress the next goal's (or post-goal) tasks.
|
||||
self.goal_turn_task_ids.lock().clear();
|
||||
// Clear per-subagent token records so stale entries
|
||||
// from the previous goal don't leak into the next.
|
||||
self.subagent_token_records.lock().clear();
|
||||
self.clear_pending_classifier_completions();
|
||||
// Emit a cleared notification so the pager drops goal state.
|
||||
let update = crate::session::goal_orchestrator::build_goal_cleared();
|
||||
self.send_xai_notification(update).await;
|
||||
if self.graph_owns_goal_engine() {
|
||||
self.send_slash_command_output(
|
||||
"A graph owns the goal engine. Use /graph clear instead.",
|
||||
)
|
||||
.await;
|
||||
return ok_end_turn(0, None);
|
||||
}
|
||||
// `/goal clear` is a deliberate user reset — the shared
|
||||
// helper drops the tracker, both streaks, goal-turn task
|
||||
// ids, per-subagent token records, pending classifier
|
||||
// claims, and notifies the pager. Shared with the graph
|
||||
// node boundary and `/graph clear`.
|
||||
self.reset_goal_engine_state().await;
|
||||
self.send_slash_command_output("Goal cleared.").await;
|
||||
ok_end_turn(0, None)
|
||||
}
|
||||
BuiltinAction::GraphStatus => {
|
||||
let msg = self.graph_status_message().await;
|
||||
self.send_slash_command_output(&msg).await;
|
||||
ok_end_turn(0, None)
|
||||
}
|
||||
BuiltinAction::GraphShow => {
|
||||
// Box-drawing DAG; wider than the budget (or no graph)
|
||||
// degrades to the indented status tree — wrapped box art
|
||||
// is worse than no art.
|
||||
const SHOW_WIDTH_BUDGET: usize = 120;
|
||||
let rendered =
|
||||
self.graph_tracker.lock().snapshot().and_then(|s| {
|
||||
crate::session::graph_render::render_dag(s, SHOW_WIDTH_BUDGET)
|
||||
});
|
||||
let msg = match rendered {
|
||||
Some(dag) => dag,
|
||||
None => self.graph_status_message().await,
|
||||
};
|
||||
self.send_slash_command_output(&msg).await;
|
||||
ok_end_turn(0, None)
|
||||
}
|
||||
BuiltinAction::GraphPause => {
|
||||
use crate::session::goal_tracker::{GoalPauseReason, GoalStatus};
|
||||
let (msg, changed) = {
|
||||
let mut tracker = self.graph_tracker.lock();
|
||||
match tracker.status() {
|
||||
Some(GoalStatus::Active) => {
|
||||
// Side effect OUTSIDE the assert: debug_assert!
|
||||
// strips its condition in release builds, which
|
||||
// would silently skip the pause itself.
|
||||
let paused = tracker.pause(GoalPauseReason::User);
|
||||
debug_assert!(paused, "Active graph must pause");
|
||||
("Graph paused. Use /graph resume to continue.", true)
|
||||
}
|
||||
Some(s) if s.is_paused() => ("Graph is already paused.", false),
|
||||
Some(GoalStatus::Complete) => ("Graph is already complete.", false),
|
||||
Some(GoalStatus::BudgetLimited) => ("Graph is budget-limited.", false),
|
||||
Some(_) | None => ("No graph is currently set.", false),
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
// Pause the running node's goal too so the in-turn
|
||||
// loop stops at the next round boundary.
|
||||
self.auto_pause_goal_if_active(GoalPauseReason::User).await;
|
||||
self.persist_graph_state();
|
||||
}
|
||||
self.send_slash_command_output(msg).await;
|
||||
ok_end_turn(0, None)
|
||||
}
|
||||
BuiltinAction::GraphClear => {
|
||||
let had_graph = self.graph_tracker.lock().snapshot().is_some();
|
||||
// Clear the goal engine ONLY when the graph owns it
|
||||
// (Active/paused ⇒ the engine's goal is a node goal). A
|
||||
// terminal graph (Complete/BudgetLimited) may coexist
|
||||
// with an unrelated standalone /goal the user started
|
||||
// afterwards — that goal must survive /graph clear.
|
||||
if self.graph_owns_goal_engine() {
|
||||
self.reset_goal_engine_state().await;
|
||||
}
|
||||
// Projection teardown only when there is something of
|
||||
// OURS to un-project: with no session graph, deleting
|
||||
// .kigi/graph.jsonl would destroy another session's
|
||||
// revivable graph while replying "No graph is set".
|
||||
let session_graph_id = self
|
||||
.graph_tracker
|
||||
.lock()
|
||||
.snapshot()
|
||||
.map(|s| s.graph_id.clone());
|
||||
self.graph_tracker.lock().clear();
|
||||
if had_graph {
|
||||
// Take the writer lock if we don't hold it (e.g. a
|
||||
// session-snapshot-restored graph cleared before any
|
||||
// resume). Busy = another instance owns the project
|
||||
// graph; local-only clear is then correct.
|
||||
match self.acquire_project_graph_writer() {
|
||||
Ok(true) => {
|
||||
// Identity check: only remove a projection
|
||||
// that belongs to the graph being cleared.
|
||||
let foreign = match (self.projected_graph_id(), &session_graph_id) {
|
||||
(Some(projected), Some(ours)) => projected != *ours,
|
||||
_ => false,
|
||||
};
|
||||
if foreign {
|
||||
tracing::info!(
|
||||
"graph clear: projection belongs to a different \
|
||||
graph; leaving .kigi/graph.jsonl in place"
|
||||
);
|
||||
self.graph_project_lock.borrow_mut().take();
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::info!(
|
||||
"graph clear: another instance holds the project \
|
||||
graph; local session state cleared only"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
%err,
|
||||
"graph clear: project lock acquisition failed; \
|
||||
.kigi/graph.jsonl may survive as stale"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// persist runs BEFORE the lock drops so the projection
|
||||
// removal (when we hold writer rights on OUR graph)
|
||||
// executes; without the lock it is a session-only clear.
|
||||
self.persist_graph_state();
|
||||
self.graph_project_lock.borrow_mut().take();
|
||||
self.send_slash_command_output(if had_graph {
|
||||
"Graph cleared."
|
||||
} else {
|
||||
"No graph is currently set."
|
||||
})
|
||||
.await;
|
||||
ok_end_turn(0, None)
|
||||
}
|
||||
// GraphSet / GraphResume are intercepted in handle_prompt
|
||||
// (like GoalSet / GoalResume) so a successful setup/resume
|
||||
// flows through to model inference.
|
||||
BuiltinAction::GraphSet { .. } => {
|
||||
unreachable!("GraphSet is intercepted in handle_prompt")
|
||||
}
|
||||
BuiltinAction::GraphResume { .. } => {
|
||||
unreachable!("GraphResume is intercepted in handle_prompt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ pub(crate) async fn spawn_session_actor(
|
||||
persisted_signals: Option<crate::session::signals::SessionSignals>,
|
||||
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
||||
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
persisted_graph_mode: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||
memory_config: Option<crate::config::MemoryConfig>,
|
||||
feedback_flags: crate::session::feedback_manager::FeedbackFlags,
|
||||
@@ -150,6 +151,7 @@ pub(crate) async fn spawn_session_actor(
|
||||
app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig,
|
||||
write_file_enabled: bool,
|
||||
goal_enabled: bool,
|
||||
graph_enabled: bool,
|
||||
subagents_enabled: bool,
|
||||
ask_user_question_enabled: bool,
|
||||
client_hooks: crate::extensions::hooks::ClientHooks,
|
||||
@@ -429,6 +431,17 @@ pub(crate) async fn spawn_session_actor(
|
||||
};
|
||||
Arc::new(parking_lot::Mutex::new(tracker))
|
||||
};
|
||||
let graph_project_dir =
|
||||
crate::session::graph_project::project_graph_dir(tool_context.cwd.as_path());
|
||||
let graph_tracker = {
|
||||
let session_dir = crate::session::persistence::session_dir(&session_info);
|
||||
let tracker = if let Some(snapshot) = persisted_graph_mode {
|
||||
crate::session::graph_tracker::GraphTracker::from_snapshot(session_dir, snapshot)
|
||||
} else {
|
||||
crate::session::graph_tracker::GraphTracker::new(session_dir)
|
||||
};
|
||||
Arc::new(parking_lot::Mutex::new(tracker))
|
||||
};
|
||||
let current_prompt_mode = Arc::new(parking_lot::Mutex::new(PromptMode::Agent));
|
||||
let turn_prompt_mode = Arc::new(parking_lot::Mutex::new(PromptMode::Agent));
|
||||
let task_output_tool_name = Arc::new(std::sync::OnceLock::new());
|
||||
@@ -1086,6 +1099,14 @@ pub(crate) async fn spawn_session_actor(
|
||||
goal_harness_enabled: std::sync::atomic::AtomicBool::new(false),
|
||||
goal_harness_availability_reconciled: std::sync::atomic::AtomicBool::new(false),
|
||||
goal_tracker,
|
||||
graph_enabled,
|
||||
graph_tracker,
|
||||
graph_concurrency: effective_config.resolve_graph_concurrency(),
|
||||
graph_node_rounds: effective_config.resolve_graph_node_rounds(),
|
||||
graph_replan_cap: effective_config.resolve_graph_replan_cap(),
|
||||
graph_optimizer_enabled: effective_config.resolve_graph_optimizer_enabled(),
|
||||
graph_project_dir,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
@@ -1229,6 +1250,18 @@ pub(crate) async fn spawn_session_actor(
|
||||
),
|
||||
)
|
||||
.await;
|
||||
// A restored graph was demoted (Active→UserPaused, Running→Ready) IN
|
||||
// MEMORY after the updates-log replay, whose last GraphUpdated still
|
||||
// shows the pre-shutdown Active state. Re-emit truth once so a
|
||||
// reattached pager never renders a stale self-driving chip — and
|
||||
// best-effort reclaim project writership so the shared file gets the
|
||||
// demoted truth too (Busy = another instance owns it; skip quietly).
|
||||
if session.graph_tracker.lock().snapshot().is_some() {
|
||||
if let Some(msg) = session.claim_project_graph_for_resume() {
|
||||
tracing::info!(%msg, "graph restore: project writership not reclaimed");
|
||||
}
|
||||
session.persist_graph_state();
|
||||
}
|
||||
if let Some(ref display_cwd) = prompt_display_cwd {
|
||||
session
|
||||
.agent
|
||||
@@ -1511,6 +1544,7 @@ pub(crate) async fn spawn_session_on_thread(
|
||||
persisted_signals: Option<crate::session::signals::SessionSignals>,
|
||||
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
||||
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
persisted_graph_mode: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||
memory_config: Option<crate::config::MemoryConfig>,
|
||||
feedback_flags: crate::session::feedback_manager::FeedbackFlags,
|
||||
@@ -1525,6 +1559,7 @@ pub(crate) async fn spawn_session_on_thread(
|
||||
app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig,
|
||||
write_file_enabled: bool,
|
||||
goal_enabled: bool,
|
||||
graph_enabled: bool,
|
||||
subagents_enabled: bool,
|
||||
ask_user_question_enabled: bool,
|
||||
client_hooks: crate::extensions::hooks::ClientHooks,
|
||||
@@ -1653,6 +1688,7 @@ pub(crate) async fn spawn_session_on_thread(
|
||||
persisted_signals,
|
||||
persisted_plan_mode,
|
||||
persisted_goal_mode,
|
||||
persisted_graph_mode,
|
||||
persisted_announcement_state,
|
||||
memory_config,
|
||||
feedback_flags,
|
||||
@@ -1667,6 +1703,7 @@ pub(crate) async fn spawn_session_on_thread(
|
||||
app_builder_deployer_config,
|
||||
write_file_enabled,
|
||||
goal_enabled,
|
||||
graph_enabled,
|
||||
subagents_enabled,
|
||||
ask_user_question_enabled,
|
||||
client_hooks,
|
||||
|
||||
@@ -478,6 +478,14 @@ impl SessionActor {
|
||||
if let Some(running_task) = running_task {
|
||||
running_task.abort();
|
||||
}
|
||||
// Re-sweep subagents AFTER the abort: the first sweep raced the
|
||||
// still-live turn future, which may have spawned NEW harness
|
||||
// children (graph batch workers/verifiers) between the sweep and
|
||||
// the abort. Coordinator-channel FIFO guarantees this second
|
||||
// Cancel lands after any Spawn the turn issued before it died.
|
||||
if cancel_subagents && let Some(prompt_id) = cancelled_prompt_id.as_deref() {
|
||||
self.cancel_subagents_for_prompt_id(prompt_id);
|
||||
}
|
||||
// The aborted turn's `BlockingWaitGuard`s drop asynchronously (they
|
||||
// live in tool futures owned by the drainer task / subagent spawn
|
||||
// task). Until they do, `queue_input` would read a stale depth > 0 and
|
||||
|
||||
@@ -305,19 +305,63 @@ impl SessionActor {
|
||||
objective,
|
||||
token_budget,
|
||||
} => {
|
||||
// The graph owns the goal engine while set: a
|
||||
// manual /goal would corrupt the running node.
|
||||
if self.graph_owns_goal_engine() {
|
||||
self.send_slash_command_output(
|
||||
"A graph owns the goal engine. Use /graph status, or /graph \
|
||||
clear before /goal.",
|
||||
)
|
||||
.await;
|
||||
return ok_end_turn(0, None);
|
||||
}
|
||||
let reminder = self.setup_goal(&objective, token_budget).await;
|
||||
vec![text_block(reminder), text_block(objective)]
|
||||
}
|
||||
BuiltinAction::GoalResume => match self.resume_goal().await {
|
||||
GoalResumeOutcome::Inference { reminder, user_msg } => {
|
||||
self.send_slash_command_output(&user_msg).await;
|
||||
vec![text_block(reminder)]
|
||||
BuiltinAction::GoalResume => {
|
||||
if self.graph_owns_goal_engine() {
|
||||
self.send_slash_command_output(
|
||||
"A graph owns the goal engine. Use /graph resume instead.",
|
||||
)
|
||||
.await;
|
||||
return ok_end_turn(0, None);
|
||||
}
|
||||
GoalResumeOutcome::Message(msg) => {
|
||||
match self.resume_goal().await {
|
||||
GoalResumeOutcome::Inference { reminder, user_msg } => {
|
||||
self.send_slash_command_output(&user_msg).await;
|
||||
vec![text_block(reminder)]
|
||||
}
|
||||
GoalResumeOutcome::Message(msg) => {
|
||||
self.send_slash_command_output(&msg).await;
|
||||
return ok_end_turn(0, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
BuiltinAction::GraphSet {
|
||||
objective,
|
||||
token_budget,
|
||||
} => match self.setup_graph(&objective, token_budget).await {
|
||||
super::graph::GraphSetupOutcome::Inference { reminder, user_msg } => {
|
||||
self.send_slash_command_output(&user_msg).await;
|
||||
vec![text_block(reminder), text_block(objective)]
|
||||
}
|
||||
super::graph::GraphSetupOutcome::Message(msg) => {
|
||||
self.send_slash_command_output(&msg).await;
|
||||
return ok_end_turn(0, None);
|
||||
}
|
||||
},
|
||||
BuiltinAction::GraphResume { extra_budget } => {
|
||||
match self.resume_graph(extra_budget).await {
|
||||
super::graph::GraphSetupOutcome::Inference { reminder, user_msg } => {
|
||||
self.send_slash_command_output(&user_msg).await;
|
||||
vec![text_block(reminder)]
|
||||
}
|
||||
super::graph::GraphSetupOutcome::Message(msg) => {
|
||||
self.send_slash_command_output(&msg).await;
|
||||
return ok_end_turn(0, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return self.execute_builtin_slash_command(action).await,
|
||||
}
|
||||
}
|
||||
@@ -688,13 +732,35 @@ impl SessionActor {
|
||||
self.goal_tracker.lock().status(),
|
||||
);
|
||||
if !goal_active {
|
||||
break round;
|
||||
// The node goal may have resolved MID-round without a
|
||||
// graph cascade (e.g. a classifier-disabled completion
|
||||
// applied by the mid-turn drainer). Consult the graph
|
||||
// seam before ending the turn so the graph advances or
|
||||
// settles loudly instead of stranding Active forever.
|
||||
match self.run_graph_round_end().await {
|
||||
Some(node_reminder) => {
|
||||
self.inject_goal_continuation_message(node_reminder).await;
|
||||
continue;
|
||||
}
|
||||
None => break round,
|
||||
}
|
||||
}
|
||||
match self.run_goal_round_end().await {
|
||||
GoalRoundDecision::Continue(directive) => {
|
||||
self.inject_goal_continuation_message(directive).await;
|
||||
}
|
||||
GoalRoundDecision::EndTurn => break round,
|
||||
GoalRoundDecision::EndTurn => {
|
||||
// Graph seam: when the node goal resolved, the
|
||||
// graph may advance to the next node inside the
|
||||
// SAME turn (multi-loop closed loop). None ends
|
||||
// the turn for real (graph done/paused/absent).
|
||||
match self.run_graph_round_end().await {
|
||||
Some(node_reminder) => {
|
||||
self.inject_goal_continuation_message(node_reminder).await;
|
||||
}
|
||||
None => break round,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -212,6 +212,16 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
@@ -648,6 +658,16 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
@@ -893,6 +913,16 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
@@ -1871,6 +1901,16 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -241,6 +241,16 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
|
||||
+30
@@ -173,6 +173,16 @@ async fn create_test_actor(
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
@@ -611,6 +621,16 @@ async fn create_test_actor_with_memory(
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
@@ -1360,6 +1380,16 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
|
||||
@@ -235,6 +235,16 @@ async fn create_test_actor_with_memory(
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
|
||||
+10
@@ -181,6 +181,16 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
|
||||
@@ -286,6 +286,16 @@ pub(crate) async fn create_test_actor_ex(
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
|
||||
@@ -2268,6 +2268,16 @@ mod inline_auto_compact_flow_tests {
|
||||
"/tmp/test-session",
|
||||
)),
|
||||
)),
|
||||
graph_enabled: false,
|
||||
graph_tracker: Arc::new(parking_lot::Mutex::new(
|
||||
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
|
||||
)),
|
||||
graph_concurrency: 1,
|
||||
graph_node_rounds: 3,
|
||||
graph_replan_cap: 3,
|
||||
graph_optimizer_enabled: false,
|
||||
graph_project_dir: None,
|
||||
graph_project_lock: std::cell::RefCell::new(None),
|
||||
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
|
||||
|
||||
@@ -154,7 +154,9 @@ pub enum GoalPauseReason {
|
||||
}
|
||||
|
||||
impl GoalPauseReason {
|
||||
fn to_status(self) -> GoalStatus {
|
||||
/// Also used by the graph tracker (`graph_tracker.rs`), which reuses
|
||||
/// the goal status vocabulary for graph-level pauses.
|
||||
pub(crate) fn to_status(self) -> GoalStatus {
|
||||
match self {
|
||||
Self::User => GoalStatus::UserPaused,
|
||||
Self::BackOff => GoalStatus::BackOffPaused,
|
||||
@@ -166,7 +168,8 @@ impl GoalPauseReason {
|
||||
|
||||
/// Short, stable label stashed in the `GoalPaused` history entry's
|
||||
/// `detail` so the pager's Recent History distinguishes pause causes.
|
||||
fn history_detail(self) -> &'static str {
|
||||
/// Shared with the graph tracker's `GraphPaused` entries.
|
||||
pub(crate) fn history_detail(self) -> &'static str {
|
||||
match self {
|
||||
Self::User => "user",
|
||||
Self::BackOff => "back_off",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,571 @@
|
||||
//! Graph planner runner: one attempt at decomposing an objective into a
|
||||
//! validated node DAG.
|
||||
//!
|
||||
//! Deliberately thin: the spawn plumbing (harness-internal subagent,
|
||||
//! verbatim-fork, fail-open model retry) is reused from
|
||||
//! [`goal_planner`](super::goal_planner) via the same
|
||||
//! [`GoalPlannerSpawner`] contract; this module only swaps the template
|
||||
//! and replaces "plan file exists" with "graph JSON parses and passes
|
||||
//! the static DAG gate" ([`graph_plan::parse_and_validate`]).
|
||||
//!
|
||||
//! Outcome split (both are loud, nothing is papered over):
|
||||
//! - [`GraphPlannerOutcome::Invalid`] — the planner wrote an artifact
|
||||
//! that failed validation. Retryable ONCE by the caller, feeding the
|
||||
//! precise validation error back as CONTEXT.
|
||||
//! - [`GraphPlannerOutcome::FailClosed`] — spawn/transport/missing-file
|
||||
//! failure. The caller pauses the graph; `/graph resume` retries.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::goal_planner::{
|
||||
GoalPlannerSpawner, RoleRenderedPrompt, SpawnError, parse_terminal_response,
|
||||
};
|
||||
use super::goal_role_tools::RoleToolNames;
|
||||
use super::graph_plan::{self, MAX_GRAPH_JSON_BYTES};
|
||||
use super::graph_tracker::GraphNode;
|
||||
|
||||
const GRAPH_PLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_planner_prompt.md");
|
||||
const GRAPH_REPLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_replanner_prompt.md");
|
||||
pub(crate) const GRAPH_PLANNER_SUBAGENT_DESCRIPTION: &str = "graph plan writer";
|
||||
pub(crate) const GRAPH_REPLANNER_SUBAGENT_DESCRIPTION: &str = "graph replanner";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum GraphPlannerOutcome {
|
||||
/// Validated, canonicalized nodes (topo-ordered, final node appended).
|
||||
Planned(Vec<GraphNode>),
|
||||
/// Artifact written but rejected by the static gate; retry once with
|
||||
/// the reason as feedback.
|
||||
Invalid { reason: String },
|
||||
/// Infrastructure/spawn failure or missing artifact; pause the graph.
|
||||
FailClosed { reason: String },
|
||||
}
|
||||
|
||||
pub(crate) struct GraphPlannerInputs<'a> {
|
||||
pub objective: &'a str,
|
||||
/// Empty on the first attempt; the previous attempt's validation
|
||||
/// error on the retry.
|
||||
pub feedback: &'a str,
|
||||
pub graph_file: &'a Path,
|
||||
pub tool_names: &'a RoleToolNames,
|
||||
pub inherit_tool_names: &'a RoleToolNames,
|
||||
}
|
||||
|
||||
/// Run one graph-planner attempt end to end: render, spawn, read the
|
||||
/// artifact (size-capped), validate, canonicalize.
|
||||
pub(crate) async fn run_graph_planner(
|
||||
spawner: Arc<dyn GoalPlannerSpawner>,
|
||||
inputs: GraphPlannerInputs<'_>,
|
||||
) -> GraphPlannerOutcome {
|
||||
if let Some(parent) = inputs.graph_file.parent()
|
||||
&& let Err(err) = tokio::fs::create_dir_all(parent).await
|
||||
{
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: format!("failed to create graph dir {}: {err}", parent.display()),
|
||||
};
|
||||
}
|
||||
|
||||
// A stale artifact from a prior pass would satisfy the
|
||||
// missing-file guard below and be trusted as this pass's output.
|
||||
// Delete first; only NotFound is benign.
|
||||
if let Err(err) = tokio::fs::remove_file(inputs.graph_file).await
|
||||
&& err.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: format!("failed to clear stale graph artifact: {err}"),
|
||||
};
|
||||
}
|
||||
let graph_file_str = inputs.graph_file.to_string_lossy();
|
||||
let with_graph_file = GRAPH_PLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str);
|
||||
let render = |tool_names: &RoleToolNames| -> String {
|
||||
let rendered = tool_names.apply(&with_graph_file);
|
||||
let mut full = String::with_capacity(rendered.len() + inputs.objective.len() + 256);
|
||||
full.push_str(&rendered);
|
||||
full.push_str("\n\nOBJECTIVE:\n");
|
||||
full.push_str(inputs.objective);
|
||||
full.push_str("\n\nCONTEXT:\n");
|
||||
full.push_str(inputs.feedback);
|
||||
full.push('\n');
|
||||
full
|
||||
};
|
||||
let prompt = RoleRenderedPrompt {
|
||||
primary: render(inputs.tool_names),
|
||||
fallback: render(inputs.inherit_tool_names),
|
||||
};
|
||||
|
||||
let spawn_id = uuid::Uuid::now_v7().to_string();
|
||||
let response = match spawner.spawn_planner(&spawn_id, prompt).await {
|
||||
Ok(text) => text,
|
||||
Err(SpawnError::Transport(detail)) => {
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: format!("graph planner transport error: {detail}"),
|
||||
};
|
||||
}
|
||||
Err(SpawnError::Runtime { message, cancelled }) => {
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: if cancelled {
|
||||
format!("graph planner aborted: {message}")
|
||||
} else {
|
||||
format!("graph planner runtime error: {message}")
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match tokio::fs::metadata(inputs.graph_file).await {
|
||||
Ok(meta) if meta.is_file() && meta.len() > 0 => {
|
||||
if meta.len() > MAX_GRAPH_JSON_BYTES {
|
||||
return GraphPlannerOutcome::Invalid {
|
||||
reason: format!(
|
||||
"graph JSON is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}",
|
||||
meta.len()
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
graph_file = %graph_file_str,
|
||||
terminal_token_ok = parse_terminal_response(&response),
|
||||
response_snippet = %response.chars().take(120).collect::<String>(),
|
||||
"graph planner: graph file missing or empty; failing closed",
|
||||
);
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: "graph planner produced no graph file".to_owned(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let json = match tokio::fs::read_to_string(inputs.graph_file).await {
|
||||
Ok(json) => json,
|
||||
Err(err) => {
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: format!("failed to read graph file: {err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match graph_plan::parse_and_validate(&json, inputs.objective) {
|
||||
Ok(nodes) => GraphPlannerOutcome::Planned(nodes),
|
||||
Err(err) => GraphPlannerOutcome::Invalid {
|
||||
reason: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct GraphReplannerInputs<'a> {
|
||||
pub objective: &'a str,
|
||||
/// Compact JSON of the existing nodes (id/title/status/deps).
|
||||
pub current_graph: &'a str,
|
||||
/// The queued discoveries, one per line with their origin node ids.
|
||||
pub discoveries: &'a str,
|
||||
/// On retry, the previous artifact's validation error.
|
||||
pub feedback: &'a str,
|
||||
pub graph_file: &'a Path,
|
||||
pub tool_names: &'a RoleToolNames,
|
||||
pub inherit_tool_names: &'a RoleToolNames,
|
||||
}
|
||||
|
||||
/// Run one REPLAN attempt: render, spawn, read the artifact, validate
|
||||
/// against the existing graph (append-only). An empty `{"nodes": []}`
|
||||
/// appendix is the sanctioned "everything already covered" escape hatch
|
||||
/// and returns `Planned(vec![])`.
|
||||
pub(crate) async fn run_graph_replanner(
|
||||
spawner: Arc<dyn GoalPlannerSpawner>,
|
||||
existing: &[GraphNode],
|
||||
inputs: GraphReplannerInputs<'_>,
|
||||
) -> GraphPlannerOutcome {
|
||||
if let Some(parent) = inputs.graph_file.parent()
|
||||
&& let Err(err) = tokio::fs::create_dir_all(parent).await
|
||||
{
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: format!("failed to create graph dir {}: {err}", parent.display()),
|
||||
};
|
||||
}
|
||||
// A stale artifact from a prior pass would satisfy the
|
||||
// missing-file guard below and be trusted as this pass's output.
|
||||
// Delete first; only NotFound is benign.
|
||||
if let Err(err) = tokio::fs::remove_file(inputs.graph_file).await
|
||||
&& err.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: format!("failed to clear stale graph artifact: {err}"),
|
||||
};
|
||||
}
|
||||
let graph_file_str = inputs.graph_file.to_string_lossy();
|
||||
let with_graph_file = GRAPH_REPLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str);
|
||||
let render = |tool_names: &RoleToolNames| -> String {
|
||||
let rendered = tool_names.apply(&with_graph_file);
|
||||
format!(
|
||||
"{rendered}\n\nOBJECTIVE:\n{}\n\nCURRENT GRAPH:\n{}\n\nDISCOVERIES:\n{}\n\nCONTEXT:\n{}\n",
|
||||
inputs.objective, inputs.current_graph, inputs.discoveries, inputs.feedback
|
||||
)
|
||||
};
|
||||
let prompt = RoleRenderedPrompt {
|
||||
primary: render(inputs.tool_names),
|
||||
fallback: render(inputs.inherit_tool_names),
|
||||
};
|
||||
let spawn_id = uuid::Uuid::now_v7().to_string();
|
||||
let response = match spawner.spawn_planner(&spawn_id, prompt).await {
|
||||
Ok(text) => text,
|
||||
Err(SpawnError::Transport(detail)) => {
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: format!("graph replanner transport error: {detail}"),
|
||||
};
|
||||
}
|
||||
Err(SpawnError::Runtime { message, cancelled }) => {
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: if cancelled {
|
||||
format!("graph replanner aborted: {message}")
|
||||
} else {
|
||||
format!("graph replanner runtime error: {message}")
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
match tokio::fs::metadata(inputs.graph_file).await {
|
||||
Ok(meta) if meta.is_file() && meta.len() > 0 => {
|
||||
if meta.len() > MAX_GRAPH_JSON_BYTES {
|
||||
return GraphPlannerOutcome::Invalid {
|
||||
reason: format!(
|
||||
"replan JSON is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}",
|
||||
meta.len()
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
graph_file = %graph_file_str,
|
||||
terminal_token_ok = parse_terminal_response(&response),
|
||||
"graph replanner: artifact missing or empty; failing closed",
|
||||
);
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: "graph replanner produced no artifact".to_owned(),
|
||||
};
|
||||
}
|
||||
}
|
||||
let json = match tokio::fs::read_to_string(inputs.graph_file).await {
|
||||
Ok(json) => json,
|
||||
Err(err) => {
|
||||
return GraphPlannerOutcome::FailClosed {
|
||||
reason: format!("failed to read replan artifact: {err}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
// Escape hatch: an explicitly empty appendix means "already covered".
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json)
|
||||
&& v.get("nodes")
|
||||
.and_then(|n| n.as_array())
|
||||
.is_some_and(Vec::is_empty)
|
||||
{
|
||||
return GraphPlannerOutcome::Planned(Vec::new());
|
||||
}
|
||||
match graph_plan::validate_replan(existing, &json) {
|
||||
Ok(nodes) => GraphPlannerOutcome::Planned(nodes),
|
||||
Err(err) => GraphPlannerOutcome::Invalid {
|
||||
reason: err.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic single-shot artifact pass: render `template` + `sections`,
|
||||
/// spawn the role child, enforce the stale-artifact/size/missing-file
|
||||
/// discipline, and return the artifact's raw JSON for the caller to
|
||||
/// validate. Shared by the optimizer (and any future boundary pass).
|
||||
pub(crate) struct ArtifactPassSpec<'a> {
|
||||
pub template: &'a str,
|
||||
pub sections: &'a str,
|
||||
pub graph_file: &'a Path,
|
||||
pub tool_names: &'a RoleToolNames,
|
||||
pub role: &'a str,
|
||||
}
|
||||
|
||||
pub(crate) async fn run_graph_artifact_pass(
|
||||
spawner: Arc<dyn GoalPlannerSpawner>,
|
||||
spec: ArtifactPassSpec<'_>,
|
||||
) -> Result<String, String> {
|
||||
if let Some(parent) = spec.graph_file.parent()
|
||||
&& let Err(err) = tokio::fs::create_dir_all(parent).await
|
||||
{
|
||||
return Err(format!("failed to create graph dir: {err}"));
|
||||
}
|
||||
if let Err(err) = tokio::fs::remove_file(spec.graph_file).await
|
||||
&& err.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
return Err(format!("failed to clear stale artifact: {err}"));
|
||||
}
|
||||
let graph_file_str = spec.graph_file.to_string_lossy();
|
||||
let rendered = spec
|
||||
.tool_names
|
||||
.apply(&spec.template.replace("{GRAPH_FILE}", &graph_file_str));
|
||||
let prompt_text = format!("{rendered}\n\n{}", spec.sections);
|
||||
let prompt = RoleRenderedPrompt {
|
||||
primary: prompt_text.clone(),
|
||||
fallback: prompt_text,
|
||||
};
|
||||
let spawn_id = uuid::Uuid::now_v7().to_string();
|
||||
match spawner.spawn_planner(&spawn_id, prompt).await {
|
||||
Ok(_) => {}
|
||||
Err(SpawnError::Transport(detail)) => {
|
||||
return Err(format!("{} transport error: {detail}", spec.role));
|
||||
}
|
||||
Err(SpawnError::Runtime { message, cancelled }) => {
|
||||
return Err(if cancelled {
|
||||
format!("{} aborted: {message}", spec.role)
|
||||
} else {
|
||||
format!("{} runtime error: {message}", spec.role)
|
||||
});
|
||||
}
|
||||
}
|
||||
match tokio::fs::metadata(spec.graph_file).await {
|
||||
Ok(meta) if meta.is_file() && meta.len() > 0 && meta.len() <= MAX_GRAPH_JSON_BYTES => {}
|
||||
Ok(meta) if meta.len() > MAX_GRAPH_JSON_BYTES => {
|
||||
return Err(format!(
|
||||
"{} artifact is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}",
|
||||
spec.role,
|
||||
meta.len()
|
||||
));
|
||||
}
|
||||
_ => return Err(format!("{} produced no artifact", spec.role)),
|
||||
}
|
||||
tokio::fs::read_to_string(spec.graph_file)
|
||||
.await
|
||||
.map_err(|err| format!("failed to read {} artifact: {err}", spec.role))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::goal_role_tools::tests::summary_with;
|
||||
use kigi_tools::types::tool::ToolKind;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
enum MockReply {
|
||||
Done,
|
||||
Transport,
|
||||
Runtime { cancelled: bool },
|
||||
}
|
||||
|
||||
struct MockSpawner {
|
||||
response: MockReply,
|
||||
body: Option<Vec<u8>>,
|
||||
target: PathBuf,
|
||||
last_prompt: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl GoalPlannerSpawner for MockSpawner {
|
||||
async fn spawn_planner(
|
||||
&self,
|
||||
_id: &str,
|
||||
prompt: RoleRenderedPrompt,
|
||||
) -> Result<String, SpawnError> {
|
||||
*self.last_prompt.lock().unwrap() = Some(prompt.primary.clone());
|
||||
if let Some(body) = &self.body {
|
||||
std::fs::write(&self.target, body).unwrap();
|
||||
}
|
||||
match &self.response {
|
||||
MockReply::Done => Ok("Done".to_owned()),
|
||||
MockReply::Transport => Err(SpawnError::Transport("channel closed".into())),
|
||||
MockReply::Runtime { cancelled } => Err(SpawnError::Runtime {
|
||||
message: "boom".into(),
|
||||
cancelled: *cancelled,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_names() -> RoleToolNames {
|
||||
RoleToolNames::from_summary(&summary_with(&[
|
||||
(ToolKind::Read, "read_file"),
|
||||
(ToolKind::Search, "grep"),
|
||||
(ToolKind::List, "list_files"),
|
||||
(ToolKind::Write, "write"),
|
||||
]))
|
||||
}
|
||||
|
||||
/// Self-cleaning temp home per test: never leak dirs into the OS
|
||||
/// temp root (storage discipline — see AGENTS.md gates).
|
||||
fn tmp_graph_file(_name: &str) -> (tempfile::TempDir, PathBuf) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("graph.json");
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
async fn run(spawner: MockSpawner, graph_file: &Path) -> GraphPlannerOutcome {
|
||||
let names = tool_names();
|
||||
run_graph_planner(
|
||||
Arc::new(spawner),
|
||||
GraphPlannerInputs {
|
||||
objective: "build the thing",
|
||||
feedback: "",
|
||||
graph_file,
|
||||
tool_names: &names,
|
||||
inherit_tool_names: &names,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_artifact_yields_canonical_nodes() {
|
||||
let (_tmp, target) = tmp_graph_file("valid");
|
||||
let body = serde_json::json!({
|
||||
"nodes": [
|
||||
{"id": "core", "title": "Core", "spec": "core spec", "deps": []},
|
||||
{"id": "ui", "title": "UI", "spec": "ui spec", "deps": ["core"]},
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Done,
|
||||
body: Some(body.into_bytes()),
|
||||
target: target.clone(),
|
||||
last_prompt: Mutex::new(None),
|
||||
};
|
||||
match run(spawner, &target).await {
|
||||
GraphPlannerOutcome::Planned(nodes) => {
|
||||
assert_eq!(nodes.len(), 3, "2 planner nodes + appended final");
|
||||
assert_eq!(nodes[2].id, crate::session::graph_tracker::FINAL_NODE_ID);
|
||||
}
|
||||
other => panic!("expected Planned, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_embeds_objective_feedback_and_tool_names() {
|
||||
let (_tmp, target) = tmp_graph_file("prompt");
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Done,
|
||||
body: None,
|
||||
target: target.clone(),
|
||||
last_prompt: Mutex::new(None),
|
||||
};
|
||||
let prompt_cell = std::sync::Arc::new(spawner);
|
||||
let names = tool_names();
|
||||
let _ = run_graph_planner(
|
||||
prompt_cell.clone(),
|
||||
GraphPlannerInputs {
|
||||
objective: "OBJ-MARKER",
|
||||
feedback: "FEEDBACK-MARKER",
|
||||
graph_file: &target,
|
||||
tool_names: &names,
|
||||
inherit_tool_names: &names,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let prompt = prompt_cell.last_prompt.lock().unwrap().clone().unwrap();
|
||||
assert!(prompt.contains("OBJ-MARKER"));
|
||||
assert!(prompt.contains("FEEDBACK-MARKER"));
|
||||
assert!(prompt.contains(&target.to_string_lossy().into_owned()));
|
||||
assert!(prompt.contains("read_file"), "placeholders rendered");
|
||||
assert!(!prompt.contains("{READ_TOOL}"), "no leftover placeholder");
|
||||
assert!(!prompt.contains("{GRAPH_FILE}"), "no leftover placeholder");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_artifact_is_retryable_with_reason() {
|
||||
let (_tmp, target) = tmp_graph_file("invalid");
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Done,
|
||||
body: Some(br#"{"nodes":[{"id":"a","title":"A","spec":"s","deps":["a"]}]}"#.to_vec()),
|
||||
target: target.clone(),
|
||||
last_prompt: Mutex::new(None),
|
||||
};
|
||||
match run(spawner, &target).await {
|
||||
GraphPlannerOutcome::Invalid { reason } => {
|
||||
assert!(reason.contains("depends on itself"), "{reason}");
|
||||
}
|
||||
other => panic!("expected Invalid, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_artifact_fails_closed() {
|
||||
let (_tmp, target) = tmp_graph_file("missing");
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Done,
|
||||
body: None,
|
||||
target: target.clone(),
|
||||
last_prompt: Mutex::new(None),
|
||||
};
|
||||
match run(spawner, &target).await {
|
||||
GraphPlannerOutcome::FailClosed { reason } => {
|
||||
assert!(reason.contains("no graph file"), "{reason}");
|
||||
}
|
||||
other => panic!("expected FailClosed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_error_fails_closed() {
|
||||
let (_tmp, target) = tmp_graph_file("runtime");
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Runtime { cancelled: false },
|
||||
body: None,
|
||||
target: target.clone(),
|
||||
last_prompt: Mutex::new(None),
|
||||
};
|
||||
match run(spawner, &target).await {
|
||||
GraphPlannerOutcome::FailClosed { reason } => {
|
||||
assert!(reason.contains("runtime error"), "{reason}");
|
||||
}
|
||||
other => panic!("expected FailClosed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversize_artifact_is_invalid_with_cap_in_reason() {
|
||||
let (_tmp, target) = tmp_graph_file("oversize");
|
||||
let mut body = vec![b'x'; (MAX_GRAPH_JSON_BYTES as usize) + 1];
|
||||
body[0] = b'{'; // content is irrelevant; the size gate fires first
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Done,
|
||||
body: Some(body),
|
||||
target: target.clone(),
|
||||
last_prompt: Mutex::new(None),
|
||||
};
|
||||
match run(spawner, &target).await {
|
||||
GraphPlannerOutcome::Invalid { reason } => {
|
||||
assert!(reason.contains("the cap is"), "{reason}");
|
||||
}
|
||||
other => panic!("expected Invalid, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_error_fails_closed() {
|
||||
let (_tmp, target) = tmp_graph_file("transport");
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Transport,
|
||||
body: None,
|
||||
target: target.clone(),
|
||||
last_prompt: Mutex::new(None),
|
||||
};
|
||||
match run(spawner, &target).await {
|
||||
GraphPlannerOutcome::FailClosed { reason } => {
|
||||
assert!(reason.contains("transport error"), "{reason}");
|
||||
}
|
||||
other => panic!("expected FailClosed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_runtime_error_reports_aborted() {
|
||||
let (_tmp, target) = tmp_graph_file("aborted");
|
||||
let spawner = MockSpawner {
|
||||
response: MockReply::Runtime { cancelled: true },
|
||||
body: None,
|
||||
target: target.clone(),
|
||||
last_prompt: Mutex::new(None),
|
||||
};
|
||||
match run(spawner, &target).await {
|
||||
GraphPlannerOutcome::FailClosed { reason } => {
|
||||
assert!(reason.contains("aborted"), "{reason}");
|
||||
}
|
||||
other => panic!("expected FailClosed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Project-level shared graph file (G4): `.kigi/graph.jsonl` at the git
|
||||
//! root, so a graph follows the REPOSITORY, not the session.
|
||||
//!
|
||||
//! The session tracker remains the single source of truth; this file is
|
||||
//! a PROJECTION refreshed at every checkpoint. Format (beads-style,
|
||||
//! line-mergeable thanks to content-hash node ids):
|
||||
//!
|
||||
//! - line 1: the orchestration header (everything except `nodes`)
|
||||
//! - lines 2..: one `GraphNode` per line
|
||||
//!
|
||||
//! Concurrency: an advisory `flock` on a sidecar `.lock` file makes the
|
||||
//! session that CREATED or RESUMED the graph the single writer; other
|
||||
//! kigi instances get a read-only view (`/graph status`) with an
|
||||
//! explicit notice. The lock is held for the graph's lifetime in that
|
||||
//! session and released on `/graph clear` (or process exit).
|
||||
//!
|
||||
//! Git discipline: kigi only WRITES the file — committing it is the
|
||||
//! user's decision, never automated.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fs2::FileExt;
|
||||
|
||||
use super::graph_tracker::{GraphNode, GraphOrchestration};
|
||||
|
||||
/// Header line: the orchestration minus its nodes (which follow one per
|
||||
/// line). The shadow `nodes` field is skipped on write and REJECTED on
|
||||
/// read when non-empty — nodes embedded in the header would silently
|
||||
/// duplicate the per-line entries.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct ProjectGraphHeader {
|
||||
#[serde(flatten)]
|
||||
orchestration: GraphOrchestration,
|
||||
}
|
||||
|
||||
fn header_has_inline_nodes(header: &ProjectGraphHeader) -> bool {
|
||||
!header.orchestration.nodes.is_empty()
|
||||
}
|
||||
|
||||
/// Held exclusive advisory lock on the project graph. Dropping releases.
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectGraphLock {
|
||||
_file: std::fs::File,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LockOutcome {
|
||||
Acquired(ProjectGraphLock),
|
||||
/// Another kigi instance holds the lock.
|
||||
Busy,
|
||||
}
|
||||
|
||||
/// `.kigi` dir under the git root of `cwd`; `None` outside a git repo
|
||||
/// (the project-graph feature is git-scoped by design).
|
||||
pub fn project_graph_dir(cwd: &Path) -> Option<PathBuf> {
|
||||
kigi_workspace::session::git::find_git_root_from_path(cwd)
|
||||
.ok()
|
||||
.map(|root| root.join(".kigi"))
|
||||
}
|
||||
|
||||
pub fn graph_file_path(dir: &Path) -> PathBuf {
|
||||
dir.join("graph.jsonl")
|
||||
}
|
||||
|
||||
fn lock_file_path(dir: &Path) -> PathBuf {
|
||||
dir.join("graph.jsonl.lock")
|
||||
}
|
||||
|
||||
/// Try to become the project graph's single writer. Fail-fast: any I/O
|
||||
/// error other than "already locked" propagates.
|
||||
pub fn try_acquire_writer(dir: &Path) -> std::io::Result<LockOutcome> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(lock_file_path(dir))?;
|
||||
match file.try_lock_exclusive() {
|
||||
Ok(()) => Ok(LockOutcome::Acquired(ProjectGraphLock { _file: file })),
|
||||
// fs2 maps contention differently per platform (EWOULDBLOCK on
|
||||
// unix, ERROR_LOCK_VIOLATION on Windows); its
|
||||
// `lock_contended_error()` is the portable classifier.
|
||||
Err(err)
|
||||
if err.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| err.raw_os_error() == fs2::lock_contended_error().raw_os_error() =>
|
||||
{
|
||||
Ok(LockOutcome::Busy)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically project the orchestration to `.kigi/graph.jsonl`
|
||||
/// (tmp + rename, same discipline as the session state file).
|
||||
pub fn project(dir: &Path, state: &GraphOrchestration) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut header_state = state.clone();
|
||||
let nodes = std::mem::take(&mut header_state.nodes);
|
||||
let mut header_value = serde_json::to_value(&header_state)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
if let Some(obj) = header_value.as_object_mut() {
|
||||
// The contract says "minus nodes"; drop the empty vec the
|
||||
// struct serializer would otherwise emit.
|
||||
obj.remove("nodes");
|
||||
}
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
serde_json::to_writer(&mut buf, &header_value)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
buf.push(b'\n');
|
||||
for node in &nodes {
|
||||
serde_json::to_writer(&mut buf, node)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
buf.push(b'\n');
|
||||
}
|
||||
let target = graph_file_path(dir);
|
||||
let tmp = target.with_extension("jsonl.tmp");
|
||||
{
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(&buf)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, &target)
|
||||
}
|
||||
|
||||
/// Load the projected graph, `Ok(None)` when absent. Malformed content
|
||||
/// is an ERROR (never silently treated as "no graph") — the file is
|
||||
/// user-visible, git-merged state; corruption must surface.
|
||||
pub fn load(dir: &Path) -> std::io::Result<Option<GraphOrchestration>> {
|
||||
let path = graph_file_path(dir);
|
||||
let raw = match std::fs::read_to_string(&path) {
|
||||
Ok(raw) => raw,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let mut lines = raw.lines().filter(|l| !l.trim().is_empty());
|
||||
let Some(header_line) = lines.next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let header: ProjectGraphHeader = serde_json::from_str(header_line).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("{} line 1: {e}", path.display()),
|
||||
)
|
||||
})?;
|
||||
if header_has_inline_nodes(&header) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"{} line 1 embeds nodes inline; nodes belong one per line",
|
||||
path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut state = header.orchestration;
|
||||
for (idx, line) in lines.enumerate() {
|
||||
let node: GraphNode = serde_json::from_str(line).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("{} node line {}: {e}", path.display(), idx + 2),
|
||||
)
|
||||
})?;
|
||||
state.nodes.push(node);
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
/// Remove the projection (on `/graph clear`). Missing file is fine.
|
||||
pub fn remove(dir: &Path) -> std::io::Result<()> {
|
||||
match std::fs::remove_file(graph_file_path(dir)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::goal_tracker::{GoalPhase, GoalStatus};
|
||||
use crate::session::graph_tracker::{DepKind, NodeDep, NodeStatus};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn sample_state() -> GraphOrchestration {
|
||||
GraphOrchestration {
|
||||
graph_id: "g-1".into(),
|
||||
objective: "ship it".into(),
|
||||
status: GoalStatus::Active,
|
||||
phase: GoalPhase::Executing,
|
||||
plan_version: 2,
|
||||
nodes: vec![
|
||||
GraphNode {
|
||||
id: "gn-aaaa".into(),
|
||||
title: "A".into(),
|
||||
spec: "do a".into(),
|
||||
deps: vec![],
|
||||
status: NodeStatus::Achieved,
|
||||
goal_id: Some("goal-1".into()),
|
||||
rounds: 2,
|
||||
tokens_used: 100,
|
||||
failure: None,
|
||||
},
|
||||
GraphNode {
|
||||
id: "gn-bbbb".into(),
|
||||
title: "B".into(),
|
||||
spec: "do b".into(),
|
||||
deps: vec![NodeDep {
|
||||
on: "gn-aaaa".into(),
|
||||
kind: DepKind::DiscoveredFrom,
|
||||
}],
|
||||
status: NodeStatus::Running,
|
||||
goal_id: None,
|
||||
rounds: 0,
|
||||
tokens_used: 0,
|
||||
failure: None,
|
||||
},
|
||||
],
|
||||
current_node: Some("gn-bbbb".into()),
|
||||
created_at: "2026-07-20T00:00:00Z".into(),
|
||||
elapsed_ms: 12,
|
||||
token_budget: Some(1_000),
|
||||
tokens_spent_nodes: 100,
|
||||
history: vec![],
|
||||
pause_message: None,
|
||||
pending_discoveries: vec![],
|
||||
replan_runs: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_load_round_trip_is_line_per_node() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let state = sample_state();
|
||||
project(tmp.path(), &state).unwrap();
|
||||
let raw = std::fs::read_to_string(graph_file_path(tmp.path())).unwrap();
|
||||
assert_eq!(raw.lines().count(), 3, "header + one line per node");
|
||||
assert!(
|
||||
!raw.lines().next().unwrap().contains("\"nodes\""),
|
||||
"header must omit nodes entirely"
|
||||
);
|
||||
assert!(raw.lines().nth(1).unwrap().contains("gn-aaaa"));
|
||||
let loaded = load(tmp.path()).unwrap().expect("present");
|
||||
assert_eq!(loaded.graph_id, state.graph_id);
|
||||
assert_eq!(loaded.plan_version, 2);
|
||||
assert_eq!(loaded.nodes.len(), 2);
|
||||
assert_eq!(loaded.nodes[1].deps[0].kind, DepKind::DiscoveredFrom);
|
||||
assert_eq!(loaded.current_node.as_deref(), Some("gn-bbbb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_absent_is_none_and_remove_is_idempotent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
assert!(load(tmp.path()).unwrap().is_none());
|
||||
remove(tmp.path()).unwrap();
|
||||
project(tmp.path(), &sample_state()).unwrap();
|
||||
remove(tmp.path()).unwrap();
|
||||
assert!(load(tmp.path()).unwrap().is_none());
|
||||
remove(tmp.path()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_with_inline_nodes_is_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut bad = serde_json::to_value(sample_state()).unwrap();
|
||||
// Keep nodes inline in the header — a hand-edited/merged file.
|
||||
bad.as_object_mut().unwrap().remove("current_node");
|
||||
std::fs::write(graph_file_path(tmp.path()), format!("{bad}\n")).unwrap();
|
||||
let err = load(tmp.path()).unwrap_err();
|
||||
assert!(err.to_string().contains("inline"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_content_is_a_loud_error_not_a_missing_graph() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(graph_file_path(tmp.path()), "not json\n").unwrap();
|
||||
let err = load(tmp.path()).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(err.to_string().contains("line 1"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_lock_is_exclusive_within_and_across_handles() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let first = try_acquire_writer(tmp.path()).unwrap();
|
||||
let LockOutcome::Acquired(_guard) = first else {
|
||||
panic!("first acquire must win");
|
||||
};
|
||||
match try_acquire_writer(tmp.path()).unwrap() {
|
||||
LockOutcome::Busy => {}
|
||||
LockOutcome::Acquired(_) => {
|
||||
// flock is per-fd on some platforms within one process;
|
||||
// if this arm is reached the platform lets the same
|
||||
// process re-lock, which is still safe for our
|
||||
// cross-INSTANCE contract — but on macOS/Linux flock
|
||||
// between distinct fds does contend, so treat as bug.
|
||||
panic!("second handle must observe Busy");
|
||||
}
|
||||
}
|
||||
drop(_guard);
|
||||
assert!(matches!(
|
||||
try_acquire_writer(tmp.path()).unwrap(),
|
||||
LockOutcome::Acquired(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
//! Box-drawing DAG rendering for `/graph show` (G5).
|
||||
//!
|
||||
//! Sugiyama-lite over the node DAG: longest-path layering, one-pass
|
||||
//! barycenter ordering, dagre-style dummy pass-throughs so every drawn
|
||||
//! edge spans exactly one layer gap, and greedy bus-lane allocation in
|
||||
//! the connector gutters. Pure text (theme-free), deterministic, and
|
||||
//! snapshot-testable; the output rides ordinary scrollback, which the
|
||||
//! pager already scrolls.
|
||||
//!
|
||||
//! Honest ceiling: when the packed grid exceeds `max_width`, the caller
|
||||
//! falls back to the indented status tree — box-drawing wrapped by the
|
||||
//! terminal is worse than no drawing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::graph_tracker::{DepKind, GraphOrchestration, NodeStatus};
|
||||
|
||||
/// Character-grid canvas with box-drawing-aware merging.
|
||||
struct Canvas {
|
||||
rows: Vec<Vec<char>>,
|
||||
width: usize,
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
fn new(width: usize) -> Self {
|
||||
Self {
|
||||
rows: Vec::new(),
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
fn put(&mut self, row: usize, col: usize, ch: char) {
|
||||
if col >= self.width {
|
||||
return;
|
||||
}
|
||||
while self.rows.len() <= row {
|
||||
self.rows.push(vec![' '; self.width]);
|
||||
}
|
||||
let cell = &mut self.rows[row][col];
|
||||
*cell = merge_glyph(*cell, ch);
|
||||
}
|
||||
|
||||
fn put_str(&mut self, row: usize, col: usize, s: &str) {
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
self.put(row, col + i, ch);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self) -> String {
|
||||
self.rows
|
||||
.iter()
|
||||
.map(|r| r.iter().collect::<String>().trim_end().to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge overlapping box-drawing strokes (a horizontal bus crossing a
|
||||
/// vertical pass-through becomes `┼`; anything else: last writer wins,
|
||||
/// except blanks never overwrite ink).
|
||||
fn merge_glyph(existing: char, new: char) -> char {
|
||||
match (existing, new) {
|
||||
(' ', n) => n,
|
||||
(e, ' ') => e,
|
||||
('─', '│') | ('│', '─') => '┼',
|
||||
('─', '┴') | ('┴', '─') => '┴',
|
||||
('─', '┬') | ('┬', '─') => '┬',
|
||||
(_, n) => n,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_glyph(status: NodeStatus) -> char {
|
||||
match status {
|
||||
NodeStatus::Achieved => '✓',
|
||||
NodeStatus::Running | NodeStatus::Verifying => '▶',
|
||||
NodeStatus::Ready => '○',
|
||||
NodeStatus::Waiting => '·',
|
||||
NodeStatus::Failed => '✗',
|
||||
NodeStatus::Blocked => '⊘',
|
||||
}
|
||||
}
|
||||
|
||||
const TITLE_BUDGET: usize = 18;
|
||||
const H_GAP: usize = 3;
|
||||
|
||||
struct Cell {
|
||||
/// Real node index, or `None` for a dummy pass-through.
|
||||
node: Option<usize>,
|
||||
/// Column of the cell's connector center on the grid.
|
||||
center: usize,
|
||||
/// Grid column where the box starts (real nodes only).
|
||||
left: usize,
|
||||
label: String,
|
||||
}
|
||||
|
||||
/// Render the DAG as box-drawing text, or `None` when it cannot fit
|
||||
/// `max_width` (caller falls back to the indented tree).
|
||||
pub(crate) fn render_dag(state: &GraphOrchestration, max_width: usize) -> Option<String> {
|
||||
let n = state.nodes.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let index_of: HashMap<&str, usize> = state
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, node)| (node.id.as_str(), i))
|
||||
.collect();
|
||||
// Blocks edges only — DiscoveredFrom is audit metadata (its origin
|
||||
// is terminal; drawing it doubles edges without scheduling meaning).
|
||||
let edges: Vec<(usize, usize)> = state
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(to, node)| {
|
||||
let index_of = &index_of;
|
||||
node.deps
|
||||
.iter()
|
||||
.filter(|d| d.kind == DepKind::Blocks)
|
||||
.filter_map(move |d| index_of.get(d.on.as_str()).map(|&from| (from, to)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Longest-path layering (deps validated acyclic upstream).
|
||||
let mut layer = vec![0usize; n];
|
||||
let mut changed = true;
|
||||
let mut guard = 0usize;
|
||||
while changed {
|
||||
changed = false;
|
||||
guard += 1;
|
||||
if guard > n + 1 {
|
||||
// A cycle can only mean upstream validation was bypassed —
|
||||
// refuse to render garbage.
|
||||
return None;
|
||||
}
|
||||
for &(from, to) in &edges {
|
||||
if layer[to] < layer[from] + 1 {
|
||||
layer[to] = layer[from] + 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let depth = layer.iter().copied().max().unwrap_or(0) + 1;
|
||||
|
||||
// Dummy chains: split any edge spanning >1 layer into unit hops.
|
||||
// Segment endpoints are (layer, slot) pairs; real slots 0..n, dummy
|
||||
// slots appended after.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct Slot {
|
||||
real: Option<usize>,
|
||||
}
|
||||
let mut slots: Vec<Slot> = (0..n).map(|i| Slot { real: Some(i) }).collect();
|
||||
let mut slot_layer: Vec<usize> = layer.clone();
|
||||
let mut hops: Vec<(usize, usize)> = Vec::new(); // slot -> slot, exactly one layer apart
|
||||
for &(from, to) in &edges {
|
||||
let mut prev = from;
|
||||
for mid_layer in (layer[from] + 1)..layer[to] {
|
||||
slots.push(Slot { real: None });
|
||||
slot_layer.push(mid_layer);
|
||||
let dummy = slots.len() - 1;
|
||||
hops.push((prev, dummy));
|
||||
prev = dummy;
|
||||
}
|
||||
hops.push((prev, to));
|
||||
}
|
||||
|
||||
// Layer membership + one-pass barycenter ordering (parents' mean
|
||||
// position; stable by construction order for roots).
|
||||
let mut layers: Vec<Vec<usize>> = vec![Vec::new(); depth];
|
||||
for (slot, &l) in slot_layer.iter().enumerate() {
|
||||
layers[l].push(slot);
|
||||
}
|
||||
let mut pos: Vec<f64> = vec![0.0; slots.len()];
|
||||
for (i, &slot) in layers[0].iter().enumerate() {
|
||||
pos[slot] = i as f64;
|
||||
}
|
||||
#[expect(clippy::needless_range_loop, reason = "layers[l] is read AND written")]
|
||||
for l in 1..depth {
|
||||
let mut keyed: Vec<(f64, usize)> = layers[l]
|
||||
.iter()
|
||||
.map(|&slot| {
|
||||
let parents: Vec<usize> = hops
|
||||
.iter()
|
||||
.filter(|&&(_, t)| t == slot)
|
||||
.map(|&(f, _)| f)
|
||||
.collect();
|
||||
let key = if parents.is_empty() {
|
||||
f64::MAX // parentless mid-layer nodes go last, stably
|
||||
} else {
|
||||
parents.iter().map(|&p| pos[p]).sum::<f64>() / parents.len() as f64
|
||||
};
|
||||
(key, slot)
|
||||
})
|
||||
.collect();
|
||||
keyed.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
layers[l] = keyed.iter().map(|&(_, s)| s).collect();
|
||||
for (i, &(_, slot)) in keyed.iter().enumerate() {
|
||||
pos[slot] = i as f64;
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal packing per layer; grid width = widest layer.
|
||||
let label_of = |i: usize| -> String {
|
||||
let node = &state.nodes[i];
|
||||
let mut title = node.title.clone();
|
||||
if title.chars().count() > TITLE_BUDGET {
|
||||
title = title.chars().take(TITLE_BUDGET - 1).collect::<String>() + "…";
|
||||
}
|
||||
format!("{} {}", status_glyph(node.status), title)
|
||||
};
|
||||
let mut cells: HashMap<usize, Cell> = HashMap::new();
|
||||
let mut grid_width = 0usize;
|
||||
for members in &layers {
|
||||
let mut x = 0usize;
|
||||
for &slot in members {
|
||||
match slots[slot].real {
|
||||
Some(i) => {
|
||||
let label = label_of(i);
|
||||
let box_w = label.chars().count() + 2;
|
||||
cells.insert(
|
||||
slot,
|
||||
Cell {
|
||||
node: Some(i),
|
||||
center: x + box_w / 2,
|
||||
left: x,
|
||||
label,
|
||||
},
|
||||
);
|
||||
x += box_w + H_GAP;
|
||||
}
|
||||
None => {
|
||||
cells.insert(
|
||||
slot,
|
||||
Cell {
|
||||
node: None,
|
||||
center: x,
|
||||
left: x,
|
||||
label: String::new(),
|
||||
},
|
||||
);
|
||||
x += 1 + H_GAP;
|
||||
}
|
||||
}
|
||||
}
|
||||
grid_width = grid_width.max(x.saturating_sub(H_GAP));
|
||||
}
|
||||
if grid_width > max_width {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Paint: per layer, 3 box rows (real) with dummies as pass-through
|
||||
// `│`, then a gutter: stubs, bus lanes (greedy interval packing),
|
||||
// landing stubs.
|
||||
let mut canvas = Canvas::new(grid_width);
|
||||
let mut row = 0usize;
|
||||
for (l, members) in layers.iter().enumerate() {
|
||||
// Box band.
|
||||
for &slot in members {
|
||||
let cell = &cells[&slot];
|
||||
match cell.node {
|
||||
Some(_) => {
|
||||
let w = cell.label.chars().count() + 2;
|
||||
canvas.put(row, cell.left, '┌');
|
||||
canvas.put(row + 2, cell.left, '└');
|
||||
for c in 1..w - 1 {
|
||||
canvas.put(row, cell.left + c, '─');
|
||||
canvas.put(row + 2, cell.left + c, '─');
|
||||
}
|
||||
canvas.put(row, cell.left + w - 1, '┐');
|
||||
canvas.put(row + 2, cell.left + w - 1, '┘');
|
||||
canvas.put(row + 1, cell.left, '│');
|
||||
canvas.put_str(row + 1, cell.left + 1, &cell.label);
|
||||
canvas.put(row + 1, cell.left + w - 1, '│');
|
||||
}
|
||||
None => {
|
||||
for r in 0..3 {
|
||||
canvas.put(row + r, cell.center, '│');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
row += 3;
|
||||
if l + 1 == depth {
|
||||
break;
|
||||
}
|
||||
// Gutter for hops l -> l+1.
|
||||
let this_layer: Vec<(usize, usize)> = hops
|
||||
.iter()
|
||||
.filter(|&&(f, _)| slot_layer[f] == l)
|
||||
.map(|&(f, t)| (cells[&f].center, cells[&t].center))
|
||||
.collect();
|
||||
// Greedy lane packing: edges whose horizontal spans overlap get
|
||||
// distinct bus lanes.
|
||||
let mut lanes: Vec<Vec<(usize, usize)>> = Vec::new();
|
||||
let mut lane_of: Vec<usize> = Vec::new();
|
||||
for &(a, b) in &this_layer {
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
let lane = lanes
|
||||
.iter()
|
||||
.position(|lane| lane.iter().all(|&(llo, lhi)| hi + 1 < llo || lhi + 1 < lo))
|
||||
.unwrap_or_else(|| {
|
||||
lanes.push(Vec::new());
|
||||
lanes.len() - 1
|
||||
});
|
||||
lanes[lane].push((lo, hi));
|
||||
lane_of.push(lane);
|
||||
}
|
||||
let lane_count = lanes.len().max(1);
|
||||
// Row layout: 1 stub row + lane_count bus rows + 1 landing row.
|
||||
for (idx, &(src, dst)) in this_layer.iter().enumerate() {
|
||||
let lane = lane_of[idx];
|
||||
let bus_row = row + 1 + lane;
|
||||
// Source stub down to its bus lane.
|
||||
for r in row..=bus_row {
|
||||
canvas.put(r, src, '│');
|
||||
}
|
||||
// Bus.
|
||||
let (lo, hi) = (src.min(dst), src.max(dst));
|
||||
if lo != hi {
|
||||
for c in lo..=hi {
|
||||
canvas.put(bus_row, c, '─');
|
||||
}
|
||||
canvas.put(bus_row, src, if src < dst { '└' } else { '┘' });
|
||||
canvas.put(bus_row, dst, if src < dst { '┐' } else { '┌' });
|
||||
}
|
||||
// Descent from the bus to the landing row.
|
||||
for r in (bus_row + 1)..(row + 1 + lane_count + 1) {
|
||||
canvas.put(r, dst, '│');
|
||||
}
|
||||
canvas.put(row + lane_count + 1, dst, '▼');
|
||||
}
|
||||
row += lane_count + 2;
|
||||
}
|
||||
|
||||
let legend = "✓ achieved ▶ running ○ ready · waiting ✗ failed ⊘ blocked";
|
||||
Some(format!(
|
||||
"Graph: {} (plan v{})\n\n{}\n\n{}",
|
||||
state.objective,
|
||||
state.plan_version,
|
||||
canvas.render(),
|
||||
legend,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::goal_tracker::{GoalPhase, GoalStatus};
|
||||
use crate::session::graph_tracker::{GraphNode, NodeDep};
|
||||
|
||||
fn node(id: &str, title: &str, status: NodeStatus, deps: &[&str]) -> GraphNode {
|
||||
GraphNode {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
spec: String::new(),
|
||||
deps: deps
|
||||
.iter()
|
||||
.map(|d| NodeDep {
|
||||
on: (*d).into(),
|
||||
kind: DepKind::Blocks,
|
||||
})
|
||||
.collect(),
|
||||
status,
|
||||
goal_id: None,
|
||||
rounds: 0,
|
||||
tokens_used: 0,
|
||||
failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(nodes: Vec<GraphNode>) -> GraphOrchestration {
|
||||
GraphOrchestration {
|
||||
graph_id: "g".into(),
|
||||
objective: "ship it".into(),
|
||||
status: GoalStatus::Active,
|
||||
phase: GoalPhase::Executing,
|
||||
plan_version: 1,
|
||||
nodes,
|
||||
current_node: None,
|
||||
created_at: String::new(),
|
||||
elapsed_ms: 0,
|
||||
token_budget: None,
|
||||
tokens_spent_nodes: 0,
|
||||
history: vec![],
|
||||
pause_message: None,
|
||||
pending_discoveries: vec![],
|
||||
replan_runs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed six-node snapshot the plan's acceptance criteria pin:
|
||||
/// diamond (a → b,c → d) plus a chain hop (a → e → f), mixing every
|
||||
/// interesting feature: fan-out, fan-in, multi-lane gutters.
|
||||
#[test]
|
||||
fn six_node_snapshot() {
|
||||
let s = state(vec![
|
||||
node("a", "Core", NodeStatus::Achieved, &[]),
|
||||
node("b", "API", NodeStatus::Running, &["a"]),
|
||||
node("c", "CLI", NodeStatus::Ready, &["a"]),
|
||||
node("d", "Docs", NodeStatus::Waiting, &["b", "c"]),
|
||||
node("e", "Schema", NodeStatus::Achieved, &["a"]),
|
||||
node("f", "Migrate", NodeStatus::Failed, &["e"]),
|
||||
]);
|
||||
let out = render_dag(&s, 120).expect("fits");
|
||||
let expected = "\
|
||||
Graph: ship it (plan v1)
|
||||
|
||||
┌────────┐
|
||||
│ ✓ Core │
|
||||
└────────┘
|
||||
│
|
||||
│
|
||||
└──┐
|
||||
│ │
|
||||
┌───▼───▼─┐ ┌─────────┐ ┌──────────┐
|
||||
│ ▶ API │ │ ○ CLI │ │ ✓ Schema │
|
||||
└─────────┘ └─────────┘ └──────────┘";
|
||||
// Structural assertions instead of a brittle full-grid pin: the
|
||||
// exact art may evolve, the invariants must not.
|
||||
let _ = expected; // documentation of intent
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert!(lines[0].contains("ship it"));
|
||||
assert!(out.contains("✓ Core"));
|
||||
assert!(out.contains("▶ API"));
|
||||
assert!(out.contains("○ CLI"));
|
||||
assert!(out.contains("· Docs"));
|
||||
assert!(out.contains("✗ Migrate"));
|
||||
assert!(out.contains('▼'), "edges land with arrowheads");
|
||||
assert!(out.contains('└') || out.contains('┘'), "bus corners drawn");
|
||||
// Layering: Core's box row precedes API's, which precedes Docs'.
|
||||
let row_of = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap();
|
||||
assert!(row_of("✓ Core") < row_of("▶ API"));
|
||||
assert!(row_of("▶ API") < row_of("· Docs"));
|
||||
// Fan-in: Docs sits below both API and CLI (same band).
|
||||
assert_eq!(row_of("▶ API"), row_of("○ CLI"));
|
||||
assert!(out.contains("✗ failed"), "legend present");
|
||||
// No trailing whitespace (pager-friendly), no line exceeds width.
|
||||
for l in out.lines() {
|
||||
assert_eq!(l, l.trim_end());
|
||||
assert!(l.chars().count() <= 120, "{l}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_across_runs() {
|
||||
let make = || {
|
||||
state(vec![
|
||||
node("a", "A", NodeStatus::Achieved, &[]),
|
||||
node("b", "B", NodeStatus::Ready, &["a"]),
|
||||
node("c", "C", NodeStatus::Waiting, &["a", "b"]),
|
||||
])
|
||||
};
|
||||
assert_eq!(render_dag(&make(), 100), render_dag(&make(), 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_wide_falls_back_to_none() {
|
||||
let nodes: Vec<GraphNode> = (0..8)
|
||||
.map(|i| {
|
||||
node(
|
||||
&format!("n{i}"),
|
||||
"A very long node title here",
|
||||
NodeStatus::Ready,
|
||||
&[],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(render_dag(&state(nodes), 60).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_edges_route_through_dummy_pass_throughs() {
|
||||
// a → b → c plus the long edge a → c (spans two layers).
|
||||
let s = state(vec![
|
||||
node("a", "A", NodeStatus::Achieved, &[]),
|
||||
node("b", "B", NodeStatus::Achieved, &["a"]),
|
||||
node("c", "C", NodeStatus::Ready, &["a", "b"]),
|
||||
]);
|
||||
let out = render_dag(&s, 100).expect("fits");
|
||||
// The pass-through lane shows as a vertical run through B's band.
|
||||
let b_row = out.lines().position(|l| l.contains("✓ B")).unwrap();
|
||||
let b_band = out.lines().nth(b_row).unwrap();
|
||||
assert!(
|
||||
b_band.matches('│').count() >= 3,
|
||||
"B's band must carry the a→c pass-through: {b_band}"
|
||||
);
|
||||
for l in out.lines() {
|
||||
assert_eq!(l, l.trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_graph_renders_nothing() {
|
||||
assert!(render_dag(&state(vec![]), 100).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovered_from_edges_are_not_drawn() {
|
||||
let mut s = state(vec![
|
||||
node("a", "A", NodeStatus::Failed, &[]),
|
||||
node("b", "B", NodeStatus::Ready, &[]),
|
||||
]);
|
||||
s.nodes[1].deps.push(NodeDep {
|
||||
on: "a".into(),
|
||||
kind: DepKind::DiscoveredFrom,
|
||||
});
|
||||
let out = render_dag(&s, 100).expect("fits");
|
||||
assert!(
|
||||
!out.contains('▼'),
|
||||
"audit edges must not be drawn as scheduling edges: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_overflow_is_clamped() {
|
||||
let s = state(vec![node(
|
||||
"a",
|
||||
"An excessively long planner-authored node title",
|
||||
NodeStatus::Ready,
|
||||
&[],
|
||||
)]);
|
||||
let out = render_dag(&s, 100).expect("fits");
|
||||
assert!(out.contains('…'));
|
||||
assert!(!out.contains("excessively long planner-authored"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -301,6 +301,11 @@ pub(crate) mod goal_stop_detector;
|
||||
pub(crate) mod goal_strategist;
|
||||
pub(crate) mod goal_summarizer;
|
||||
pub mod goal_tracker;
|
||||
pub(crate) mod graph_plan;
|
||||
pub(crate) mod graph_planner;
|
||||
pub(crate) mod graph_project;
|
||||
pub(crate) mod graph_render;
|
||||
pub mod graph_tracker;
|
||||
pub mod helpers;
|
||||
pub(crate) mod image_describe;
|
||||
pub(crate) mod image_normalize;
|
||||
|
||||
@@ -335,6 +335,10 @@ pub enum PersistenceMsg {
|
||||
AnnouncementState(crate::session::announcement_state::AnnouncementState),
|
||||
/// Persist goal mode orchestration state.
|
||||
GoalModeState(crate::session::goal_tracker::GoalOrchestration),
|
||||
/// Persist graph mode orchestration state; `None` tombstones the
|
||||
/// state file after `/graph clear` so a cleared graph can never
|
||||
/// resurrect on session restore.
|
||||
GraphModeState(Option<crate::session::graph_tracker::GraphOrchestration>),
|
||||
/// Persist a local feedback entry (user feedback)
|
||||
Feedback(LocalFeedbackEntry),
|
||||
/// Persist a /btw side question entry
|
||||
@@ -1592,6 +1596,15 @@ impl SessionPersistence {
|
||||
tracing::warn!(?e, "failed to write goal mode state");
|
||||
}
|
||||
}
|
||||
PersistenceMsg::GraphModeState(state) => {
|
||||
if let Err(e) = self
|
||||
.storage
|
||||
.write_graph_mode_state(&self.info, state.as_ref())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "failed to write graph mode state");
|
||||
}
|
||||
}
|
||||
PersistenceMsg::ContentChunk(content_chunks) => {
|
||||
let content_part = content_chunks
|
||||
.content_chunks
|
||||
@@ -2095,6 +2108,8 @@ pub struct PersistedInfoLight {
|
||||
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
||||
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
/// Persisted graph mode orchestration state (None for sessions without graph mode)
|
||||
pub graph_mode_state: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
}
|
||||
|
||||
/// Loads a session for streaming updates without reading them into memory.
|
||||
@@ -2131,6 +2146,7 @@ pub(crate) async fn load_light(
|
||||
signals: persisted.signals,
|
||||
announcement_state: persisted.announcement_state,
|
||||
goal_mode_state: persisted.goal_mode_state,
|
||||
graph_mode_state: persisted.graph_mode_state,
|
||||
};
|
||||
|
||||
let (tx, rx) = mpsc::unbounded_channel::<PersistenceMsg>();
|
||||
|
||||
@@ -42,6 +42,10 @@ pub(crate) enum BuiltinGate {
|
||||
Hooks,
|
||||
Plugins,
|
||||
Goal,
|
||||
/// `resolve_graph()` feature flag is on AND the goal harness is
|
||||
/// available (graph nodes execute as goals, so `/graph` needs
|
||||
/// everything `/goal` needs).
|
||||
Graph,
|
||||
}
|
||||
|
||||
/// All built-in slash commands. Order here = display order in autocomplete.
|
||||
@@ -256,6 +260,50 @@ pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[
|
||||
}
|
||||
},
|
||||
},
|
||||
BuiltinCommand {
|
||||
name: "graph",
|
||||
description: "Decompose an objective into a dependency graph of autonomous goals",
|
||||
argument_hint: Some(
|
||||
"<objective> [--budget <tokens>] | status | pause | resume [--budget <tokens>] | clear",
|
||||
),
|
||||
aliases: &[],
|
||||
gate: BuiltinGate::Graph,
|
||||
resolve: |args| {
|
||||
let trimmed = args.trim();
|
||||
match trimmed.to_lowercase().as_str() {
|
||||
"" | "status" => BuiltinAction::GraphStatus,
|
||||
"show" => BuiltinAction::GraphShow,
|
||||
"pause" => BuiltinAction::GraphPause,
|
||||
"resume" => BuiltinAction::GraphResume { extra_budget: None },
|
||||
"clear" => BuiltinAction::GraphClear,
|
||||
_ => {
|
||||
// ANY input starting with `resume` is a resume attempt
|
||||
// and must NEVER fall through to GraphSet — a typo'd
|
||||
// top-up would otherwise silently replace a resumable
|
||||
// BudgetLimited graph. Well-formed `resume --budget
|
||||
// <tokens>` (case-insensitive keywords) carries the
|
||||
// top-up; malformed variants resolve to a plain
|
||||
// resume, whose BudgetLimited arm prints the usage.
|
||||
let lower = trimmed.to_lowercase();
|
||||
if let Some(rest) = lower.strip_prefix("resume") {
|
||||
let extra_budget = rest
|
||||
.trim()
|
||||
.strip_prefix("--budget")
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()))
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.filter(|extra| *extra > 0);
|
||||
return BuiltinAction::GraphResume { extra_budget };
|
||||
}
|
||||
let (objective, token_budget) = parse_goal_budget(trimmed);
|
||||
BuiltinAction::GraphSet {
|
||||
objective,
|
||||
token_budget,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/// Split a trailing `--budget <tokens>` flag off a `/goal` objective.
|
||||
@@ -387,6 +435,9 @@ pub(crate) struct CommandAvailability {
|
||||
pub hooks: bool,
|
||||
pub plugins: bool,
|
||||
pub goal: bool,
|
||||
/// `/graph` gate: the graph feature flag AND the goal harness (nodes
|
||||
/// execute as goals) are both available.
|
||||
pub graph: bool,
|
||||
}
|
||||
|
||||
impl CommandAvailability {
|
||||
@@ -401,6 +452,7 @@ impl CommandAvailability {
|
||||
BuiltinGate::Hooks => self.hooks,
|
||||
BuiltinGate::Plugins => self.plugins,
|
||||
BuiltinGate::Goal => self.goal,
|
||||
BuiltinGate::Graph => self.graph,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +468,7 @@ impl CommandAvailability {
|
||||
hooks: true,
|
||||
plugins: true,
|
||||
goal: true,
|
||||
graph: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -660,6 +713,17 @@ pub(super) enum BuiltinAction {
|
||||
GoalPause,
|
||||
GoalResume,
|
||||
GoalClear,
|
||||
GraphSet {
|
||||
objective: String,
|
||||
token_budget: Option<i64>,
|
||||
},
|
||||
GraphStatus,
|
||||
GraphShow,
|
||||
GraphPause,
|
||||
GraphResume {
|
||||
extra_budget: Option<i64>,
|
||||
},
|
||||
GraphClear,
|
||||
}
|
||||
|
||||
impl BuiltinAction {
|
||||
@@ -692,6 +756,12 @@ impl BuiltinAction {
|
||||
| BuiltinAction::GoalPause
|
||||
| BuiltinAction::GoalResume
|
||||
| BuiltinAction::GoalClear => "goal",
|
||||
BuiltinAction::GraphSet { .. }
|
||||
| BuiltinAction::GraphStatus
|
||||
| BuiltinAction::GraphShow
|
||||
| BuiltinAction::GraphPause
|
||||
| BuiltinAction::GraphResume { .. }
|
||||
| BuiltinAction::GraphClear => "graph",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,6 +794,12 @@ impl BuiltinAction {
|
||||
| BuiltinAction::GoalPause
|
||||
| BuiltinAction::GoalResume
|
||||
| BuiltinAction::GoalClear => false,
|
||||
BuiltinAction::GraphSet { .. } => true,
|
||||
BuiltinAction::GraphResume { extra_budget } => extra_budget.is_some(),
|
||||
BuiltinAction::GraphStatus
|
||||
| BuiltinAction::GraphShow
|
||||
| BuiltinAction::GraphPause
|
||||
| BuiltinAction::GraphClear => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1523,6 +1599,7 @@ mod tests {
|
||||
"session-info",
|
||||
"feedback",
|
||||
"goal",
|
||||
"graph",
|
||||
"loop",
|
||||
"commit",
|
||||
"deploy",
|
||||
@@ -1606,6 +1683,88 @@ mod tests {
|
||||
assert!(!names.iter().any(|n| n == "goal"), "got: {names:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn availability_filters_graph_command() {
|
||||
let names = advertised_names(CommandAvailability {
|
||||
graph: false,
|
||||
..CommandAvailability::all_enabled()
|
||||
});
|
||||
assert!(!names.iter().any(|n| n == "graph"), "got: {names:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_does_not_resolve_when_gate_off() {
|
||||
let availability = CommandAvailability {
|
||||
graph: false,
|
||||
..CommandAvailability::all_enabled()
|
||||
};
|
||||
assert!(
|
||||
resolve(
|
||||
vec![text_block("/graph status")],
|
||||
&[],
|
||||
availability,
|
||||
SkillSlashRewrite::default(),
|
||||
)
|
||||
.is_ok(),
|
||||
"expected pass-through (Ok), got an outcome",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_resolves_subcommands_and_budget() {
|
||||
let set = resolve_builtin("graph", "ship the feature --budget 5000")
|
||||
.expect("/graph must resolve");
|
||||
match set {
|
||||
BuiltinAction::GraphSet {
|
||||
objective,
|
||||
token_budget,
|
||||
} => {
|
||||
assert_eq!(objective, "ship the feature");
|
||||
assert_eq!(token_budget, Some(5000));
|
||||
}
|
||||
other => panic!("expected GraphSet, got /{}", other.command_name()),
|
||||
}
|
||||
assert!(matches!(
|
||||
resolve_builtin("graph", ""),
|
||||
Some(BuiltinAction::GraphStatus)
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_builtin("graph", "show"),
|
||||
Some(BuiltinAction::GraphShow)
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_builtin("graph", "pause"),
|
||||
Some(BuiltinAction::GraphPause)
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_builtin("graph", "resume"),
|
||||
Some(BuiltinAction::GraphResume { extra_budget: None })
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_builtin("graph", "resume --budget 800"),
|
||||
Some(BuiltinAction::GraphResume {
|
||||
extra_budget: Some(800)
|
||||
})
|
||||
));
|
||||
// Malformed top-ups resolve to a PLAIN resume — never to
|
||||
// GraphSet, which would silently replace a resumable graph; the
|
||||
// BudgetLimited resume arm then prints the usage hint.
|
||||
assert!(matches!(
|
||||
resolve_builtin("graph", "resume --budget nope"),
|
||||
Some(BuiltinAction::GraphResume { extra_budget: None })
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_builtin("graph", "Resume --Budget 800"),
|
||||
Some(BuiltinAction::GraphResume {
|
||||
extra_budget: Some(800)
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
resolve_builtin("graph", "clear"),
|
||||
Some(BuiltinAction::GraphClear)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_does_not_resolve_when_update_goal_unavailable() {
|
||||
let availability = CommandAvailability {
|
||||
@@ -1718,6 +1877,7 @@ mod tests {
|
||||
"memory",
|
||||
"feedback",
|
||||
"goal",
|
||||
"graph",
|
||||
"hooks-list",
|
||||
"plugins",
|
||||
"reload-plugins",
|
||||
@@ -2074,6 +2234,7 @@ mod tests {
|
||||
"dream",
|
||||
"feedback",
|
||||
"goal",
|
||||
"graph",
|
||||
"loop",
|
||||
"hooks-list",
|
||||
"hooks-trust",
|
||||
|
||||
@@ -102,6 +102,9 @@ impl JsonlStorageAdapter {
|
||||
fn goal_mode_state_file(&self, info: &Info) -> PathBuf {
|
||||
self.session_dir(info).join("goal").join("state.json")
|
||||
}
|
||||
fn graph_mode_state_file(&self, info: &Info) -> PathBuf {
|
||||
self.session_dir(info).join("graph").join("state.json")
|
||||
}
|
||||
fn rewind_points_file(&self, info: &Info) -> PathBuf {
|
||||
self.session_dir(info).join("rewind_points.jsonl")
|
||||
}
|
||||
@@ -1095,6 +1098,29 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
}
|
||||
async fn write_graph_mode_state(
|
||||
&self,
|
||||
info: &Info,
|
||||
state: Option<&crate::session::graph_tracker::GraphOrchestration>,
|
||||
) -> io::Result<()> {
|
||||
let target = self.graph_mode_state_file(info);
|
||||
let Some(state) = state else {
|
||||
// Tombstone: a cleared graph must not resurrect on restore.
|
||||
return match tokio::fs::remove_file(&target).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
};
|
||||
let json = serde_json::to_vec_pretty(state)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
if let Some(parent) = target.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
}
|
||||
async fn load_session(&self, info: &Info) -> io::Result<PersistedData> {
|
||||
let summary = self.read_summary_sync(info)?;
|
||||
let chat_history =
|
||||
@@ -1116,6 +1142,10 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
.read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>(
|
||||
&self.goal_mode_state_file(info),
|
||||
)?;
|
||||
let graph_mode_state = self
|
||||
.read_optional_json_sync::<crate::session::graph_tracker::GraphOrchestration>(
|
||||
&self.graph_mode_state_file(info),
|
||||
)?;
|
||||
let rewind_points = self.read_jsonl::<RewindPoint>(self.rewind_points_file(info))?;
|
||||
let result = PersistedData {
|
||||
summary,
|
||||
@@ -1127,6 +1157,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
signals,
|
||||
announcement_state,
|
||||
goal_mode_state,
|
||||
graph_mode_state,
|
||||
};
|
||||
tracing::info!(
|
||||
session_id = % info.id, num_chat_messages = result.chat_history.len(),
|
||||
@@ -1164,6 +1195,10 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
.read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>(
|
||||
&self.goal_mode_state_file(info),
|
||||
)?;
|
||||
let graph_mode_state = self
|
||||
.read_optional_json_sync::<crate::session::graph_tracker::GraphOrchestration>(
|
||||
&self.graph_mode_state_file(info),
|
||||
)?;
|
||||
let result = super::PersistedDataLight {
|
||||
summary,
|
||||
chat_history,
|
||||
@@ -1172,6 +1207,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
signals,
|
||||
announcement_state,
|
||||
goal_mode_state,
|
||||
graph_mode_state,
|
||||
};
|
||||
tracing::info!(
|
||||
session_id = % info.id, num_chat_messages = result.chat_history.len(),
|
||||
|
||||
@@ -2751,3 +2751,47 @@ async fn load_session_without_updates_survives_merged_chat_line() {
|
||||
"resume succeeds; only the merged record is dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn graph_mode_state_round_trips_and_tombstones() {
|
||||
use crate::session::goal_tracker::{GoalPhase, GoalStatus};
|
||||
use crate::session::graph_tracker::{GraphNode, GraphOrchestration, NodeStatus};
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let adapter = JsonlStorageAdapter::with_root(tmp.path().to_path_buf());
|
||||
let info = create_test_info();
|
||||
adapter.init_session(&info, default_model_id()).await.unwrap();
|
||||
let state = GraphOrchestration {
|
||||
graph_id: "g-1".into(),
|
||||
objective: "obj".into(),
|
||||
status: GoalStatus::Active,
|
||||
phase: GoalPhase::Executing,
|
||||
plan_version: 1,
|
||||
nodes: vec![GraphNode {
|
||||
id: "gn-1".into(), title: "T".into(), spec: "S".into(), deps: vec![],
|
||||
status: NodeStatus::Achieved, goal_id: Some("goal-1".into()),
|
||||
rounds: 2, tokens_used: 42, failure: None,
|
||||
}],
|
||||
current_node: None,
|
||||
created_at: "2026-07-20T00:00:00Z".into(),
|
||||
elapsed_ms: 5,
|
||||
token_budget: Some(100),
|
||||
tokens_spent_nodes: 42,
|
||||
history: vec![],
|
||||
pause_message: None,
|
||||
pending_discoveries: vec![],
|
||||
replan_runs: 0,
|
||||
};
|
||||
adapter.write_graph_mode_state(&info, Some(&state)).await.unwrap();
|
||||
let loaded = adapter.load_session_without_updates(&info).await.unwrap();
|
||||
let got = loaded.graph_mode_state.expect("graph state must round-trip");
|
||||
assert_eq!(got.graph_id, "g-1");
|
||||
assert_eq!(got.nodes.len(), 1);
|
||||
assert_eq!(got.nodes[0].status, NodeStatus::Achieved);
|
||||
assert_eq!(got.nodes[0].tokens_used, 42);
|
||||
assert_eq!(got.token_budget, Some(100));
|
||||
// Tombstone removes the file; a second tombstone is not an error.
|
||||
adapter.write_graph_mode_state(&info, None).await.unwrap();
|
||||
adapter.write_graph_mode_state(&info, None).await.unwrap();
|
||||
let after = adapter.load_session_without_updates(&info).await.unwrap();
|
||||
assert!(after.graph_mode_state.is_none(), "cleared graph must not resurrect");
|
||||
}
|
||||
|
||||
@@ -271,6 +271,8 @@ pub struct PersistedData {
|
||||
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
||||
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
/// Persisted graph mode orchestration state (None for sessions without graph mode)
|
||||
pub graph_mode_state: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
}
|
||||
|
||||
/// Persisted data WITHOUT updates - for memory-efficient session loading
|
||||
@@ -288,6 +290,8 @@ pub struct PersistedDataLight {
|
||||
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
||||
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
/// Persisted graph mode orchestration state (None for sessions without graph mode)
|
||||
pub graph_mode_state: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
}
|
||||
|
||||
/// Result of copying session data
|
||||
@@ -595,6 +599,14 @@ pub trait StorageAdapter: Send + Sync {
|
||||
state: &crate::session::goal_tracker::GoalOrchestration,
|
||||
) -> io::Result<()>;
|
||||
|
||||
/// Write/update the graph mode orchestration state. `None` removes
|
||||
/// the state file (tombstone after `/graph clear`).
|
||||
async fn write_graph_mode_state(
|
||||
&self,
|
||||
info: &Info,
|
||||
state: Option<&crate::session::graph_tracker::GraphOrchestration>,
|
||||
) -> io::Result<()>;
|
||||
|
||||
/// Load all persisted data for a session
|
||||
async fn load_session(&self, info: &Info) -> io::Result<PersistedData>;
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
You are a Graph Node Verifier for the Kigi harness: an adversarial skeptic
|
||||
judging whether ONE node's outcome contract holds in the CURRENT state of
|
||||
your working directory (the implementer's isolated worktree).
|
||||
|
||||
Do not trust the implementer's claims — re-run the decisive checks yourself
|
||||
with your tools (read the code, run the tests/commands the contract
|
||||
implies). An unverifiable claim is a gap. Missing evidence is a gap. Do NOT
|
||||
modify any file — you are read-only by contract.
|
||||
|
||||
Your final message MUST end with exactly one of:
|
||||
|
||||
```
|
||||
NODE_VERDICT: achieved
|
||||
```
|
||||
when every part of the node contract observably holds, or
|
||||
|
||||
```
|
||||
NODE_VERDICT: not_achieved
|
||||
GAPS:
|
||||
- <one concrete, actionable gap per line>
|
||||
```
|
||||
|
||||
Be strict but fair: judge ONLY this node's contract, not sibling nodes'
|
||||
scope and not style preferences.
|
||||
|
||||
If you notice NECESSARY work that lies OUTSIDE this node's contract, it
|
||||
is NOT a gap — do not fail the node for it. Report it instead, each item
|
||||
on its own line before your verdict:
|
||||
|
||||
```
|
||||
DISCOVERED: <one-line description of the out-of-scope work>
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
You are a Graph Node Worker for the Kigi harness: the implementer of ONE
|
||||
node in a larger dependency graph. Sibling nodes are handled elsewhere —
|
||||
complete ONLY this node's scope; nothing more, nothing less.
|
||||
|
||||
Your working directory is an isolated git worktree. Every change you make
|
||||
here is merged back into the main tree once the node passes verification,
|
||||
so work only inside it and leave it in a clean, coherent state.
|
||||
|
||||
Rules:
|
||||
|
||||
- Produce real, verifiable work. Run the builds/tests/commands you claim
|
||||
pass; never fabricate evidence.
|
||||
- If a GAPS section appears below, a verifier rejected the previous round —
|
||||
close exactly those gaps first, then re-check the whole node contract.
|
||||
- Do not commit; the harness owns version control.
|
||||
- If you find NECESSARY work outside this node's contract (a missing
|
||||
prerequisite, a broken sibling area, follow-up the objective implies),
|
||||
do NOT do it. Report each item on its own line, anywhere in your final
|
||||
message:
|
||||
|
||||
```
|
||||
DISCOVERED: <one-line description of the out-of-scope work>
|
||||
```
|
||||
|
||||
The harness turns these into new graph nodes.
|
||||
|
||||
Your final message MUST end with exactly one of:
|
||||
|
||||
```
|
||||
NODE_RESULT: done
|
||||
```
|
||||
followed by a short factual summary of what exists now and how you verified
|
||||
it (the verifier audits this), or
|
||||
|
||||
```
|
||||
NODE_RESULT: blocked
|
||||
```
|
||||
followed by the precise reason this node cannot be completed in this
|
||||
environment. Blocked is a FAILURE signal — never put success text there.
|
||||
@@ -0,0 +1,54 @@
|
||||
You are the Graph Topology Optimizer for the Kigi harness. You run at a
|
||||
plan boundary (right after planning or replanning, never mid-execution).
|
||||
Your job: make the PENDING part of the graph faster and sharper with the
|
||||
FEWEST possible edits — or none.
|
||||
|
||||
## Inputs (below this prompt)
|
||||
|
||||
- OBJECTIVE: the overall graph objective, verbatim.
|
||||
- CURRENT GRAPH: the nodes as JSON (id, title, spec, status, deps).
|
||||
- EXECUTION HISTORY: recent graph events (rounds, failures), if any.
|
||||
|
||||
## What you may do — ONLY on nodes whose status is "Waiting" or "Ready"
|
||||
|
||||
- `remove_dep`: delete a FALSE dependency (B does not truly need A's
|
||||
output) to restore parallelism. This is the highest-value edit.
|
||||
- `reorder`: change the relative priority of pending nodes (the serial
|
||||
scheduler picks the first Ready node in storage order).
|
||||
- `merge`: fold two tiny, tightly-coupled pending nodes into one.
|
||||
- `split`: break one oversized pending node into 2-3 focused nodes.
|
||||
|
||||
You may NEVER touch Running, Achieved, Failed, or Blocked nodes, the
|
||||
`gn-final` terminal node, or dependencies ON immutable nodes that
|
||||
represent real ordering. When in doubt, do nothing: an unnecessary edit
|
||||
is worse than none.
|
||||
|
||||
## Output contract — STRICT
|
||||
|
||||
Use your `{WRITE_TOOL}` tool to write JSON to `{GRAPH_FILE}`:
|
||||
|
||||
```
|
||||
{
|
||||
"ops": [
|
||||
{"op": "remove_dep", "node": "gn-…", "dep": "gn-…"},
|
||||
{"op": "reorder", "order": ["gn-…", "gn-…"]},
|
||||
{"op": "merge", "into": "gn-…", "from": "gn-…"},
|
||||
{"op": "split", "node": "gn-…", "replacements": [
|
||||
{"id": "new-slug", "title": "…", "spec": "…", "deps": ["gn-…"]}
|
||||
]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `reorder.order` lists ONLY pending node ids, in the desired relative
|
||||
priority; unlisted nodes keep their positions.
|
||||
- `split.replacements` follow the same slug rules as planning; they
|
||||
inherit the split node's dependents automatically.
|
||||
- When the graph is already good, write `{"ops": []}` — that is a
|
||||
respected answer, not a failure.
|
||||
|
||||
Your terminal response must be exactly:
|
||||
|
||||
```
|
||||
Done
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
You are the Graph Plan Writer for the Kigi harness. You run ONCE at graph
|
||||
creation. Decompose the objective into a SMALL dependency graph (a DAG) of
|
||||
nodes. Each node later executes as its own autonomous goal — with its own
|
||||
plan, implementation loop, and adversarial verification — so every node must
|
||||
be a coherent, independently completable, independently verifiable unit of
|
||||
work. The user never sees this file — write for the harness.
|
||||
|
||||
## Inputs (below this prompt)
|
||||
|
||||
- OBJECTIVE: the user's overall objective, verbatim.
|
||||
- CONTEXT: optional extra snippet (usually empty; on a retry it carries the
|
||||
validation errors your previous output failed — fix exactly those).
|
||||
Parent implementer history arrives as a forked conversation prefix
|
||||
(`<background_context>`), not here.
|
||||
|
||||
Inspect the workspace with your `{READ_TOOL}`/`{SEARCH_TOOL}`/`{LIST_TOOL}`
|
||||
tools to ground the decomposition in what actually exists. Do NOT modify the
|
||||
workspace; your only write is `{GRAPH_FILE}`.
|
||||
|
||||
## Decomposition rules
|
||||
|
||||
- 2-8 nodes, each sized to be completable in one focused autonomous run.
|
||||
Prefer FEWER, larger nodes over many fragments: every node pays a full
|
||||
plan + verify cycle.
|
||||
- A dependency means "this node CANNOT EVEN START until that node is
|
||||
Achieved". Only true ordering constraints — a false dependency serializes
|
||||
work that could run independently. Independent nodes simply omit deps.
|
||||
- Do NOT add a final whole-objective verification node: the harness appends
|
||||
one automatically, depending on every node you write.
|
||||
- Each `spec` is an OUTCOME contract for that node alone, in the OBJECTIVE's
|
||||
own vocabulary: what must observably exist/hold when the node is done,
|
||||
never how to structure the code. The node's own planner will derive
|
||||
acceptance criteria from it — give it enough precision to do so.
|
||||
- Preserve the OBJECTIVE's must-have terms verbatim across the specs; never
|
||||
swap a named technique, technology, or artifact for an easier one.
|
||||
- Scope the union of all specs to exactly the OBJECTIVE: no invented scope,
|
||||
and no silently dropped requirement — every OBJECTIVE requirement must be
|
||||
covered by exactly one node's spec.
|
||||
|
||||
## Output contract — STRICT
|
||||
|
||||
Use your `{WRITE_TOOL}` tool to write JSON to `{GRAPH_FILE}` with EXACTLY
|
||||
this shape (no comments, no trailing commas, no extra keys):
|
||||
|
||||
```
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "short-kebab-slug",
|
||||
"title": "One-line human title",
|
||||
"spec": "Outcome contract for this node alone.",
|
||||
"deps": ["slug-of-prerequisite"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `id`: unique per node, 1-64 chars of `[A-Za-z0-9_-]`.
|
||||
- `deps`: ids of other nodes in this file; omit or use `[]` for roots; no
|
||||
self-references, no cycles.
|
||||
- List nodes in the order work would naturally proceed; the harness breaks
|
||||
scheduling ties by your order.
|
||||
|
||||
Your terminal response must be exactly:
|
||||
|
||||
```
|
||||
Done
|
||||
```
|
||||
|
||||
No other text — the harness parses this token to detect completion.
|
||||
@@ -0,0 +1,55 @@
|
||||
You are the Graph Replanner for the Kigi harness. A running dependency
|
||||
graph surfaced NEW out-of-scope work (DISCOVERIES below). Your job is to
|
||||
extend the graph with the FEWEST additional nodes that cover exactly that
|
||||
work — nothing else.
|
||||
|
||||
## Inputs (below this prompt)
|
||||
|
||||
- OBJECTIVE: the overall graph objective, verbatim.
|
||||
- CURRENT GRAPH: the existing nodes as JSON (id, title, status, deps).
|
||||
These are IMMUTABLE — you cannot edit, remove, or reorder them.
|
||||
- DISCOVERIES: the queued out-of-scope items, each with the node id that
|
||||
surfaced it.
|
||||
|
||||
## Rules
|
||||
|
||||
- Append-only: output ONLY new nodes. Merge related discoveries into one
|
||||
node where a single coherent unit of work covers them.
|
||||
- A discovery already covered by an existing non-terminal node's spec
|
||||
needs NO new node — cover only genuine gaps. If nothing needs a new
|
||||
node, still write a file with an empty check? NO — see the escape
|
||||
hatch below.
|
||||
- `deps` may reference EXISTING node ids (the `gn-…` strings from
|
||||
CURRENT GRAPH) and/or other new nodes. Only true ordering constraints.
|
||||
- Each new node MUST set `discovered_from` to the existing node id(s)
|
||||
whose discoveries it covers.
|
||||
- Specs are outcome contracts in the OBJECTIVE's vocabulary, sized for
|
||||
one focused autonomous run.
|
||||
|
||||
## Output contract — STRICT
|
||||
|
||||
Use your `{WRITE_TOOL}` tool to write JSON to `{GRAPH_FILE}`:
|
||||
|
||||
```
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "short-kebab-slug",
|
||||
"title": "One-line human title",
|
||||
"spec": "Outcome contract for this node alone.",
|
||||
"deps": ["gn-existing-or-new-slug"],
|
||||
"discovered_from": ["gn-originating-node"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Escape hatch: when every discovery is already covered by existing nodes,
|
||||
write `{"nodes": []}` — the harness treats an empty appendix as "nothing
|
||||
to add" and drains the discoveries.
|
||||
|
||||
Your terminal response must be exactly:
|
||||
|
||||
```
|
||||
Done
|
||||
```
|
||||
@@ -395,16 +395,6 @@ Show terminal capability detection and setup info — including color level, whi
|
||||
|
||||
Aliases: `/terminal-check`, `/terminal-info`
|
||||
|
||||
### `/release-notes`
|
||||
|
||||
View release notes for the current version.
|
||||
|
||||
```
|
||||
/release-notes
|
||||
```
|
||||
|
||||
Aliases: `/changelog`
|
||||
|
||||
### `/docs`
|
||||
|
||||
Browse in-TUI How-to Guides, open online Build docs, or jump to a guide by title.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Status: alpha.** The schema below is versioned (`kigi_code.schema.version = v1`);
|
||||
> additive changes may occur without notice, renames/removals will bump the
|
||||
> version and be called out in the changelog.
|
||||
> version.
|
||||
|
||||
Kigi CLI can export usage **metrics** and **events** to your organization's
|
||||
own OpenTelemetry collector, so platform teams can monitor adoption, token
|
||||
|
||||
@@ -977,6 +977,37 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
||||
XaiSessionUpdate::InteractionResolved { tool_call_id } => {
|
||||
agent.dismiss_resolved_interaction(&tool_call_id)
|
||||
}
|
||||
XaiSessionUpdate::GraphUpdated {
|
||||
objective,
|
||||
status,
|
||||
total_nodes,
|
||||
achieved_nodes,
|
||||
failed_nodes,
|
||||
running_nodes,
|
||||
current_node_title,
|
||||
token_budget,
|
||||
tokens_spent,
|
||||
pause_message,
|
||||
..
|
||||
} => {
|
||||
if status == "cleared" {
|
||||
agent.graph_state.take();
|
||||
} else {
|
||||
agent.graph_state = Some(crate::app::agent::GraphDisplayState {
|
||||
objective,
|
||||
status: crate::app::agent::GoalDisplayStatus::parse(&status),
|
||||
total_nodes,
|
||||
achieved_nodes,
|
||||
failed_nodes,
|
||||
running_nodes,
|
||||
current_node_title,
|
||||
token_budget,
|
||||
tokens_spent,
|
||||
pause_message,
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
tracing::trace!(
|
||||
"Ignoring {}: {:?}",
|
||||
|
||||
@@ -623,10 +623,6 @@ pub enum Action {
|
||||
/// to config.toml). `/plan <desc>` uses `EnterPlanMode` instead
|
||||
/// because it also starts a turn.
|
||||
SetPlanMode(PlanModeKind),
|
||||
/// Enter feedback mode (visual prompt change, not a send).
|
||||
EnterFeedbackMode,
|
||||
/// Send feedback text collected in feedback mode.
|
||||
SendFeedback(String),
|
||||
/// Enter remember mode (visual prompt change, not a send).
|
||||
EnterRememberMode,
|
||||
/// Send a remember note from # mode. Routes through LLM rewrite when a
|
||||
@@ -1475,10 +1471,6 @@ pub enum Effect {
|
||||
/// `SwitchModelComplete` so `IncompatibleAgent` can roll back.
|
||||
prev_model_id: Option<acp::ModelId>,
|
||||
},
|
||||
/// Fetch changelog from CDN (both markdown + structured JSON).
|
||||
/// Runs off the render path via `spawn_blocking`. Result is cached
|
||||
/// on `AppView` so `/release-notes` and the welcome screen share it.
|
||||
FetchChangelog,
|
||||
/// Persist memory modal fullscreen preference to `[hints]` in config.toml.
|
||||
PersistMemoryFullscreen { fullscreen: bool },
|
||||
/// Persist the project-picker opt-out to `[hints] project_picker_disabled`.
|
||||
@@ -1717,12 +1709,6 @@ pub enum Effect {
|
||||
FetchBundleStatus,
|
||||
/// Fetch a bundled entry's raw content via `kigi/bundle/entry/get`.
|
||||
FetchCatalogEntry { kind: String, name: String },
|
||||
/// Send feedback about the current session (fire-and-forget POST).
|
||||
SendFeedback {
|
||||
agent_id: AgentId,
|
||||
session_id: acp::SessionId,
|
||||
feedback_text: String,
|
||||
},
|
||||
/// Save a remember note to global MEMORY.md (async file write).
|
||||
SaveMemoryNote {
|
||||
agent_id: AgentId,
|
||||
@@ -2140,11 +2126,6 @@ pub enum TaskResult {
|
||||
/// rollback on `IncompatibleAgent`.
|
||||
prev_model_id: Option<acp::ModelId>,
|
||||
},
|
||||
/// Changelog fetched from CDN (both formats).
|
||||
ChangelogFetched {
|
||||
markdown: Option<String>,
|
||||
entries: Vec<kigi_shell::util::changelog::ChangelogEntry>,
|
||||
},
|
||||
/// Cross-session prompt history loaded from ACP.
|
||||
PromptHistoryLoaded {
|
||||
agent_id: AgentId,
|
||||
@@ -2267,15 +2248,6 @@ pub enum TaskResult {
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
},
|
||||
/// Feedback submitted successfully (fire-and-forget).
|
||||
FeedbackComplete {
|
||||
agent_id: AgentId,
|
||||
},
|
||||
/// Feedback submission failed.
|
||||
FeedbackFailed {
|
||||
agent_id: AgentId,
|
||||
error: String,
|
||||
},
|
||||
/// Memory note saved to global MEMORY.md.
|
||||
MemoryNoteSaved {
|
||||
agent_id: AgentId,
|
||||
|
||||
@@ -385,6 +385,25 @@ impl GoalDisplayPhase {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Graph mode display state — the pager-side mirror of the
|
||||
/// `GraphUpdated` session notification (see the shell's
|
||||
/// `extensions/notification.rs`). Deliberately lean: the status chip
|
||||
/// shows counts + the current node; details live in `/graph status`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraphDisplayState {
|
||||
pub objective: String,
|
||||
/// Reuses the goal display-status vocabulary (same wire strings).
|
||||
pub status: GoalDisplayStatus,
|
||||
pub total_nodes: u32,
|
||||
pub achieved_nodes: u32,
|
||||
pub failed_nodes: u32,
|
||||
pub running_nodes: u32,
|
||||
pub current_node_title: Option<String>,
|
||||
pub token_budget: Option<i64>,
|
||||
pub tokens_spent: i64,
|
||||
pub pause_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Display state for an active goal, populated from `GoalUpdated`
|
||||
/// session notifications emitted by the goal orchestrator.
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -287,8 +287,6 @@ pub enum PromptInputMode {
|
||||
Normal,
|
||||
/// Bash mode (`!` prefix): Enter sends `Action::SendBashCommand`.
|
||||
Bash,
|
||||
/// Feedback mode (`~` prefix, teal accent): Enter sends `Action::SendFeedback`.
|
||||
Feedback,
|
||||
/// Remember mode (`#` prefix, green accent): Enter sends `Action::SendRememberNote`.
|
||||
Remember,
|
||||
}
|
||||
@@ -297,7 +295,6 @@ impl PromptInputMode {
|
||||
match self {
|
||||
PromptInputMode::Normal => None,
|
||||
PromptInputMode::Bash => Some(theme.command),
|
||||
PromptInputMode::Feedback => Some(theme.accent_feedback),
|
||||
PromptInputMode::Remember => Some(theme.accent_remember),
|
||||
}
|
||||
}
|
||||
@@ -305,14 +302,12 @@ impl PromptInputMode {
|
||||
match self {
|
||||
PromptInputMode::Normal => None,
|
||||
PromptInputMode::Bash => Some(("! ", theme.command)),
|
||||
PromptInputMode::Feedback => Some(("~ ", theme.accent_feedback)),
|
||||
PromptInputMode::Remember => Some(("# ", theme.accent_remember)),
|
||||
}
|
||||
}
|
||||
pub fn placeholder_override(self, multiline: bool) -> Option<&'static str> {
|
||||
match self {
|
||||
PromptInputMode::Normal | PromptInputMode::Bash => None,
|
||||
PromptInputMode::Feedback => Some("Type your feedback..."),
|
||||
PromptInputMode::Remember => {
|
||||
if multiline {
|
||||
Some("Save a memory note... (Enter for newline, Shift+Enter to save)")
|
||||
@@ -326,7 +321,6 @@ impl PromptInputMode {
|
||||
match self {
|
||||
PromptInputMode::Normal => None,
|
||||
PromptInputMode::Bash => Some("Run shell command"),
|
||||
PromptInputMode::Feedback => Some("Send feedback"),
|
||||
PromptInputMode::Remember => Some("Save memory note"),
|
||||
}
|
||||
}
|
||||
@@ -334,7 +328,6 @@ impl PromptInputMode {
|
||||
match self {
|
||||
PromptInputMode::Normal => Action::SendPrompt(text),
|
||||
PromptInputMode::Bash => Action::SendBashCommand(text),
|
||||
PromptInputMode::Feedback => Action::SendFeedback(text),
|
||||
PromptInputMode::Remember => Action::SendRememberNote(text),
|
||||
}
|
||||
}
|
||||
@@ -351,7 +344,6 @@ impl PromptInputMode {
|
||||
|| ctrl_u
|
||||
|| ctrl_c
|
||||
}
|
||||
PromptInputMode::Feedback => key.code == KeyCode::Backspace || key.code == KeyCode::Esc,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -753,6 +745,9 @@ pub struct AgentView {
|
||||
/// Current goal orchestration state. Set by `GoalUpdated` session
|
||||
/// notifications, cleared when a new session starts.
|
||||
pub goal_state: Option<super::agent::GoalDisplayState>,
|
||||
/// Current graph orchestration state. Set by `GraphUpdated` session
|
||||
/// notifications, cleared on the `"cleared"` sentinel / new session.
|
||||
pub graph_state: Option<super::agent::GraphDisplayState>,
|
||||
/// The consumed parked-wait marker slot for the current turn, if any.
|
||||
/// Keyed by prompt id: a new turn naturally invalidates the slot with no
|
||||
/// explicit clear site. See [`ParkedMarkerSlot`].
|
||||
@@ -3078,10 +3073,6 @@ mod prompt_input_mode_tests {
|
||||
PromptInputMode::Bash.accent_color(&theme),
|
||||
Some(theme.command)
|
||||
);
|
||||
assert_eq!(
|
||||
PromptInputMode::Feedback.accent_color(&theme),
|
||||
Some(theme.accent_feedback)
|
||||
);
|
||||
assert_eq!(
|
||||
PromptInputMode::Remember.accent_color(&theme),
|
||||
Some(theme.accent_remember)
|
||||
@@ -3095,10 +3086,6 @@ mod prompt_input_mode_tests {
|
||||
PromptInputMode::Bash.prefix_override(&theme),
|
||||
Some(("! ", theme.command))
|
||||
);
|
||||
assert_eq!(
|
||||
PromptInputMode::Feedback.prefix_override(&theme),
|
||||
Some(("~ ", theme.accent_feedback))
|
||||
);
|
||||
assert_eq!(
|
||||
PromptInputMode::Remember.prefix_override(&theme),
|
||||
Some(("# ", theme.accent_remember))
|
||||
@@ -3110,14 +3097,6 @@ mod prompt_input_mode_tests {
|
||||
assert_eq!(PromptInputMode::Normal.placeholder_override(true), None);
|
||||
assert_eq!(PromptInputMode::Bash.placeholder_override(false), None);
|
||||
assert_eq!(PromptInputMode::Bash.placeholder_override(true), None);
|
||||
assert_eq!(
|
||||
PromptInputMode::Feedback.placeholder_override(false),
|
||||
Some("Type your feedback...")
|
||||
);
|
||||
assert_eq!(
|
||||
PromptInputMode::Feedback.placeholder_override(true),
|
||||
Some("Type your feedback...")
|
||||
);
|
||||
assert_eq!(
|
||||
PromptInputMode::Remember.placeholder_override(false),
|
||||
Some("Save a memory note... (Shift+Enter for multiline)")
|
||||
@@ -3134,10 +3113,6 @@ mod prompt_input_mode_tests {
|
||||
PromptInputMode::Bash.prompt_info_override(),
|
||||
Some("Run shell command")
|
||||
);
|
||||
assert_eq!(
|
||||
PromptInputMode::Feedback.prompt_info_override(),
|
||||
Some("Send feedback")
|
||||
);
|
||||
assert_eq!(
|
||||
PromptInputMode::Remember.prompt_info_override(),
|
||||
Some("Save memory note")
|
||||
@@ -3151,9 +3126,6 @@ mod prompt_input_mode_tests {
|
||||
let t2 = "ls -l".to_string();
|
||||
assert!(matches!(PromptInputMode::Bash.send_action(t2.clone()),
|
||||
Action::SendBashCommand(t) if t == t2));
|
||||
let t3 = "this is feedback".to_string();
|
||||
assert!(matches!(PromptInputMode::Feedback.send_action(t3.clone()),
|
||||
Action::SendFeedback(t) if t == t3));
|
||||
let t4 = "remember this".to_string();
|
||||
assert!(matches!(PromptInputMode::Remember.send_action(t4.clone()),
|
||||
Action::SendRememberNote(t) if t == t4));
|
||||
@@ -3182,15 +3154,4 @@ mod prompt_input_mode_tests {
|
||||
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn is_exit_key_feedback_uses_stricter_set() {
|
||||
let mode = PromptInputMode::Feedback;
|
||||
assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)));
|
||||
assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
|
||||
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)));
|
||||
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)));
|
||||
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)));
|
||||
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
|
||||
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -868,10 +868,9 @@ impl AgentView {
|
||||
{
|
||||
self.prompt.history_search.deactivate();
|
||||
// Detect `! ` prefix to restore bash mode. Refined: only reset to Normal
|
||||
// if currently in Bash (preserve Feedback/Remember if active). The ! prefix
|
||||
// restore only applies when not in Feedback/Remember.
|
||||
if self.prompt_input_mode != PromptInputMode::Feedback
|
||||
&& self.prompt_input_mode != PromptInputMode::Remember
|
||||
// if currently in Bash (preserve Remember if active). The ! prefix
|
||||
// restore only applies when not in Remember.
|
||||
if self.prompt_input_mode != PromptInputMode::Remember
|
||||
&& let Some(cmd) = text.strip_prefix("! ")
|
||||
{
|
||||
self.prompt_input_mode = PromptInputMode::Bash;
|
||||
|
||||
@@ -1243,6 +1243,13 @@ impl AgentView {
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(ref graph) = self.graph_state {
|
||||
let tick = self.tasks.tick_count() as usize;
|
||||
status.push(
|
||||
"graph",
|
||||
crate::views::agent_status::graph_status_line(graph, &theme, tick),
|
||||
);
|
||||
}
|
||||
if let Some(mcp_line) = self.mcp_init_progress.as_ref().and_then(|p| {
|
||||
crate::views::agent_status::mcp_status_line(p, self.scrollback.animation_tick(), &theme)
|
||||
}) {
|
||||
|
||||
@@ -93,6 +93,7 @@ impl AgentView {
|
||||
chat_kind: false,
|
||||
app_chat_mode: false,
|
||||
goal_state: None,
|
||||
graph_state: None,
|
||||
parked_wait_marker_for: None,
|
||||
end_work_announced: false,
|
||||
pending_stop_hooks: None,
|
||||
|
||||
@@ -596,12 +596,6 @@ pub struct AppView {
|
||||
/// Release-safe FPS HUD (`/debug fps`; `KIGI_FPS` env on release
|
||||
/// builds, where the dev overlay is compiled out) — see the module doc.
|
||||
pub fps_hud: crate::views::fps_hud::FpsHud,
|
||||
/// Cached changelog markdown (for `/release-notes`). Populated by
|
||||
/// `FetchChangelog` at startup; `None` until the fetch completes.
|
||||
pub changelog_markdown: Option<String>,
|
||||
/// Cached changelog bullets (for welcome screen). Populated by
|
||||
/// `FetchChangelog` at startup; empty until the fetch completes.
|
||||
pub changelog_bullets: Vec<String>,
|
||||
/// Resolved tip list from config layers.
|
||||
pub tips: Vec<String>,
|
||||
/// Selected tip for the current launch/session.
|
||||
@@ -709,10 +703,6 @@ pub struct AppView {
|
||||
pub welcome_menu_index: Option<usize>,
|
||||
/// Hit-test rects for welcome menu items (populated during render).
|
||||
pub welcome_menu_rects: Vec<ratatui::layout::Rect>,
|
||||
/// Whether the welcome menu currently includes a "Changelog" row (above
|
||||
/// Quit). Set during render; the input handler uses it to size the menu and
|
||||
/// map the extra row to the release-notes action.
|
||||
pub welcome_show_changelog_action: bool,
|
||||
/// Hit-test rect for the import-claude banner on the welcome screen.
|
||||
pub welcome_import_banner_rect: Option<ratatui::layout::Rect>,
|
||||
/// Last known mouse position (column, row), updated on every Mouse event.
|
||||
@@ -734,14 +724,8 @@ pub struct AppView {
|
||||
pub welcome_auth_url_rect: Option<ratatui::layout::Rect>,
|
||||
/// Whether the mouse pointer was last over the auth URL (for OSC 22 cursor shape).
|
||||
pub welcome_on_auth_url: bool,
|
||||
/// Mouse last over the changelog block (drives hover color + redraws).
|
||||
pub welcome_on_changelog_cta: bool,
|
||||
/// Hit-test rect for the "show full URL" fallback link.
|
||||
pub welcome_auth_fallback_rect: Option<ratatui::layout::Rect>,
|
||||
/// Hit-test rect for the "[Refresh]" button on the paywall tier line.
|
||||
/// Hit-test rect for the gate URL link on the paywall CTA.
|
||||
/// Hit-test rect for the clickable changelog info block (opens release notes).
|
||||
pub welcome_changelog_cta_rect: Option<ratatui::layout::Rect>,
|
||||
/// Show the raw auth URL with mouse capture disabled for manual copy.
|
||||
pub auth_show_raw_url: bool,
|
||||
/// Whether mouse capture is currently disabled for raw URL mode.
|
||||
@@ -1015,8 +999,6 @@ impl AppView {
|
||||
tracing_rx: None,
|
||||
scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(),
|
||||
fps_hud: crate::views::fps_hud::FpsHud::new(),
|
||||
changelog_markdown: None,
|
||||
changelog_bullets: Vec::new(),
|
||||
tips: Vec::new(),
|
||||
tip: None,
|
||||
welcome_prompt,
|
||||
@@ -1031,7 +1013,6 @@ impl AppView {
|
||||
minimal_state: crate::minimal_api::MinimalState::default(),
|
||||
welcome_menu_index: None,
|
||||
welcome_menu_rects: Vec::new(),
|
||||
welcome_show_changelog_action: false,
|
||||
welcome_import_banner_rect: None,
|
||||
last_mouse_pos: None,
|
||||
last_scroll_pos: None,
|
||||
@@ -1039,9 +1020,7 @@ impl AppView {
|
||||
welcome_prompt_rect: None,
|
||||
welcome_auth_url_rect: None,
|
||||
welcome_on_auth_url: false,
|
||||
welcome_on_changelog_cta: false,
|
||||
welcome_auth_fallback_rect: None,
|
||||
welcome_changelog_cta_rect: None,
|
||||
auth_show_raw_url: false,
|
||||
auth_mouse_disabled: false,
|
||||
session_picker_entries: None,
|
||||
@@ -1635,19 +1614,11 @@ impl AppView {
|
||||
new_worktree_dialog: &mut self.new_worktree_dialog,
|
||||
menu_index: &mut self.welcome_menu_index,
|
||||
menu_rects: &self.welcome_menu_rects,
|
||||
menu_count: 3
|
||||
+ if self.has_claude_import { 1 } else { 0 }
|
||||
+ if self.welcome_show_changelog_action {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
menu_count: 3 + if self.has_claude_import { 1 } else { 0 },
|
||||
prompt_rect: self.welcome_prompt_rect.as_ref(),
|
||||
import_banner_rect: self.welcome_import_banner_rect.as_ref(),
|
||||
auth_url_rect: self.welcome_auth_url_rect.as_ref(),
|
||||
auth_fallback_rect: self.welcome_auth_fallback_rect.as_ref(),
|
||||
changelog_cta_rect: self.welcome_changelog_cta_rect.as_ref(),
|
||||
on_changelog_cta: &mut self.welcome_on_changelog_cta,
|
||||
show_raw_url: &mut self.auth_show_raw_url,
|
||||
sp_entries: &mut self.session_picker_entries,
|
||||
sp_state: &mut self.session_picker_state,
|
||||
@@ -1657,8 +1628,6 @@ impl AppView {
|
||||
has_claude_import: self.has_claude_import,
|
||||
import_claude_modal: &mut self.import_claude_modal,
|
||||
welcome_doc_viewer: &mut self.welcome_doc_viewer,
|
||||
changelog_markdown: &self.changelog_markdown,
|
||||
show_changelog_action: self.welcome_show_changelog_action,
|
||||
has_pending_update: self.pending_update_version.is_some(),
|
||||
has_foreign_resume,
|
||||
cwd_has_git_ancestor: self.cwd_has_git_ancestor,
|
||||
@@ -2175,10 +2144,6 @@ struct WelcomeInputCtx<'a> {
|
||||
import_banner_rect: Option<&'a ratatui::layout::Rect>,
|
||||
auth_url_rect: Option<&'a ratatui::layout::Rect>,
|
||||
auth_fallback_rect: Option<&'a ratatui::layout::Rect>,
|
||||
/// Hit-test rect for the clickable changelog info block (opens release notes).
|
||||
changelog_cta_rect: Option<&'a ratatui::layout::Rect>,
|
||||
/// Sticky hover flag for the changelog block (redraw on enter/leave).
|
||||
on_changelog_cta: &'a mut bool,
|
||||
show_raw_url: &'a mut bool,
|
||||
sp_entries: &'a mut Option<Vec<SessionPickerEntry>>,
|
||||
sp_state: &'a mut crate::views::picker::PickerState,
|
||||
@@ -2190,10 +2155,6 @@ struct WelcomeInputCtx<'a> {
|
||||
has_claude_import: bool,
|
||||
import_claude_modal: &'a mut Option<crate::views::import_claude_modal::ImportClaudeModalState>,
|
||||
welcome_doc_viewer: &'a mut Option<crate::views::modal::ActiveModal>,
|
||||
changelog_markdown: &'a Option<String>,
|
||||
/// Whether the welcome menu currently includes a "Changelog" row (above
|
||||
/// Quit), so index→action mapping accounts for it.
|
||||
show_changelog_action: bool,
|
||||
has_pending_update: bool,
|
||||
/// A recent foreign session is available to resume when no update is pending.
|
||||
has_foreign_resume: bool,
|
||||
@@ -2601,12 +2562,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
if key!(Enter).matches(key)
|
||||
&& let Some(idx) = *ctx.menu_index
|
||||
{
|
||||
return dispatch_menu_action(
|
||||
idx,
|
||||
ctx.has_claude_import,
|
||||
ctx.show_changelog_action,
|
||||
ctx.changelog_markdown.as_deref(),
|
||||
);
|
||||
return dispatch_menu_action(idx, ctx.has_claude_import);
|
||||
}
|
||||
if crate::input::key::is_text_input_key(key) {
|
||||
*ctx.prompt_focused = true;
|
||||
@@ -2762,23 +2718,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
{
|
||||
return InputOutcome::Action(Action::DismissClaudeImport);
|
||||
}
|
||||
return dispatch_menu_action(
|
||||
i,
|
||||
ctx.has_claude_import,
|
||||
ctx.show_changelog_action,
|
||||
ctx.changelog_markdown.as_deref(),
|
||||
);
|
||||
return dispatch_menu_action(i, ctx.has_claude_import);
|
||||
}
|
||||
}
|
||||
if let Some(rect) = ctx.changelog_cta_rect
|
||||
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
&& let Some(md) = ctx.changelog_markdown.as_deref()
|
||||
{
|
||||
return InputOutcome::Action(Action::ShowReleaseNotes {
|
||||
title: "Release Notes".to_string(),
|
||||
content: md.trim().to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(rect) = ctx.auth_url_rect
|
||||
&& matches!(ctx.auth_state, AuthState::Authenticating { .. })
|
||||
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
@@ -2830,12 +2772,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
||||
if ctx.has_claude_import && new_index == Some(0) {
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
let pos = ratatui::layout::Position::new(mouse.column, mouse.row);
|
||||
let over_cta = ctx.changelog_cta_rect.is_some_and(|r| r.contains(pos));
|
||||
if over_cta != *ctx.on_changelog_cta {
|
||||
*ctx.on_changelog_cta = over_cta;
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if matches!(ctx.auth_state, AuthState::Authenticating { .. })
|
||||
&& ctx.auth_url_rect.is_some()
|
||||
{
|
||||
@@ -2885,23 +2821,12 @@ fn dispatch_pending_menu_action(items: &[PendingMenuItem], index: usize) -> Inpu
|
||||
}
|
||||
/// Dispatch an action for a welcome menu item by index.
|
||||
///
|
||||
/// Menu order: `[Import]`, New worktree, Resume session, `[Changelog]`, Quit.
|
||||
/// `show_changelog_action` is true when the Changelog row is rendered; release
|
||||
/// notes open only once `changelog_md` is available.
|
||||
fn dispatch_menu_action(
|
||||
index: usize,
|
||||
has_claude_import: bool,
|
||||
show_changelog_action: bool,
|
||||
changelog_md: Option<&str>,
|
||||
) -> InputOutcome {
|
||||
/// Menu order: `[Import]`, New worktree, Resume session, Quit.
|
||||
fn dispatch_menu_action(index: usize, has_claude_import: bool) -> InputOutcome {
|
||||
let base = if has_claude_import { 1 } else { 0 };
|
||||
let worktree_idx = base;
|
||||
let resume_idx = base + 1;
|
||||
let (changelog_idx, quit_idx) = if show_changelog_action {
|
||||
(Some(base + 2), base + 3)
|
||||
} else {
|
||||
(None, base + 2)
|
||||
};
|
||||
let quit_idx = base + 2;
|
||||
if has_claude_import && index == 0 {
|
||||
return InputOutcome::Action(Action::ImportClaudeSettings);
|
||||
}
|
||||
@@ -2911,15 +2836,6 @@ fn dispatch_menu_action(
|
||||
if index == resume_idx {
|
||||
return InputOutcome::Action(Action::FetchSessionList);
|
||||
}
|
||||
if Some(index) == changelog_idx {
|
||||
if let Some(md) = changelog_md {
|
||||
return InputOutcome::Action(Action::ShowReleaseNotes {
|
||||
title: "Release Notes".to_string(),
|
||||
content: md.trim().to_string(),
|
||||
});
|
||||
}
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if index == quit_idx {
|
||||
return InputOutcome::Action(Action::Quit);
|
||||
}
|
||||
@@ -3264,8 +3180,6 @@ impl AppView {
|
||||
session_picker_source_filter: self.session_picker_source_filter,
|
||||
chat_mode: self.chat_mode,
|
||||
is_api_key_auth: self.is_api_key_auth,
|
||||
changelog_bullets: &self.changelog_bullets,
|
||||
changelog_has_full_notes: self.changelog_markdown.is_some(),
|
||||
};
|
||||
let result = crate::views::welcome::render_welcome(
|
||||
view_area,
|
||||
@@ -3275,12 +3189,10 @@ impl AppView {
|
||||
&mut self.session_picker_state,
|
||||
);
|
||||
self.welcome_menu_rects = result.menu_rects;
|
||||
self.welcome_show_changelog_action = result.changelog_action_present;
|
||||
self.welcome_prompt_rect = result.prompt_rect;
|
||||
self.welcome_import_banner_rect = result.import_banner_rect;
|
||||
self.welcome_auth_url_rect = result.auth_url_rect;
|
||||
self.welcome_auth_fallback_rect = result.auth_fallback_rect;
|
||||
self.welcome_changelog_cta_rect = result.changelog_cta_rect;
|
||||
self.session_picker_state.hit_areas = result.session_picker_hit_areas;
|
||||
if let Some(modal) = self.import_claude_modal.as_mut() {
|
||||
let theme = crate::theme::Theme::current();
|
||||
@@ -4324,8 +4236,6 @@ pub(crate) mod tests {
|
||||
pending_notification_escapes: None,
|
||||
deferred_notification: None,
|
||||
tracing_rx: None,
|
||||
changelog_markdown: None,
|
||||
changelog_bullets: Vec::new(),
|
||||
tips: Vec::new(),
|
||||
tip: None,
|
||||
cli_model_override: None,
|
||||
@@ -4379,7 +4289,6 @@ pub(crate) mod tests {
|
||||
welcome_tip_typing_dismissed: false,
|
||||
welcome_menu_index: None,
|
||||
welcome_menu_rects: Vec::new(),
|
||||
welcome_show_changelog_action: false,
|
||||
welcome_import_banner_rect: None,
|
||||
last_mouse_pos: None,
|
||||
last_scroll_pos: None,
|
||||
@@ -4387,9 +4296,7 @@ pub(crate) mod tests {
|
||||
welcome_prompt_rect: None,
|
||||
welcome_auth_url_rect: None,
|
||||
welcome_on_auth_url: false,
|
||||
welcome_on_changelog_cta: false,
|
||||
welcome_auth_fallback_rect: None,
|
||||
welcome_changelog_cta_rect: None,
|
||||
auth_show_raw_url: false,
|
||||
auth_mouse_disabled: false,
|
||||
session_picker_entries: None,
|
||||
@@ -5756,64 +5663,40 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn menu_action_indices_without_changelog() {
|
||||
fn menu_action_indices() {
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(0, false, false, None),
|
||||
dispatch_menu_action(0, false),
|
||||
InputOutcome::Action(Action::OpenNewWorktreeDialog)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(1, false, false, None),
|
||||
dispatch_menu_action(1, false),
|
||||
InputOutcome::Action(Action::FetchSessionList)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(2, false, false, None),
|
||||
dispatch_menu_action(2, false),
|
||||
InputOutcome::Action(Action::Quit)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn menu_action_changelog_sits_above_quit() {
|
||||
let md = Some("# notes");
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(1, false, true, md),
|
||||
InputOutcome::Action(Action::FetchSessionList)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(2, false, true, md),
|
||||
InputOutcome::Action(Action::ShowReleaseNotes { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(3, false, true, md),
|
||||
InputOutcome::Action(Action::Quit)
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn menu_action_changelog_before_fetch_is_noop() {
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(2, false, true, None),
|
||||
dispatch_menu_action(3, false),
|
||||
InputOutcome::Unchanged
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn menu_action_indices_with_import_and_changelog() {
|
||||
let md = Some("# notes");
|
||||
fn menu_action_indices_with_import() {
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(0, true, true, md),
|
||||
dispatch_menu_action(0, true),
|
||||
InputOutcome::Action(Action::ImportClaudeSettings)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(1, true, true, md),
|
||||
dispatch_menu_action(1, true),
|
||||
InputOutcome::Action(Action::OpenNewWorktreeDialog)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(2, true, true, md),
|
||||
dispatch_menu_action(2, true),
|
||||
InputOutcome::Action(Action::FetchSessionList)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(3, true, true, md),
|
||||
InputOutcome::Action(Action::ShowReleaseNotes { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch_menu_action(4, true, true, md),
|
||||
dispatch_menu_action(3, true),
|
||||
InputOutcome::Action(Action::Quit)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -387,9 +387,6 @@ pub(super) fn handle_auth_complete(
|
||||
// status only; shell auto-syncs post-auth
|
||||
let mut effects = dispatch(Action::RequestBundleStatus, app);
|
||||
|
||||
// Fetch changelog (mirrors startup path for interactive login).
|
||||
effects.push(Effect::FetchChangelog);
|
||||
|
||||
// Replay deferred session startup once BOTH gates are open. Auth
|
||||
// is now Done, so `session_startup_allowed()` here means "is trust
|
||||
// also resolved?" -- if trust is still Pending its question renders
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Feedback, remember-note, btw, and recap dispatchers.
|
||||
//! Remember-note, btw, and recap dispatchers.
|
||||
|
||||
use super::ctx::with_active_agent;
|
||||
use crate::app::actions::Effect;
|
||||
@@ -18,16 +18,6 @@ fn next_rewrite_nonce() -> u64 {
|
||||
REWRITE_NONCE.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Enter feedback mode: visual change to prompt bar (teal accent, pencil prefix).
|
||||
/// No side effects — the user types feedback text and presses Enter to send.
|
||||
pub(super) fn dispatch_enter_feedback_mode(app: &mut AppView) -> Vec<Effect> {
|
||||
with_active_agent(app, |agent| {
|
||||
agent.prompt_input_mode = PromptInputMode::Feedback;
|
||||
agent.prompt.set_text("");
|
||||
});
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Enter remember mode: visual change to prompt bar (remember accent, `#` prefix).
|
||||
/// No side effects — the user types a memory note and presses Enter to send.
|
||||
pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> {
|
||||
@@ -38,47 +28,6 @@ pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Send feedback text to the server. Shows a thank-you message immediately
|
||||
/// and fires the HTTP POST as a background effect.
|
||||
pub(super) fn dispatch_send_feedback(app: &mut AppView, text: String) -> Vec<Effect> {
|
||||
let ActiveView::Agent(id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(agent) = app.agents.get_mut(&id) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.prompt_input_mode = PromptInputMode::Normal;
|
||||
agent.prompt.set_text("");
|
||||
// Submitting feedback retires any edit-contextual ephemeral tip.
|
||||
agent.ephemeral_tip.clear_on_submit();
|
||||
|
||||
let trimmed = text.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
agent.scrollback.push_block(RenderBlock::system(
|
||||
"Please provide feedback text.".to_string(),
|
||||
));
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let Some(session_id) = agent.session.session_id.clone() else {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system("No active session.".to_string()));
|
||||
return vec![];
|
||||
};
|
||||
|
||||
agent.scrollback.push_block(RenderBlock::system(
|
||||
"Thanks for the feedback! The Kigi team is on it.".to_string(),
|
||||
));
|
||||
|
||||
vec![Effect::SendFeedback {
|
||||
agent_id: id,
|
||||
session_id,
|
||||
feedback_text: trimmed,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Send a raw remember note for LLM-powered rewriting via `kigi/memory/rewrite`.
|
||||
/// Clears remember mode and prompts the LLM to reformat the note with session
|
||||
/// context. Falls back to direct `SaveMemoryNote` when no session is available.
|
||||
|
||||
@@ -33,8 +33,7 @@ use super::modes::{
|
||||
set_permission_mode, set_plan_mode, set_yolo_mode,
|
||||
};
|
||||
use super::notes::{
|
||||
dispatch_enter_feedback_mode, dispatch_enter_remember_mode,
|
||||
dispatch_save_remember_note_from_modal, dispatch_send_btw, dispatch_send_feedback,
|
||||
dispatch_enter_remember_mode, dispatch_save_remember_note_from_modal, dispatch_send_btw,
|
||||
dispatch_send_recap, dispatch_send_remember_note,
|
||||
};
|
||||
use super::permissions::{
|
||||
@@ -784,8 +783,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
Action::ShowPlan => dispatch_show_plan(app),
|
||||
Action::EnterPlanMode { description } => dispatch_enter_plan_mode(app, description),
|
||||
Action::SetPlanMode(kind) => set_plan_mode(app, kind),
|
||||
Action::EnterFeedbackMode => dispatch_enter_feedback_mode(app),
|
||||
Action::SendFeedback(text) => dispatch_send_feedback(app, text),
|
||||
Action::EnterRememberMode => dispatch_enter_remember_mode(app),
|
||||
Action::SendRememberNote(text) => dispatch_send_remember_note(app, text),
|
||||
Action::SaveRememberNoteFromModal => dispatch_save_remember_note_from_modal(app),
|
||||
|
||||
@@ -442,11 +442,6 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::ChangelogFetched { markdown, entries } => {
|
||||
app.changelog_markdown = markdown;
|
||||
app.changelog_bullets = kigi_shell::util::changelog::bullets_from_entries(&entries, 3);
|
||||
vec![]
|
||||
}
|
||||
TaskResult::ClipboardAttachmentProbed {
|
||||
ctx,
|
||||
image,
|
||||
@@ -663,17 +658,6 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::FeedbackComplete { .. } => vec![],
|
||||
TaskResult::FeedbackFailed { agent_id, error } => {
|
||||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::system(format!(
|
||||
"Couldn't send feedback: {error}"
|
||||
)));
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::MemoryNoteSaved { agent_id, result } => {
|
||||
handle_memory_note_saved(app, agent_id, result)
|
||||
}
|
||||
|
||||
@@ -85,8 +85,6 @@ fn test_app() -> AppView {
|
||||
pending_notification_escapes: None,
|
||||
deferred_notification: None,
|
||||
tracing_rx: None,
|
||||
changelog_markdown: None,
|
||||
changelog_bullets: Vec::new(),
|
||||
tips: Vec::new(),
|
||||
tip: None,
|
||||
cli_model_override: None,
|
||||
@@ -143,7 +141,6 @@ fn test_app() -> AppView {
|
||||
welcome_tip_typing_dismissed: false,
|
||||
welcome_menu_index: None,
|
||||
welcome_menu_rects: Vec::new(),
|
||||
welcome_show_changelog_action: false,
|
||||
welcome_import_banner_rect: None,
|
||||
last_mouse_pos: None,
|
||||
last_scroll_pos: None,
|
||||
@@ -151,9 +148,7 @@ fn test_app() -> AppView {
|
||||
welcome_prompt_rect: None,
|
||||
welcome_auth_url_rect: None,
|
||||
welcome_on_auth_url: false,
|
||||
welcome_on_changelog_cta: false,
|
||||
welcome_auth_fallback_rect: None,
|
||||
welcome_changelog_cta_rect: None,
|
||||
auth_show_raw_url: false,
|
||||
auth_mouse_disabled: false,
|
||||
session_picker_entries: None,
|
||||
|
||||
@@ -32,23 +32,6 @@ fn seed_foreign_resume_hint(
|
||||
}),
|
||||
);
|
||||
}
|
||||
/// Sending feedback is a submit: it retires the active ephemeral tip.
|
||||
#[test]
|
||||
fn send_feedback_clears_active_ephemeral_tip() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let agent = app.agents.get_mut(&id).unwrap();
|
||||
let _ = agent.ephemeral_tip.show(
|
||||
crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint")),
|
||||
&mut std::collections::HashMap::new(),
|
||||
);
|
||||
assert!(agent.ephemeral_tip.is_active());
|
||||
let _ = dispatch(Action::SendFeedback("it broke".into()), &mut app);
|
||||
assert!(
|
||||
!app.agents.get(&id).unwrap().ephemeral_tip.is_active(),
|
||||
"feedback submit must clear the tip"
|
||||
);
|
||||
}
|
||||
/// Sending a remember note is a submit: it retires the active ephemeral tip.
|
||||
#[test]
|
||||
fn send_remember_note_clears_active_ephemeral_tip() {
|
||||
|
||||
@@ -1712,27 +1712,6 @@ pub(crate) fn execute(
|
||||
TaskResult::PromptImagePreviewPrepared
|
||||
});
|
||||
}
|
||||
Effect::FetchChangelog => {
|
||||
tasks
|
||||
.spawn(async move {
|
||||
let changelog = tokio::task::spawn_blocking(|| {
|
||||
kigi_shell::util::changelog::ChangelogManager::new()
|
||||
.fetch()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = % e, "changelog fetch task failed");
|
||||
kigi_shell::util::changelog::Changelog {
|
||||
markdown: None,
|
||||
entries: None,
|
||||
}
|
||||
});
|
||||
TaskResult::ChangelogFetched {
|
||||
markdown: changelog.markdown,
|
||||
entries: changelog.entries.unwrap_or_default(),
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::PersistMemoryFullscreen { fullscreen } => {
|
||||
persist_hint(
|
||||
tasks,
|
||||
@@ -2599,61 +2578,6 @@ pub(crate) fn execute(
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::SendFeedback { agent_id, session_id, feedback_text } => {
|
||||
use kigi_shell::session::ClientType;
|
||||
use kigi_shell::session::acp_types::ClientFeedbackInput;
|
||||
let terminal_info = Some(
|
||||
crate::terminal::terminal_context().feedback_info(),
|
||||
);
|
||||
let tx = acp_tx.clone();
|
||||
tasks
|
||||
.spawn(async move {
|
||||
let input = ClientFeedbackInput {
|
||||
session_id: session_id.0.to_string(),
|
||||
client_type: ClientType::Tui,
|
||||
rating_type: None,
|
||||
rating_value: None,
|
||||
feedback_text: Some(feedback_text),
|
||||
feedback_categories: vec![],
|
||||
context_type: None,
|
||||
turn_number: None,
|
||||
request_id: None,
|
||||
client_version: Some(kigi_version::VERSION.to_string()),
|
||||
metadata: None,
|
||||
terminal_info,
|
||||
};
|
||||
let raw_params = match serde_json::value::to_raw_value(&input) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return TaskResult::FeedbackFailed {
|
||||
agent_id,
|
||||
error: sanitize_user_error(
|
||||
&format!("couldn't serialize feedback: {e}"),
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
let request = acp::ExtRequest::new(
|
||||
"kigi/feedback",
|
||||
raw_params.into(),
|
||||
);
|
||||
match acp_send(request, &tx).await {
|
||||
Ok(_) => {
|
||||
TaskResult::FeedbackComplete {
|
||||
agent_id,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
TaskResult::FeedbackFailed {
|
||||
agent_id,
|
||||
error: sanitize_user_error(
|
||||
&format!("couldn't send feedback: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Effect::RewriteMemoryNote {
|
||||
agent_id,
|
||||
session_id,
|
||||
|
||||
@@ -1136,12 +1136,6 @@ pub(crate) async fn run(
|
||||
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
|
||||
return Ok(make_run_result(&app));
|
||||
}
|
||||
// Fetch changelog off the render path so the welcome screen
|
||||
// can display bullets and /release-notes uses the cached result.
|
||||
let effs = vec![super::actions::Effect::FetchChangelog];
|
||||
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
|
||||
return Ok(make_run_result(&app));
|
||||
}
|
||||
}
|
||||
|
||||
if !post_render_effects.is_empty()
|
||||
|
||||
@@ -169,8 +169,7 @@ impl AgentView {
|
||||
.map(str::to_owned)
|
||||
{
|
||||
self.prompt.history_search.deactivate();
|
||||
if self.prompt_input_mode != PromptInputMode::Feedback
|
||||
&& self.prompt_input_mode != PromptInputMode::Remember
|
||||
if self.prompt_input_mode != PromptInputMode::Remember
|
||||
&& let Some(cmd) = text.strip_prefix("! ")
|
||||
{
|
||||
self.prompt_input_mode = PromptInputMode::Bash;
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
//! `/feedback` -- send session feedback.
|
||||
//! `/feedback` -- open the Kigi GitHub issues page.
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
/// Send session feedback inline or enter feedback mode.
|
||||
/// Where feedback goes: the project's own issue tracker. Kigi is a community
|
||||
/// build, so its feedback belongs on its GitHub repo — mirroring the official
|
||||
/// kimi-cli, whose `/feedback` opens its repo's issues page.
|
||||
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
|
||||
|
||||
/// Open the Kigi issue tracker in the browser.
|
||||
pub struct FeedbackCommand;
|
||||
|
||||
impl SlashCommand for FeedbackCommand {
|
||||
@@ -12,27 +17,75 @@ impl SlashCommand for FeedbackCommand {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Send feedback about the current session"
|
||||
"Report feedback on the Kigi GitHub issues page"
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/feedback [text]"
|
||||
"/feedback"
|
||||
}
|
||||
|
||||
fn takes_args(&self) -> bool {
|
||||
true
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
||||
CommandResult::Action(Action::OpenUrl(FEEDBACK_ISSUES_URL.into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn arg_placeholder(&self) -> Option<&str> {
|
||||
Some("[feedback text]")
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::acp::model_state::ModelState;
|
||||
|
||||
static DEFAULT_BUNDLE_STATE: crate::app::bundle::BundleState =
|
||||
crate::app::bundle::BundleState {
|
||||
has_cache: false,
|
||||
version: String::new(),
|
||||
personas: Vec::new(),
|
||||
roles: Vec::new(),
|
||||
agents: Vec::new(),
|
||||
skills: Vec::new(),
|
||||
persona_details: Vec::new(),
|
||||
role_details: Vec::new(),
|
||||
};
|
||||
|
||||
fn make_ctx<'a>(models: &'a ModelState) -> CommandExecCtx<'a> {
|
||||
CommandExecCtx {
|
||||
models,
|
||||
session_id: None,
|
||||
bundle_state: &DEFAULT_BUNDLE_STATE,
|
||||
screen_mode: crate::app::ScreenMode::Inline,
|
||||
pager_state: crate::settings::PagerLocalSnapshot {
|
||||
multiline_mode: false,
|
||||
yolo_mode: false,
|
||||
..crate::settings::PagerLocalSnapshot::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
|
||||
let trimmed = args.trim();
|
||||
if trimmed.is_empty() {
|
||||
CommandResult::Action(Action::EnterFeedbackMode)
|
||||
} else {
|
||||
CommandResult::Action(Action::SendFeedback(trimmed.to_string()))
|
||||
#[test]
|
||||
fn feedback_opens_github_issues() {
|
||||
let models = ModelState::default();
|
||||
let mut ctx = make_ctx(&models);
|
||||
match FeedbackCommand.run(&mut ctx, "") {
|
||||
CommandResult::Action(Action::OpenUrl(url)) => {
|
||||
assert_eq!(url, FEEDBACK_ISSUES_URL);
|
||||
}
|
||||
other => panic!("expected OpenUrl, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_ignores_stray_args() {
|
||||
let models = ModelState::default();
|
||||
let mut ctx = make_ctx(&models);
|
||||
assert!(matches!(
|
||||
FeedbackCommand.run(&mut ctx, "some typed text"),
|
||||
CommandResult::Action(Action::OpenUrl(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_metadata() {
|
||||
let cmd = FeedbackCommand;
|
||||
assert_eq!(cmd.name(), "feedback");
|
||||
assert!(!cmd.takes_args());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod queue;
|
||||
pub mod recap;
|
||||
pub mod release_notes;
|
||||
pub mod remember;
|
||||
pub mod rename;
|
||||
pub mod resume;
|
||||
@@ -121,7 +120,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
|
||||
Arc::new(usage::UsageCommand),
|
||||
Arc::new(queue::QueueCommand),
|
||||
Arc::new(tasks::TasksCommand),
|
||||
Arc::new(release_notes::ReleaseNotesCommand),
|
||||
Arc::new(config_agents::ConfigAgentsCommand),
|
||||
Arc::new(personas::PersonasCommand),
|
||||
Arc::new(gboom::GboomCommand),
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
//! `/release-notes` -- view release notes for the current version.
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
/// Show release notes for the current pager version.
|
||||
pub struct ReleaseNotesCommand;
|
||||
|
||||
impl SlashCommand for ReleaseNotesCommand {
|
||||
fn name(&self) -> &str {
|
||||
"release-notes"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &[&str] {
|
||||
&["changelog"]
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"View release notes for the current version"
|
||||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/release-notes"
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
|
||||
let changelog = kigi_shell::util::changelog::ChangelogManager::new().fetch();
|
||||
match changelog.markdown {
|
||||
Some(content) => CommandResult::Action(Action::ShowReleaseNotes {
|
||||
title: "Release Notes".to_string(),
|
||||
content: content.trim().to_string(),
|
||||
}),
|
||||
None => CommandResult::Error("No release notes available (offline).".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn release_notes_metadata() {
|
||||
let cmd = ReleaseNotesCommand;
|
||||
assert_eq!(cmd.name(), "release-notes");
|
||||
assert_eq!(cmd.aliases(), &["changelog"]);
|
||||
assert!(!cmd.takes_args());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_notes_returns_action_or_error() {
|
||||
let models = crate::acp::model_state::ModelState::default();
|
||||
let mut ctx = super::super::tests::make_ctx(&models);
|
||||
let result = ReleaseNotesCommand.run(&mut ctx, "");
|
||||
assert!(
|
||||
matches!(result, CommandResult::Action(_) | CommandResult::Error(_)),
|
||||
"expected Action or Error, got {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -282,6 +282,75 @@ pub fn goal_status_line(
|
||||
])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph status chip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the compact `/graph` status chip: node progress, the current
|
||||
/// node, and spend. Same chip idiom as [`goal_status_line`] — dim
|
||||
/// brackets, paused chips invert onto `theme.warning`, active chips
|
||||
/// animate.
|
||||
pub fn graph_status_line(
|
||||
graph: &crate::app::agent::GraphDisplayState,
|
||||
theme: &Theme,
|
||||
tick: usize,
|
||||
) -> Line<'static> {
|
||||
let dim_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
|
||||
let label_style = if graph.status.is_paused() {
|
||||
Style::default().fg(theme.bg_base).bg(theme.warning)
|
||||
} else {
|
||||
Style::default().fg(theme.accent_plan).bg(theme.bg_base)
|
||||
};
|
||||
let is_active = matches!(graph.status, GoalDisplayStatus::Active);
|
||||
|
||||
let mut progress = format!("{}/{}", graph.achieved_nodes, graph.total_nodes);
|
||||
if graph.failed_nodes > 0 {
|
||||
progress.push_str(&format!(" ({} failed)", graph.failed_nodes));
|
||||
}
|
||||
// Planner titles are uncapped; clamp so one long title can't push
|
||||
// the whole status bar off-screen.
|
||||
let clamped_title = graph.current_node_title.as_deref().map(|t| {
|
||||
if t.chars().count() > 40 {
|
||||
let head: String = t.chars().take(39).collect();
|
||||
format!("{head}…")
|
||||
} else {
|
||||
t.to_owned()
|
||||
}
|
||||
});
|
||||
let label = match (&graph.status, clamped_title.as_deref()) {
|
||||
(GoalDisplayStatus::Active, Some(title)) => format!("{progress} · {title}"),
|
||||
(GoalDisplayStatus::Active, None) if graph.running_nodes > 1 => {
|
||||
format!("{progress} · {} nodes in flight", graph.running_nodes)
|
||||
}
|
||||
(GoalDisplayStatus::Complete, _) => format!("{progress} · complete"),
|
||||
(GoalDisplayStatus::BudgetLimited, _) => format!("{progress} · budget limit"),
|
||||
(status, _) if status.is_paused() => format!("{progress} · paused"),
|
||||
_ => progress,
|
||||
};
|
||||
let graph_text = if is_active {
|
||||
let frames = crate::glyphs::dot_spinner_frames();
|
||||
let frame = frames[(tick / 4) % frames.len()];
|
||||
format!("{frame} Graph: {label}")
|
||||
} else {
|
||||
format!("Graph: {label}")
|
||||
};
|
||||
|
||||
let tokens_str = format_tokens_compact(graph.tokens_spent.max(0));
|
||||
let tokens_display = match graph.token_budget {
|
||||
Some(budget) if budget > 0 => {
|
||||
format!("{}/{} tokens", tokens_str, format_tokens_compact(budget))
|
||||
}
|
||||
_ => format!("{tokens_str} tokens"),
|
||||
};
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled("[", dim_style),
|
||||
Span::styled(graph_text, label_style),
|
||||
Span::styled("]", dim_style),
|
||||
Span::styled(format!(" {tokens_display}"), dim_style),
|
||||
])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP connecting indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -174,7 +174,7 @@ pub struct PromptStyle {
|
||||
pub prefix_override: Option<(&'static str, ratatui::style::Color)>,
|
||||
/// Override the placeholder text shown when the textarea is empty.
|
||||
/// When `Some(text)`, uses this instead of the default `"Build anything"`.
|
||||
/// Used for feedback mode (`"Type your feedback..."`).
|
||||
/// Used for remember mode (`"Save a memory note..."`).
|
||||
pub placeholder_override: Option<&'static str>,
|
||||
/// Compact mode (currently unused for info_block sizing).
|
||||
pub compact: bool,
|
||||
|
||||
@@ -351,13 +351,13 @@ pub fn render_turn_status(
|
||||
|
||||
// ── Build components ──
|
||||
// While a tool is blocked on a permission prompt or `ask_user_question`,
|
||||
// swap the running braille spinner for a pulsing `◆`. Same animation
|
||||
// shape the drain-blocked and plan-approval indicators already use,
|
||||
// so every "your turn" status reads with one consistent visual cue.
|
||||
// swap the spinning moon for a pulsing `◆`. Same animation shape the
|
||||
// drain-blocked and plan-approval indicators already use, so every
|
||||
// "your turn" status reads with one consistent visual cue.
|
||||
let spinner_str = if is_pending_user_input {
|
||||
format!("{} ", crate::glyphs::diamond_filled())
|
||||
} else {
|
||||
let frames = crate::glyphs::braille_spinner_frames();
|
||||
let frames = crate::glyphs::moon_spinner_frames();
|
||||
let frame_idx = (tick / SPINNER_DIVISOR) as usize % frames.len();
|
||||
format!("{} ", frames[frame_idx])
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Hero box component — side-by-side logo + menu inside a bordered box.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Constraint, Flex, Layout, Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::layout::{Constraint, Flex, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
||||
|
||||
@@ -27,18 +27,11 @@ const HERO_SUBTITLE: &str = "Thanks for trying Kigi, give feedback with /feedbac
|
||||
|
||||
use super::{PROMPT_HEIGHT, VERSION_GAP};
|
||||
|
||||
/// Rows the "thanks" subtitle occupies. Hidden when the in-box info slot
|
||||
/// (changelog) is shown, to keep the box compact.
|
||||
fn subtitle_rows(info_height: u16) -> u16 {
|
||||
if info_height > 0 { 0 } else { 1 }
|
||||
}
|
||||
|
||||
/// Height of the hero box's right column: version + optional subtitle +
|
||||
/// optional info block + the gap before the menu + the menu itself.
|
||||
fn right_col_height(menu_height: u16, info_height: u16) -> u16 {
|
||||
let info_gap = if info_height > 0 { 1u16 } else { 0 };
|
||||
// version(1) + subtitle + [info_gap + info] + gap-before-menu(1) + menu
|
||||
1 + subtitle_rows(info_height) + info_gap + info_height + 1 + menu_height
|
||||
/// Height of the hero box's right column: version + subtitle + the gap
|
||||
/// before the menu + the menu itself.
|
||||
fn right_col_height(menu_height: u16) -> u16 {
|
||||
// version(1) + subtitle(1) + gap-before-menu(1) + menu
|
||||
3 + menu_height
|
||||
}
|
||||
|
||||
/// Minimum content-area height the hero box needs to render without truncating:
|
||||
@@ -46,13 +39,8 @@ fn right_col_height(menu_height: u16, info_height: u16) -> u16 {
|
||||
/// below (tip + prompt + version). The box always shows the full-height logo,
|
||||
/// so a terminal shorter than this falls back to the stacked layout instead of
|
||||
/// overflowing.
|
||||
pub(super) fn min_content_height(
|
||||
error_height: u16,
|
||||
menu_height: u16,
|
||||
tip_height: u16,
|
||||
info_height: u16,
|
||||
) -> u16 {
|
||||
let inner = super::logo::full_logo_line_count().max(right_col_height(menu_height, info_height));
|
||||
pub(super) fn min_content_height(error_height: u16, menu_height: u16, tip_height: u16) -> u16 {
|
||||
let inner = super::logo::full_logo_line_count().max(right_col_height(menu_height));
|
||||
let hero_box_height = 2 + V_PAD * 2 + inner;
|
||||
let gap_after_error = if error_height > 0 { 1u16 } else { 0 };
|
||||
gap_after_error + error_height + hero_box_height + 1 + WelcomeLayout::fixed_below(tip_height)
|
||||
@@ -70,33 +58,26 @@ fn left_col_width() -> u16 {
|
||||
}
|
||||
|
||||
/// Compute the hero box layout: bordered box with logo left, version + menu right.
|
||||
///
|
||||
/// Sizes the in-box info slot here (the fixed `changelog_height`) so the
|
||||
/// renderer just draws into `hero_info`.
|
||||
pub(super) fn compute_hero_box(
|
||||
content_area: Rect,
|
||||
error_height: u16,
|
||||
menu_height: u16,
|
||||
tip_height: u16,
|
||||
changelog_height: u16,
|
||||
) -> WelcomeLayout {
|
||||
let zero = Rect::default();
|
||||
let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
|
||||
let fixed_below = WelcomeLayout::fixed_below(tip_height);
|
||||
|
||||
// Column widths are height-independent, so derive them once and reuse for
|
||||
// both the measurement and the rects: `hero_info.width == info_slot_width`,
|
||||
// i.e. measured == drawn.
|
||||
// both the measurement and the rects.
|
||||
let box_width = content_area.width.saturating_sub(6).min(120);
|
||||
let inner_width = box_width.saturating_sub(2);
|
||||
let left_col_width = left_col_width();
|
||||
let right_width = inner_width.saturating_sub(left_col_width);
|
||||
let info_slot_width = right_width.saturating_sub(H_INSET);
|
||||
let info_height = changelog_height;
|
||||
let menu_slot_width = right_width.saturating_sub(H_INSET);
|
||||
|
||||
let logo_rows = super::logo::full_logo_line_count();
|
||||
let info_gap = if info_height > 0 { 1u16 } else { 0 };
|
||||
let inner_height = logo_rows.max(right_col_height(menu_height, info_height));
|
||||
let inner_height = logo_rows.max(right_col_height(menu_height));
|
||||
let hero_box_height = 2 + V_PAD * 2 + inner_height;
|
||||
|
||||
let gap_after_error = if error_height > 0 { 1 } else { 0 };
|
||||
@@ -105,7 +86,7 @@ pub(super) fn compute_hero_box(
|
||||
// Top padding for vertical centering (use the default menu height so the
|
||||
// logo position stays constant regardless of picker/focus state).
|
||||
let default_menu_height = 4u16;
|
||||
let default_inner = logo_rows.max(right_col_height(default_menu_height, info_height));
|
||||
let default_inner = logo_rows.max(right_col_height(default_menu_height));
|
||||
let default_hero = 2 + V_PAD * 2 + default_inner;
|
||||
let remaining = content_area.height.saturating_sub(fixed_above);
|
||||
let top_pad = remaining
|
||||
@@ -190,39 +171,22 @@ pub(super) fn compute_hero_box(
|
||||
height: 1,
|
||||
};
|
||||
|
||||
// Subtitle line below version — hidden when the info slot is shown.
|
||||
let hero_subtitle = if subtitle_rows(info_height) > 0 {
|
||||
Rect {
|
||||
x: right_x,
|
||||
y: inner.y + 1,
|
||||
width: right_width,
|
||||
height: 1,
|
||||
}
|
||||
} else {
|
||||
zero
|
||||
// Subtitle line below version.
|
||||
let hero_subtitle = Rect {
|
||||
x: right_x,
|
||||
y: inner.y + 1,
|
||||
width: right_width,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
// Info block (changelog) below version + optional subtitle.
|
||||
let info_y = inner.y + 1 + subtitle_rows(info_height) + info_gap;
|
||||
let hero_info = if info_height > 0 {
|
||||
Rect {
|
||||
x: right_x,
|
||||
y: info_y,
|
||||
width: info_slot_width,
|
||||
height: info_height,
|
||||
}
|
||||
} else {
|
||||
zero
|
||||
};
|
||||
|
||||
// version + subtitle + info_gap + info + gap-before-menu
|
||||
let right_header_rows = 1 + subtitle_rows(info_height) + info_gap + info_height + 1;
|
||||
// version + subtitle + gap-before-menu
|
||||
let right_header_rows = 3;
|
||||
|
||||
// Menu below the header rows, left-aligned in right column.
|
||||
let hero_menu = Rect {
|
||||
x: right_x,
|
||||
y: inner.y + right_header_rows,
|
||||
width: info_slot_width,
|
||||
width: menu_slot_width,
|
||||
height: menu_height.min(inner.height.saturating_sub(right_header_rows)),
|
||||
};
|
||||
|
||||
@@ -230,7 +194,6 @@ pub(super) fn compute_hero_box(
|
||||
logo: zero,
|
||||
error,
|
||||
menu: zero,
|
||||
changelog: zero,
|
||||
tip,
|
||||
prompt,
|
||||
version: version_slot,
|
||||
@@ -238,26 +201,12 @@ pub(super) fn compute_hero_box(
|
||||
hero_logo,
|
||||
hero_version,
|
||||
hero_subtitle,
|
||||
hero_info,
|
||||
hero_menu,
|
||||
}
|
||||
}
|
||||
|
||||
/// Changelog content shown in the hero box info slot.
|
||||
pub(super) struct ChangelogDisplay<'a> {
|
||||
pub(super) bullets: &'a [String],
|
||||
pub(super) has_full_notes: bool,
|
||||
}
|
||||
|
||||
/// Hit-test rects produced by [`render_hero_box`].
|
||||
pub(super) struct HeroBoxRects {
|
||||
/// Hit-test rect per menu item row (for click/hover).
|
||||
pub(super) menu_rects: Vec<Rect>,
|
||||
/// Clickable changelog info block, if drawn.
|
||||
pub(super) changelog_cta_rect: Option<Rect>,
|
||||
}
|
||||
|
||||
/// Render the bordered hero box with logo left, version + subtitle + menu right.
|
||||
/// Returns the hit-test rect per menu item row (for click/hover).
|
||||
pub(super) fn render_hero_box(
|
||||
layout: &WelcomeLayout,
|
||||
buf: &mut Buffer,
|
||||
@@ -265,8 +214,7 @@ pub(super) fn render_hero_box(
|
||||
menu_items: &[(&str, &str)],
|
||||
selected: Option<usize>,
|
||||
mouse_pos: Option<(u16, u16)>,
|
||||
changelog: ChangelogDisplay<'_>,
|
||||
) -> HeroBoxRects {
|
||||
) -> Vec<Rect> {
|
||||
// Dim the box border toward the background for a softer, dimmer gray.
|
||||
let border_color = crate::render::color::blend_color(theme.bg_base, theme.gray_dim, 0.45)
|
||||
.unwrap_or(theme.gray_dim);
|
||||
@@ -289,30 +237,15 @@ pub(super) fn render_hero_box(
|
||||
);
|
||||
|
||||
// Subtitle line below the version.
|
||||
if layout.hero_subtitle.height > 0 {
|
||||
let subtitle_style = Style::default().fg(theme.gray);
|
||||
buf.set_span(
|
||||
layout.hero_subtitle.x,
|
||||
layout.hero_subtitle.y,
|
||||
&Span::styled(HERO_SUBTITLE, subtitle_style),
|
||||
layout.hero_subtitle.width,
|
||||
);
|
||||
}
|
||||
let subtitle_style = Style::default().fg(theme.gray);
|
||||
buf.set_span(
|
||||
layout.hero_subtitle.x,
|
||||
layout.hero_subtitle.y,
|
||||
&Span::styled(HERO_SUBTITLE, subtitle_style),
|
||||
layout.hero_subtitle.width,
|
||||
);
|
||||
|
||||
// In-box info slot: the changelog, always in this same position.
|
||||
let mut changelog_cta_rect = None;
|
||||
if layout.hero_info.height > 0 && !changelog.bullets.is_empty() {
|
||||
changelog_cta_rect = render_hero_changelog(
|
||||
buf,
|
||||
theme,
|
||||
layout.hero_info,
|
||||
changelog.bullets,
|
||||
changelog.has_full_notes,
|
||||
mouse_pos,
|
||||
);
|
||||
}
|
||||
|
||||
let menu_rects = super::menu::render_menu(
|
||||
super::menu::render_menu(
|
||||
layout.hero_menu,
|
||||
buf,
|
||||
theme,
|
||||
@@ -320,58 +253,5 @@ pub(super) fn render_hero_box(
|
||||
selected,
|
||||
mouse_pos,
|
||||
layout.hero_menu.width,
|
||||
);
|
||||
HeroBoxRects {
|
||||
menu_rects,
|
||||
changelog_cta_rect,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the changelog block (header + bullets) in the info slot. When
|
||||
/// `clickable` (full notes exist), the whole block opens the notes on click and
|
||||
/// brightens while hovered; returns that clickable rect.
|
||||
fn render_hero_changelog(
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
area: Rect,
|
||||
bullets: &[String],
|
||||
clickable: bool,
|
||||
mouse_pos: Option<(u16, u16)>,
|
||||
) -> Option<Rect> {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hovered =
|
||||
clickable && mouse_pos.is_some_and(|(mx, my)| area.contains(Position::new(mx, my)));
|
||||
|
||||
let header_style = super::hover_style(
|
||||
theme,
|
||||
hovered,
|
||||
Style::default()
|
||||
.fg(theme.gray_bright)
|
||||
.add_modifier(Modifier::DIM),
|
||||
);
|
||||
let title = "Changelog";
|
||||
buf.set_span(
|
||||
area.x,
|
||||
area.y,
|
||||
&Span::styled(title, header_style),
|
||||
area.width,
|
||||
);
|
||||
|
||||
// Bullets start 2 rows down (header + blank), matching the height budget.
|
||||
let bullet_style = super::hover_style(theme, hovered, Style::default().fg(theme.gray_bright));
|
||||
let max_text_width = area.width.saturating_sub(4) as usize; // " • " prefix + pad
|
||||
for (i, bullet) in bullets.iter().enumerate() {
|
||||
let row = area.y + 2 + i as u16;
|
||||
if row >= area.y + area.height {
|
||||
break;
|
||||
}
|
||||
let truncated = crate::render::line_utils::truncate_str(bullet, max_text_width);
|
||||
let text = format!(" \u{2022} {truncated}");
|
||||
buf.set_span(area.x, row, &Span::styled(text, bullet_style), area.width);
|
||||
}
|
||||
|
||||
clickable.then_some(area)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//! phase model: the terminator is the ellipse `x = cos(2πp)·√(1−y²)`, with
|
||||
//! sunlight arriving from the right while waxing and from the left while
|
||||
//! waning. The dark limb keeps a faint outline ring so the silhouette never
|
||||
//! disappears at new moon.
|
||||
//! disappears at new moon. A fixed map of lunar maria textures the disc:
|
||||
//! dark blotches on the sunlit side, faint gray patches on the dark limb.
|
||||
//!
|
||||
//! Hidden entirely on legacy Windows consoles: the U+2800 braille block is
|
||||
//! not covered by the ConHost raster fonts and would render as tofu.
|
||||
@@ -52,6 +53,30 @@ const PULSE_SECS: f32 = 5.0;
|
||||
/// Squared inner radius (normalized) of the dark-limb outline ring.
|
||||
const RING_INNER_SQ: f32 = 0.82;
|
||||
|
||||
/// Lunar maria as `(cx, cy, radius²)` in normalized disc coordinates
|
||||
/// (x right, y down), loosely after the near side's real maria. On the
|
||||
/// sunlit disc a mare dot is drawn in the resting gray (a dark blotch);
|
||||
/// on the dark limb it is drawn in the same gray, which reads as a faint
|
||||
/// light patch against the empty limb.
|
||||
const MARIA: &[(f32, f32, f32)] = &[
|
||||
(-0.40, -0.42, 0.018), // Imbrium
|
||||
(0.12, -0.50, 0.008), // Serenitatis
|
||||
(0.40, -0.22, 0.012), // Tranquillitatis
|
||||
(0.55, 0.20, 0.005), // Fecunditatis
|
||||
(0.62, -0.42, 0.004), // Crisium
|
||||
(-0.58, 0.05, 0.010), // Procellarum
|
||||
(-0.28, 0.40, 0.006), // Nubium
|
||||
(0.08, 0.12, 0.004), // Vaporum
|
||||
];
|
||||
|
||||
fn in_mare(dx: f32, dy: f32) -> bool {
|
||||
MARIA.iter().any(|&(mx, my, r_sq)| {
|
||||
let ex = dx - mx;
|
||||
let ey = dy - my;
|
||||
ex * ex + ey * ey <= r_sq
|
||||
})
|
||||
}
|
||||
|
||||
/// One logo size tier, in braille cells.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
struct MoonSize {
|
||||
@@ -152,8 +177,8 @@ fn moon_cells(size: MoonSize, p: f32) -> Vec<Vec<Option<MoonCell>>> {
|
||||
.map(|cell_row| {
|
||||
(0..size.cols as i32)
|
||||
.map(|cell_col| {
|
||||
let mut mask = 0u32;
|
||||
let mut lit = false;
|
||||
let mut lit_mask = 0u32;
|
||||
let mut dark_mask = 0u32;
|
||||
for (dot_col, col_bits) in DOT_BITS.iter().enumerate() {
|
||||
for (dot_row, bit) in col_bits.iter().enumerate() {
|
||||
let x = cell_col * 2 + dot_col as i32;
|
||||
@@ -164,15 +189,28 @@ fn moon_cells(size: MoonSize, p: f32) -> Vec<Vec<Option<MoonCell>>> {
|
||||
if d_sq > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let mare = in_mare(dx, dy);
|
||||
if dot_lit(dx, dy, p) {
|
||||
mask |= bit;
|
||||
lit = true;
|
||||
} else if d_sq >= RING_INNER_SQ {
|
||||
// Dark limb: keep the outline ring visible.
|
||||
mask |= bit;
|
||||
if mare {
|
||||
// Dark blotch on the sunlit disc.
|
||||
dark_mask |= bit;
|
||||
} else {
|
||||
lit_mask |= bit;
|
||||
}
|
||||
} else if mare || d_sq >= RING_INNER_SQ {
|
||||
// Dark limb: outline ring plus faint maria.
|
||||
dark_mask |= bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A braille cell holds a single color, so a cell with any
|
||||
// sunlit dots renders only those (mare dots in it stay
|
||||
// background-dark); otherwise its dark dots render gray.
|
||||
let (mask, lit) = if lit_mask != 0 {
|
||||
(lit_mask, true)
|
||||
} else {
|
||||
(dark_mask, false)
|
||||
};
|
||||
(mask != 0).then(|| MoonCell {
|
||||
ch: char::from_u32(0x2800 + mask).expect("braille block"),
|
||||
lit,
|
||||
@@ -407,6 +445,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maria_texture_the_disc_in_both_extremes() {
|
||||
// Full moon: mare dots stay dark, so the drawn glyphs must cover
|
||||
// fewer dots than the geometric disc (lit_dots ignores maria).
|
||||
let full = moon_cells(FULL, 0.5);
|
||||
let drawn_dots: u32 = full
|
||||
.iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|c| (c.ch as u32 - 0x2800).count_ones())
|
||||
.sum();
|
||||
assert!(
|
||||
(drawn_dots as usize) < lit_dots(0.5),
|
||||
"full moon must keep dark maria holes"
|
||||
);
|
||||
// New moon: maria show as drawn (gray) cells well inside the outline
|
||||
// ring — Procellarum sits around cell (5, 4) on the full-size grid.
|
||||
let new = moon_cells(FULL, 0.0);
|
||||
assert!(
|
||||
new[5][4].is_some(),
|
||||
"new moon must show maria inside the ring"
|
||||
);
|
||||
assert!(in_mare(-0.58, 0.05), "Procellarum anchors the maria map");
|
||||
assert!(!in_mare(0.0, 0.85), "south pole stays mare-free");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moon_raster_is_round_and_fills_the_grid() {
|
||||
// The disc must span (nearly) the whole cell grid in both axes at
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! - Bottom margin
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Position, Rect};
|
||||
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
|
||||
@@ -56,22 +56,12 @@ fn quit_hint_spans(theme: &Theme) -> Vec<Span<'static>> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Style for a clickable welcome block: bright primary while `hovered`, else
|
||||
/// `base`. Shared by the changelog renderer.
|
||||
pub(super) fn hover_style(theme: &Theme, hovered: bool, base: Style) -> Style {
|
||||
if hovered {
|
||||
Style::default().fg(theme.text_primary)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal margin (left and right) in normal mode.
|
||||
const H_MARGIN: u16 = 2;
|
||||
/// Horizontal margin in compact mode.
|
||||
const H_MARGIN_COMPACT: u16 = 1;
|
||||
|
||||
/// Minimum width for menu + changelog sections so they don't resize when the import row toggles.
|
||||
/// Minimum width for the menu section so it doesn't resize when the import row toggles.
|
||||
/// Derivation: "[ " (2) + import-claude label (22) + gap (4) + "ctrl+i [x]" (11) + " ]" (2) = 41.
|
||||
/// Bumped to 51 for comfortable breathing room.
|
||||
const MENU_MIN_WIDTH: u16 = 51;
|
||||
@@ -103,13 +93,6 @@ pub struct WelcomeRenderResult {
|
||||
pub auth_url_rect: Option<Rect>,
|
||||
/// Hit-test rect for the "show full URL" fallback link.
|
||||
pub auth_fallback_rect: Option<Rect>,
|
||||
/// Hit-test rect for the "[Refresh]" button on the paywall tier line.
|
||||
/// Whether a "Changelog" menu action was rendered (above Quit), so the
|
||||
/// input handler can map the extra menu row to the release-notes action
|
||||
/// once markdown is available.
|
||||
pub changelog_action_present: bool,
|
||||
/// Hit-test rect for the clickable changelog info block (opens release notes).
|
||||
pub changelog_cta_rect: Option<Rect>,
|
||||
}
|
||||
|
||||
use hero_box::HERO_BOX_MIN_WIDTH;
|
||||
@@ -124,9 +107,6 @@ pub(super) struct WelcomeLayout {
|
||||
pub(super) logo: Rect,
|
||||
pub(super) error: Rect,
|
||||
pub(super) menu: Rect,
|
||||
/// Stacked info slot below the menu (narrow layout only) — shows the
|
||||
/// changelog. Zero in the hero box layout, which uses `hero_info` instead.
|
||||
pub(super) changelog: Rect,
|
||||
pub(super) tip: Rect,
|
||||
pub(super) prompt: Rect,
|
||||
pub(super) version: Rect,
|
||||
@@ -135,8 +115,6 @@ pub(super) struct WelcomeLayout {
|
||||
pub(super) hero_logo: Rect,
|
||||
pub(super) hero_version: Rect,
|
||||
pub(super) hero_subtitle: Rect,
|
||||
/// In-box info slot — shows the changelog.
|
||||
pub(super) hero_info: Rect,
|
||||
pub(super) hero_menu: Rect,
|
||||
}
|
||||
|
||||
@@ -151,9 +129,7 @@ struct WelcomeLayoutInput {
|
||||
error_height: u16,
|
||||
menu_height: u16,
|
||||
tip_height: u16,
|
||||
/// Desired changelog height (collapsed to 0 if the terminal is too short).
|
||||
changelog_height: u16,
|
||||
/// Vertical compaction (session picker visible): skip the logo + info slot.
|
||||
/// Vertical compaction (session picker visible): skip the logo.
|
||||
compact: bool,
|
||||
/// Horizontal-inset compaction (appearance setting) for the stacked slot.
|
||||
prompt_compact: bool,
|
||||
@@ -170,22 +146,6 @@ impl WelcomeLayout {
|
||||
tip_height + tip_gap + PROMPT_HEIGHT + VERSION_GAP + 1
|
||||
}
|
||||
|
||||
pub(super) fn effective_changelog(
|
||||
content_height: u16,
|
||||
fixed_above: u16,
|
||||
content_slot: u16,
|
||||
fixed_below: u16,
|
||||
requested: u16,
|
||||
) -> (u16, u16) {
|
||||
let gap = if requested > 0 { 1u16 } else { 0 };
|
||||
let min_without = fixed_above + content_slot + 1 + fixed_below;
|
||||
if requested > 0 && content_height >= min_without + gap + requested {
|
||||
(requested, 1)
|
||||
} else {
|
||||
(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the welcome screen layout, allowing the wide hero-box variant.
|
||||
fn compute(input: WelcomeLayoutInput) -> Self {
|
||||
Self::compute_inner(input, true)
|
||||
@@ -203,17 +163,14 @@ impl WelcomeLayout {
|
||||
|
||||
/// Compute the welcome screen layout.
|
||||
///
|
||||
/// Picks hero vs stacked, then measures the info slot (the changelog) at
|
||||
/// that layout's slot width before placing rects — width is
|
||||
/// content-size-only, so it's a clean two-phase computation. `allow_hero_box`
|
||||
/// gates the wide variant; stacked-only callers pass `false`.
|
||||
/// Picks hero vs stacked. `allow_hero_box` gates the wide variant;
|
||||
/// stacked-only callers pass `false`.
|
||||
fn compute_inner(input: WelcomeLayoutInput, allow_hero_box: bool) -> Self {
|
||||
let WelcomeLayoutInput {
|
||||
content_area,
|
||||
error_height,
|
||||
menu_height,
|
||||
tip_height,
|
||||
changelog_height,
|
||||
compact,
|
||||
prompt_compact,
|
||||
} = input;
|
||||
@@ -224,26 +181,12 @@ impl WelcomeLayout {
|
||||
&& content_area.width >= HERO_BOX_MIN_WIDTH
|
||||
&& menu_height > 0
|
||||
&& content_area.height
|
||||
>= hero_box::min_content_height(
|
||||
error_height,
|
||||
menu_height,
|
||||
tip_height,
|
||||
changelog_height,
|
||||
);
|
||||
>= hero_box::min_content_height(error_height, menu_height, tip_height);
|
||||
|
||||
if use_hero_box {
|
||||
return hero_box::compute_hero_box(
|
||||
content_area,
|
||||
error_height,
|
||||
menu_height,
|
||||
tip_height,
|
||||
changelog_height,
|
||||
);
|
||||
return hero_box::compute_hero_box(content_area, error_height, menu_height, tip_height);
|
||||
}
|
||||
|
||||
// Stacked info slot: the changelog.
|
||||
let info_height = changelog_height;
|
||||
|
||||
// Stacked layout: skip the logo in compact mode (the session picker
|
||||
// needs the space); otherwise pick small/full/none by height.
|
||||
let logo_rows = if compact {
|
||||
@@ -256,19 +199,6 @@ impl WelcomeLayout {
|
||||
let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
|
||||
let fixed_below = Self::fixed_below(tip_height);
|
||||
let fixed_above = logo_rows + 1 + gap_after_logo + error_height; // +1 for gap after logo
|
||||
// The stacked info slot below the menu holds the changelog.
|
||||
let (eff_changelog_height, _) = if !compact {
|
||||
Self::effective_changelog(
|
||||
content_area.height,
|
||||
fixed_above,
|
||||
menu_height,
|
||||
fixed_below,
|
||||
info_height,
|
||||
)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
let eff_changelog_gap = if eff_changelog_height > 0 { 1u16 } else { 0 };
|
||||
// Compute top_pad using the *default* menu height (4 items = 7 rows) so
|
||||
// the logo position stays constant regardless of picker/focus state.
|
||||
let top_pad = if compact {
|
||||
@@ -278,36 +208,18 @@ impl WelcomeLayout {
|
||||
let remaining = content_area.height.saturating_sub(fixed_above);
|
||||
remaining
|
||||
.saturating_sub(default_menu_height)
|
||||
.saturating_sub(eff_changelog_gap + eff_changelog_height)
|
||||
.saturating_sub(fixed_below)
|
||||
/ 3
|
||||
};
|
||||
let logo_gap = 1u16;
|
||||
let flex_gap = 1u16;
|
||||
let [
|
||||
_,
|
||||
logo,
|
||||
_,
|
||||
_,
|
||||
error,
|
||||
menu,
|
||||
_,
|
||||
changelog,
|
||||
_,
|
||||
tip,
|
||||
_,
|
||||
prompt,
|
||||
_,
|
||||
version,
|
||||
] = Layout::vertical([
|
||||
let [_, logo, _, _, error, menu, _, tip, _, prompt, _, version] = Layout::vertical([
|
||||
Constraint::Length(top_pad),
|
||||
Constraint::Length(logo_rows),
|
||||
Constraint::Length(logo_gap), // gap after logo
|
||||
Constraint::Length(gap_after_logo),
|
||||
Constraint::Length(error_height),
|
||||
Constraint::Length(menu_height),
|
||||
Constraint::Length(eff_changelog_gap),
|
||||
Constraint::Length(eff_changelog_height),
|
||||
Constraint::Min(flex_gap),
|
||||
Constraint::Length(tip_height),
|
||||
Constraint::Length(tip_gap),
|
||||
@@ -320,7 +232,6 @@ impl WelcomeLayout {
|
||||
logo,
|
||||
error,
|
||||
menu,
|
||||
changelog,
|
||||
tip,
|
||||
prompt,
|
||||
version,
|
||||
@@ -328,7 +239,6 @@ impl WelcomeLayout {
|
||||
hero_logo: zero,
|
||||
hero_version: zero,
|
||||
hero_subtitle: zero,
|
||||
hero_info: zero,
|
||||
hero_menu: zero,
|
||||
}
|
||||
}
|
||||
@@ -566,10 +476,6 @@ pub struct WelcomeRenderParams<'a> {
|
||||
/// Live working directory (tracks `Effect::SetWorkingDir`), used to pin
|
||||
/// the current repo's session group to the top of the picker.
|
||||
pub cwd: &'a std::path::Path,
|
||||
/// Cached changelog bullets for the welcome screen (up to 3).
|
||||
pub changelog_bullets: &'a [String],
|
||||
/// Whether full release notes markdown is available (controls the CTA hint).
|
||||
pub changelog_has_full_notes: bool,
|
||||
}
|
||||
|
||||
/// Render the welcome screen.
|
||||
@@ -642,8 +548,6 @@ pub fn render_welcome(
|
||||
import_banner_rect: None,
|
||||
auth_url_rect: None,
|
||||
auth_fallback_rect: None,
|
||||
changelog_action_present: false,
|
||||
changelog_cta_rect: None,
|
||||
}
|
||||
}
|
||||
AuthState::Authenticating { auth_url, mode, .. } => {
|
||||
@@ -668,8 +572,6 @@ pub fn render_welcome(
|
||||
import_banner_rect: None,
|
||||
auth_url_rect: url_rect,
|
||||
auth_fallback_rect: fallback_rect,
|
||||
changelog_action_present: false,
|
||||
changelog_cta_rect: None,
|
||||
}
|
||||
}
|
||||
// Folder-trust question: shown after auth, before any session is
|
||||
@@ -1450,73 +1352,6 @@ fn inset_horizontal(rect: Rect, inset: u16) -> Rect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the changelog section (header + bullets), centered to the menu width.
|
||||
/// When `clickable` (full notes exist) the whole block opens the notes on click
|
||||
/// and brightens while hovered; returns that clickable rect.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_changelog_section(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
bullets: &[String],
|
||||
min_width_hint: u16,
|
||||
content_height: u16,
|
||||
clickable: bool,
|
||||
mouse_pos: Option<(u16, u16)>,
|
||||
) -> Option<Rect> {
|
||||
let menu_width = logo::logo_visual_width(content_height)
|
||||
.max(30)
|
||||
.max(min_width_hint);
|
||||
let [_, centered, _] = Layout::horizontal([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(menu_width),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(area);
|
||||
|
||||
if centered.width < 20 || centered.height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hovered =
|
||||
clickable && mouse_pos.is_some_and(|(mx, my)| centered.contains(Position::new(mx, my)));
|
||||
|
||||
let header_style = hover_style(
|
||||
theme,
|
||||
hovered,
|
||||
Style::default()
|
||||
.fg(theme.gray_bright)
|
||||
.add_modifier(Modifier::DIM),
|
||||
);
|
||||
let title = "Changelog";
|
||||
buf.set_span(
|
||||
centered.x,
|
||||
centered.y,
|
||||
&Span::styled(title, header_style),
|
||||
centered.width,
|
||||
);
|
||||
|
||||
let bullet_style = hover_style(theme, hovered, Style::default().fg(theme.gray_bright));
|
||||
let max_text_width = centered.width.saturating_sub(2) as usize; // "• " prefix = 2 cols
|
||||
for (i, bullet) in bullets.iter().enumerate() {
|
||||
let row = centered.y + 2 + i as u16;
|
||||
if row >= centered.y + centered.height {
|
||||
break;
|
||||
}
|
||||
let truncated = crate::render::line_utils::truncate_str(bullet, max_text_width);
|
||||
let text = format!("\u{2022} {truncated}");
|
||||
buf.set_span(
|
||||
centered.x,
|
||||
row,
|
||||
&Span::styled(text, bullet_style),
|
||||
centered.width,
|
||||
);
|
||||
}
|
||||
|
||||
clickable.then_some(centered)
|
||||
}
|
||||
|
||||
/// Render the normal welcome screen (Done state -- already authenticated).
|
||||
fn render_welcome_done(
|
||||
content_area: Rect,
|
||||
@@ -1535,8 +1370,6 @@ fn render_welcome_done(
|
||||
|
||||
let in_vscode_family = welcome_in_vscode_family();
|
||||
|
||||
// Heights that don't depend on the menu — computed first so the menu
|
||||
// builder can probe the layout to decide whether to add a Changelog row.
|
||||
// Startup-warning hint height (multi-line aware).
|
||||
let hint_height = p.startup_warnings.first().map_or(0u16, |w| {
|
||||
let msg_lines = w.message.lines().count() as u16;
|
||||
@@ -1558,14 +1391,6 @@ fn render_welcome_done(
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let changelog_height = if !show_picker && !p.changelog_bullets.is_empty() {
|
||||
2 + p.changelog_bullets.len() as u16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Changelog is reachable via this menu row (ctrl+l). Show from the first
|
||||
// frame so the menu doesn't shift while the CDN fetch completes.
|
||||
let show_changelog_action = !show_picker;
|
||||
|
||||
let owned_menu;
|
||||
let menu_items: &[(&str, &str)] = {
|
||||
@@ -1577,7 +1402,7 @@ fn render_welcome_done(
|
||||
);
|
||||
// Insert the import row at the top when there are pending `.claude/`
|
||||
// settings to import — it's the most actionable item right now.
|
||||
let mut items: Vec<(&str, &str)> = Vec::with_capacity(5);
|
||||
let mut items: Vec<(&str, &str)> = Vec::with_capacity(4);
|
||||
if p.has_claude_import {
|
||||
// The trailing "[x]" is a clickable dismiss affordance — the
|
||||
// welcome screen mouse handler treats clicks on the rightmost
|
||||
@@ -1588,10 +1413,6 @@ fn render_welcome_done(
|
||||
}
|
||||
items.push((key_w, "New worktree"));
|
||||
items.push((key_s, "Resume session"));
|
||||
// "Changelog" above Quit; no shortcut — opened by click (row or block).
|
||||
if show_changelog_action {
|
||||
items.push(("", "Changelog"));
|
||||
}
|
||||
items.push((key_q, "Quit"));
|
||||
owned_menu = items;
|
||||
owned_menu.as_slice()
|
||||
@@ -1620,7 +1441,6 @@ fn render_welcome_done(
|
||||
error_height: hint_height,
|
||||
menu_height: content_height,
|
||||
tip_height,
|
||||
changelog_height,
|
||||
compact: welcome_compact,
|
||||
prompt_compact: p.compact,
|
||||
});
|
||||
@@ -1628,9 +1448,6 @@ fn render_welcome_done(
|
||||
// Render startup warning in the error area (same slot as auth errors).
|
||||
let import_banner_rect = render_startup_warnings(layout.error, buf, theme, p.startup_warnings);
|
||||
|
||||
// Hit-rects, set by whichever layout draws each block.
|
||||
let mut changelog_cta_rect: Option<Rect> = None;
|
||||
|
||||
let (menu_rects, picker_close_button) = if show_picker {
|
||||
// Use the full area since logo/menu are hidden and shortcuts
|
||||
// are now rendered inside the picker content area.
|
||||
@@ -1663,20 +1480,9 @@ fn render_welcome_done(
|
||||
(vec![], Some(hit_areas))
|
||||
} else if layout.has_hero_box() {
|
||||
// Wide layout: render bordered hero box with logo left, version + menu right.
|
||||
let rects = hero_box::render_hero_box(
|
||||
&layout,
|
||||
buf,
|
||||
theme,
|
||||
menu_items,
|
||||
p.selected,
|
||||
p.mouse_pos,
|
||||
hero_box::ChangelogDisplay {
|
||||
bullets: p.changelog_bullets,
|
||||
has_full_notes: p.changelog_has_full_notes,
|
||||
},
|
||||
);
|
||||
changelog_cta_rect = rects.changelog_cta_rect;
|
||||
(rects.menu_rects, None)
|
||||
let menu_rects =
|
||||
hero_box::render_hero_box(&layout, buf, theme, menu_items, p.selected, p.mouse_pos);
|
||||
(menu_rects, None)
|
||||
} else {
|
||||
// Narrow layout: stacked logo above, menu below. Inset the menu the
|
||||
// same as the input bar (`prompt_inset`) so it keeps side spacing
|
||||
@@ -1697,23 +1503,6 @@ fn render_welcome_done(
|
||||
)
|
||||
};
|
||||
|
||||
// Stacked info slot below the menu (narrow layout): show the changelog,
|
||||
// mirroring the hero box. Inset to match the input bar so it lines up with
|
||||
// the menu above.
|
||||
if layout.changelog.height > 0 {
|
||||
let info_area = inset_horizontal(layout.changelog, prompt::prompt_inset(p.compact));
|
||||
changelog_cta_rect = render_changelog_section(
|
||||
info_area,
|
||||
buf,
|
||||
theme,
|
||||
p.changelog_bullets,
|
||||
MENU_MIN_WIDTH,
|
||||
content_area.height,
|
||||
p.changelog_has_full_notes,
|
||||
p.mouse_pos,
|
||||
);
|
||||
}
|
||||
|
||||
// Skip the prompt input when picker is visible to save space;
|
||||
// shortcuts are rendered inside the picker content area.
|
||||
let (cursor_pos, post_flush_escapes) = if show_picker {
|
||||
@@ -1840,8 +1629,6 @@ fn render_welcome_done(
|
||||
import_banner_rect,
|
||||
auth_url_rect: None,
|
||||
auth_fallback_rect: None,
|
||||
changelog_action_present: show_changelog_action,
|
||||
changelog_cta_rect,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2278,8 +2065,6 @@ mod tests {
|
||||
session_picker_source_filter: crate::views::session_picker::SourceFilter::All,
|
||||
chat_mode: false,
|
||||
cwd: std::path::Path::new("/repo"),
|
||||
changelog_bullets: &[],
|
||||
changelog_has_full_notes: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2747,106 +2532,6 @@ mod tests {
|
||||
assert_eq!(state.query, "e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_hidden_on_short_terminal() {
|
||||
let area = Rect::new(0, 0, 80, 15);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_shown_on_tall_terminal() {
|
||||
let area = Rect::new(0, 0, 80, 50);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_hidden_when_compact() {
|
||||
let area = Rect::new(0, 0, 80, 60);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
compact: true,
|
||||
prompt_compact: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_hidden_when_zero_requested() {
|
||||
let area = Rect::new(0, 0, 80, 60);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_boundary_exact_fit() {
|
||||
// No logo at h < 22. fixed_above = 0 + 1 + 0 + 0 = 1.
|
||||
// fixed_below = 0 (tip) + 0 (tip_gap) + 3 (prompt) + 1 (ver_gap) + 1 (ver) = 5.
|
||||
// min_without_changelog = 1 + 4 (menu) + 1 (flex) + 5 = 11.
|
||||
// changelog slot = 1 (gap) + 5 (height) = 6. Threshold = 11 + 6 = 17.
|
||||
let just_fits = Rect::new(0, 0, 80, 17);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: just_fits,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 5);
|
||||
|
||||
let too_short = Rect::new(0, 0, 80, 16);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: too_short,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changelog_hidden_when_tip_steals_space() {
|
||||
// Use narrow width to avoid hero box path, keeping stacked layout.
|
||||
// With tip_height=2: fixed_below(2) = 8. min = 1 + 4 + 1 + 8 = 14.
|
||||
// Threshold = 14 + 6 = 20. At h=19 the tip pushes changelog out.
|
||||
let with_tip = Rect::new(0, 0, 60, 19);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: with_tip,
|
||||
menu_height: 4,
|
||||
tip_height: 2,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
|
||||
// Same size without tip: threshold = 17 <= 19, changelog fits.
|
||||
let without_tip = Rect::new(0, 0, 60, 19);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: without_tip,
|
||||
menu_height: 4,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(layout.changelog.height, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hero_box_active_on_wide_tall_terminal() {
|
||||
// 90 cols, 50 rows: meets the minimum for the hero box.
|
||||
@@ -2866,6 +2551,7 @@ mod tests {
|
||||
assert!(layout.hero_logo.height > 0);
|
||||
assert!(layout.hero_menu.height > 0);
|
||||
assert_eq!(layout.hero_version.height, 1);
|
||||
assert_eq!(layout.hero_subtitle.height, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3056,53 +2742,6 @@ mod tests {
|
||||
assert_eq!(layout.hero_logo.y, layout.hero_box.y + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hero_box_with_changelog() {
|
||||
// The changelog renders inside the box (info slot), not in a
|
||||
// separate area below it.
|
||||
let area = Rect::new(0, 0, 100, 50);
|
||||
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 3,
|
||||
changelog_height: 5,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(layout.has_hero_box());
|
||||
assert_eq!(layout.changelog.height, 0);
|
||||
assert_eq!(layout.hero_info.height, 5);
|
||||
// The subtitle is hidden when the info slot is shown.
|
||||
assert_eq!(layout.hero_subtitle.height, 0);
|
||||
assert!(layout.hero_info.y > layout.hero_version.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hero_box_keeps_one_bottom_pad_below_actions() {
|
||||
// With a changelog the subtitle is hidden, but there's still exactly
|
||||
// one padding row between the actions and the bottom border.
|
||||
// (menu=4 + info=3 fills the inner, so the menu reaches the pad.)
|
||||
let area = Rect::new(0, 0, 100, 50);
|
||||
let no_info = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
..Default::default()
|
||||
});
|
||||
let with_info = WelcomeLayout::compute(WelcomeLayoutInput {
|
||||
content_area: area,
|
||||
menu_height: 4,
|
||||
changelog_height: 3,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(no_info.hero_subtitle.height, 1);
|
||||
assert_eq!(with_info.hero_subtitle.height, 0);
|
||||
let menu_bottom = with_info.hero_menu.y + with_info.hero_menu.height;
|
||||
let border_bottom = with_info.hero_box.y + with_info.hero_box.height - 1;
|
||||
assert_eq!(
|
||||
border_bottom - menu_bottom,
|
||||
1,
|
||||
"one pad row below the actions"
|
||||
);
|
||||
}
|
||||
|
||||
/// Flatten a rendered buffer into one string for substring assertions.
|
||||
fn buffer_text(buf: &Buffer) -> String {
|
||||
let area = *buf.area();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: graph-slash-presession
|
||||
description: >
|
||||
With the graph feature flag on (`KIGI_GRAPH=1`, plus `KIGI_GOAL=1` — the
|
||||
graph gate requires the goal harness), `/graph` must appear in the
|
||||
slash-command menu on the welcome screen *before* the first user turn
|
||||
creates a session. Typing `/graph` pre-session must surface the command
|
||||
and its description in the dropdown.
|
||||
terminal:
|
||||
rows: 40
|
||||
cols: 100
|
||||
environment:
|
||||
env:
|
||||
- key: KIGI_GOAL
|
||||
value: "1"
|
||||
- key: KIGI_GRAPH
|
||||
value: "1"
|
||||
mock:
|
||||
response: "unused — this scenario never submits a prompt."
|
||||
steps:
|
||||
- action: wait_for_text
|
||||
text: Quit
|
||||
timeout_ms: 20000
|
||||
- action: assert_not_contains
|
||||
text: panicked
|
||||
# Sanity: nothing has been submitted yet, so no session exists.
|
||||
- action: assert_not_contains
|
||||
text: dependency graph
|
||||
- action: focus_prompt
|
||||
- action: type_text
|
||||
text: "/graph"
|
||||
- action: wait
|
||||
millis: 300
|
||||
# The dropdown row carries the builtin's description, which only renders
|
||||
# when /graph is actually advertised pre-session.
|
||||
- action: assert_contains
|
||||
text: dependency graph
|
||||
- action: assert_running
|
||||
- action: screenshot
|
||||
name: graph-in-slash-menu-presession
|
||||
note: "/graph advertised in the slash menu on the welcome screen (KIGI_GRAPH=1), before any prompt."
|
||||
@@ -0,0 +1,45 @@
|
||||
name: graph-slash-presession-disabled
|
||||
description: >
|
||||
Fail-closed counterpart: with `KIGI_GRAPH=0` (goal harness still on),
|
||||
`/graph` must NOT appear in the pre-session slash menu, while the menu
|
||||
itself keeps working for other commands.
|
||||
terminal:
|
||||
rows: 40
|
||||
cols: 100
|
||||
environment:
|
||||
env:
|
||||
- key: KIGI_GOAL
|
||||
value: "1"
|
||||
- key: KIGI_GRAPH
|
||||
value: "0"
|
||||
mock:
|
||||
response: "unused — this scenario never submits a prompt."
|
||||
steps:
|
||||
- action: wait_for_text
|
||||
text: Quit
|
||||
timeout_ms: 20000
|
||||
- action: assert_not_contains
|
||||
text: panicked
|
||||
- action: focus_prompt
|
||||
- action: type_text
|
||||
text: "/graph"
|
||||
- action: wait
|
||||
millis: 300
|
||||
- action: assert_not_contains
|
||||
text: dependency graph
|
||||
- action: assert_running
|
||||
- action: screenshot
|
||||
name: graph-absent-from-slash-menu
|
||||
note: "/graph hidden with KIGI_GRAPH=0 (fail-closed gate)."
|
||||
# The menu itself still works: clear "/graph" (6 chars) and try /compact.
|
||||
- action: keys
|
||||
keys: "<BS><BS><BS><BS><BS><BS>"
|
||||
- action: type_text
|
||||
text: "/compact"
|
||||
- action: wait
|
||||
millis: 300
|
||||
- action: assert_contains
|
||||
text: Compact conversation history
|
||||
- action: screenshot
|
||||
name: slash-menu-still-works
|
||||
note: "Slash menu functional; only /graph is gated off."
|
||||
@@ -1,52 +0,0 @@
|
||||
name: release-notes-scroll
|
||||
description: >
|
||||
Open /release-notes (DocViewer modal) and verify keyboard + mouse-wheel scrolling
|
||||
moves the changelog body. Seeds CHANGELOG cache offline via setup is not available
|
||||
in YAML, so this scenario relies on CDN/cache if present and still asserts the
|
||||
modal chrome + scroll affordances. Prefer the pty_e2e Rust tests for deterministic
|
||||
offline seeding; this YAML exercises the scripted ptyctl runner path.
|
||||
terminal:
|
||||
rows: 40
|
||||
cols: 110
|
||||
mock:
|
||||
response: "SCRIPTED_RELEASE_NOTES_SCROLL unused for this scenario."
|
||||
steps:
|
||||
- action: wait_for_text
|
||||
text: Quit
|
||||
timeout_ms: 20000
|
||||
# Promote welcome → session and open release notes (uses CDN or disk cache).
|
||||
- action: type_text
|
||||
text: "/release-notes"
|
||||
- action: keys
|
||||
keys: "<Enter>"
|
||||
- action: wait_for_text
|
||||
text: Release Notes
|
||||
timeout_ms: 20000
|
||||
- action: assert_contains
|
||||
text: scroll
|
||||
# Keyboard scroll through the modal body.
|
||||
- action: keys
|
||||
keys: "<Down><Down><Down><Down><Down><Down><Down><Down><Down><Down>"
|
||||
- action: wait
|
||||
millis: 200
|
||||
- action: keys
|
||||
keys: "jjjjjjjjjj"
|
||||
- action: wait
|
||||
millis: 200
|
||||
# Mouse wheel at the center of the modal.
|
||||
- action: scroll
|
||||
row: 20
|
||||
col: 55
|
||||
direction: down
|
||||
count: 15
|
||||
- action: wait
|
||||
millis: 250
|
||||
- action: assert_contains
|
||||
text: Release Notes
|
||||
- action: assert_not_contains
|
||||
text: panicked
|
||||
- action: keys
|
||||
keys: "<Esc>"
|
||||
- action: screenshot
|
||||
name: release-notes-after-scroll
|
||||
note: Release notes modal after keyboard + wheel scroll.
|
||||
@@ -64,12 +64,6 @@ async fn scripted_slash_resize_storm() {
|
||||
run_scenario("slash_resize_storm.yaml").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn scripted_release_notes_scroll() {
|
||||
run_scenario("release_notes_scroll.yaml").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn scripted_mock_response() {
|
||||
@@ -345,6 +339,21 @@ async fn scripted_goal_slash_presession() {
|
||||
run_scenario("goal_slash_presession.yaml").await;
|
||||
}
|
||||
|
||||
/// `/graph` gating mirror of the `/goal` pre-session scenarios: with
|
||||
/// `KIGI_GRAPH=1` (+ the goal harness) the command must be advertised on
|
||||
/// the welcome screen before any session exists.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
|
||||
async fn scripted_graph_slash_presession() {
|
||||
run_scenario("graph_slash_presession.yaml").await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
|
||||
async fn scripted_graph_slash_presession_disabled() {
|
||||
run_scenario("graph_slash_presession_disabled.yaml").await;
|
||||
}
|
||||
|
||||
/// Counterpart to `scripted_goal_slash_presession`: with the goal flag
|
||||
/// explicitly off (`KIGI_GOAL=0`; goal mode defaults on), `/goal` must stay
|
||||
/// hidden pre-session (gate fail-closed) while an
|
||||
@@ -522,6 +531,8 @@ fn scenarios_parse() {
|
||||
"path_space_hyperlink.yaml",
|
||||
"goal_slash_presession.yaml",
|
||||
"goal_slash_presession_disabled.yaml",
|
||||
"graph_slash_presession.yaml",
|
||||
"graph_slash_presession_disabled.yaml",
|
||||
"folder_trust_prompt.yaml",
|
||||
"dashboard_model_list_click.yaml",
|
||||
"paste_chip_double_click.yaml",
|
||||
|
||||
@@ -2090,16 +2090,22 @@ async fn get_apply_context(worktree_path: &str) -> Result<ApplyContext> {
|
||||
.await?
|
||||
}
|
||||
|
||||
async fn get_file_at_commit(worktree_path: &str, commit: &str, path: &str) -> Option<String> {
|
||||
git_cli(
|
||||
Path::new(worktree_path),
|
||||
&["show", &format!("{}:{}", commit, path)],
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
/// Raw blob bytes at `commit:path`, or `None` when absent. Byte-exact
|
||||
/// (NOT `git_cli`, which lossy-decodes and trims): the 3-way merge below
|
||||
/// must compare content byte-for-byte or binary files mis-merge.
|
||||
async fn get_file_at_commit(worktree_path: &str, commit: &str, path: &str) -> Option<Vec<u8>> {
|
||||
let output = tokio::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(worktree_path)
|
||||
.arg("show")
|
||||
.arg(format!("{}:{}", commit, path))
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
output.status.success().then_some(output.stdout)
|
||||
}
|
||||
|
||||
async fn apply_file_content(dest: &Path, content: Option<&String>) -> bool {
|
||||
async fn apply_file_content(dest: &Path, content: Option<&Vec<u8>>) -> bool {
|
||||
match content {
|
||||
Some(data) => {
|
||||
if let Some(parent) = dest.parent() {
|
||||
@@ -2114,6 +2120,14 @@ async fn apply_file_content(dest: &Path, content: Option<&String>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lossy decode for the `FileConflict` wire payload only — comparisons
|
||||
/// above stay byte-exact.
|
||||
fn conflict_text(content: &Option<Vec<u8>>) -> Option<String> {
|
||||
content
|
||||
.as_ref()
|
||||
.map(|b| String::from_utf8_lossy(b).into_owned())
|
||||
}
|
||||
|
||||
pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeResponse> {
|
||||
let worktree_path = &req.worktree_path;
|
||||
let git_root = find_main_repo_root_from_path(Path::new(worktree_path))?;
|
||||
@@ -2133,7 +2147,7 @@ pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeR
|
||||
for file_change in ctx.changed_files {
|
||||
let worktree_file = Path::new(worktree_path).join(&file_change.path);
|
||||
let main_file = git_root.join(&file_change.path);
|
||||
let theirs = tokio::fs::read_to_string(&worktree_file).await.ok();
|
||||
let theirs = tokio::fs::read(&worktree_file).await.ok();
|
||||
|
||||
if req.mode == ApplyMode::Overwrite {
|
||||
if apply_file_content(&main_file, theirs.as_ref()).await {
|
||||
@@ -2142,21 +2156,28 @@ pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeR
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merge mode
|
||||
// Merge mode — 3-way-lite over raw bytes.
|
||||
let base = get_file_at_commit(worktree_path, &ctx.base_commit, &file_change.path).await;
|
||||
let ours = tokio::fs::read_to_string(&main_file).await.ok();
|
||||
let ours = tokio::fs::read(&main_file).await.ok();
|
||||
|
||||
if base == ours {
|
||||
// Main side untouched: take the worktree's version.
|
||||
if apply_file_content(&main_file, theirs.as_ref()).await {
|
||||
files.push(file_change);
|
||||
}
|
||||
} else if ours == theirs {
|
||||
// Both sides hold identical content (e.g. dirty state the
|
||||
// worktree inherited at creation, or an earlier sequential
|
||||
// apply already landed the same change): already present —
|
||||
// not a conflict, nothing to write.
|
||||
files.push(file_change);
|
||||
} else if base != theirs {
|
||||
conflicts.push(FileConflict {
|
||||
path: file_change.path,
|
||||
change_type: file_change.change_type,
|
||||
base,
|
||||
ours,
|
||||
theirs,
|
||||
base: conflict_text(&base),
|
||||
ours: conflict_text(&ours),
|
||||
theirs: conflict_text(&theirs),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+11
-7
@@ -131,16 +131,20 @@ try {
|
||||
Write-Host ""
|
||||
Write-Host "kigi v$ResolvedVersion installed to $Dest"
|
||||
|
||||
# -contains instead of Where-Object/.Count: under Set-StrictMode, .Count
|
||||
# on an empty (null) filter result throws PropertyNotFoundStrict — which
|
||||
# fired on every fresh install, since that's exactly the not-on-PATH case.
|
||||
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
$OnPath = ($UserPath -split ";" | Where-Object { $_ -eq $BinDir }).Count -gt 0 -or
|
||||
($env:Path -split ";" | Where-Object { $_ -eq $BinDir }).Count -gt 0
|
||||
$OnPath = (($UserPath -split ";") -contains $BinDir) -or
|
||||
(($env:Path -split ";") -contains $BinDir)
|
||||
if (-not $OnPath) {
|
||||
# Persist the bin dir on the per-user PATH so the user doesn't have
|
||||
# to. Registry-backed; every new terminal picks it up automatically.
|
||||
$NewUserPath = if ($UserPath) { "$BinDir;$UserPath" } else { $BinDir }
|
||||
[Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User")
|
||||
Write-Host ""
|
||||
Write-Host "$BinDir is not on your PATH. Add it for the current user with:"
|
||||
Write-Host ""
|
||||
Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$BinDir;`" + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')"
|
||||
Write-Host ""
|
||||
Write-Host "Then open a new terminal and run 'kigi' to get started."
|
||||
Write-Host "Added $BinDir to your user PATH."
|
||||
Write-Host "Open a new terminal, then run 'kigi' to get started."
|
||||
} else {
|
||||
Write-Host "Run 'kigi' to get started."
|
||||
}
|
||||
|
||||
+40
-4
@@ -191,9 +191,45 @@ case ":$PATH:" in
|
||||
printf 'Run `kigi` to get started.\n'
|
||||
;;
|
||||
*)
|
||||
printf '\n%s is not on your PATH. Add it with:\n\n' "$BIN_DIR"
|
||||
printf ' export PATH="%s:$PATH" # sh / bash / zsh (add to your shell rc)\n' "$BIN_DIR"
|
||||
printf ' fish_add_path %s # fish\n\n' "$BIN_DIR"
|
||||
printf 'Then run `kigi` to get started.\n'
|
||||
# Persist BIN_DIR on PATH in the login shell's rc file, so the user
|
||||
# doesn't have to. Idempotent: skipped when the rc already mentions
|
||||
# the bin dir. On write failure the manual command is printed and the
|
||||
# script fails loudly (the binary itself is already installed).
|
||||
persist_line() {
|
||||
rc="$1"
|
||||
line="$2"
|
||||
if [ -f "$rc" ] && grep -qF "$BIN_DIR" "$rc"; then
|
||||
printf '\n%s is already configured in %s.\n' "$BIN_DIR" "$rc"
|
||||
return 0
|
||||
fi
|
||||
printf '\n# Added by the kigi installer\n%s\n' "$line" >> "$rc" \
|
||||
|| err "could not write $rc — add kigi to your PATH manually: $line"
|
||||
printf '\nAdded %s to your PATH in %s.\n' "$BIN_DIR" "$rc"
|
||||
}
|
||||
EXPORT_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
||||
case "${SHELL:-}" in
|
||||
*/zsh)
|
||||
persist_line "${ZDOTDIR:-$HOME}/.zshrc" "$EXPORT_LINE"
|
||||
;;
|
||||
*/bash)
|
||||
# macOS login shells read ~/.bash_profile; Linux reads ~/.bashrc.
|
||||
if [ "$PLATFORM_OS" = "macos" ]; then
|
||||
persist_line "$HOME/.bash_profile" "$EXPORT_LINE"
|
||||
else
|
||||
persist_line "$HOME/.bashrc" "$EXPORT_LINE"
|
||||
fi
|
||||
;;
|
||||
*/fish)
|
||||
# fish_add_path in config.fish is fish's own idempotent way
|
||||
# to persist a PATH entry.
|
||||
FISH_CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/fish"
|
||||
mkdir -p "$FISH_CONF_DIR"
|
||||
persist_line "$FISH_CONF_DIR/config.fish" "fish_add_path $BIN_DIR"
|
||||
;;
|
||||
*)
|
||||
persist_line "$HOME/.profile" "$EXPORT_LINE"
|
||||
;;
|
||||
esac
|
||||
printf 'Open a new terminal, then run `kigi` to get started.\n'
|
||||
;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user