Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6f216facd | ||
|
|
4d2cf7ffd4 | ||
|
|
02cf5deebd | ||
|
|
db05ed0751 | ||
|
|
771de1822d | ||
|
|
bb5cbff62d | ||
|
|
1579558b56 | ||
|
|
4d1e4fdc52 | ||
|
|
4361b03259 | ||
|
|
2c29d3ecd5 | ||
|
|
de534582c1 | ||
|
|
54c9177ce2 |
@@ -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).
|
- `third_party/` — vendored Mermaid rendering stack (untouched policy).
|
||||||
- `bin/protoc` — dotslash launcher used by proto codegen.
|
- `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
|
## Test seams
|
||||||
|
|
||||||
Cross-crate test hooks are behind the `test-support` cargo feature
|
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
|
dependents' `[dev-dependencies]`. Don't expose new test seams as plain
|
||||||
`#[cfg(test)]` items across crate boundaries.
|
`#[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)
|
## Milestones (PRD §8.3)
|
||||||
|
|
||||||
- M0 (done): rename, deletions (voice/telemetry/announcements/marketplace/
|
- M0 (done): rename, deletions (voice/telemetry/announcements/marketplace/
|
||||||
|
|||||||
Generated
+62
-62
@@ -5442,7 +5442,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-acp-lib"
|
name = "kigi-acp-lib"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent"
|
name = "kigi-agent"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent-lifecycle"
|
name = "kigi-agent-lifecycle"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-auth"
|
name = "kigi-auth"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-bin"
|
name = "kigi-bin"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-chat-state"
|
name = "kigi-chat-state"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"kigi-compaction",
|
"kigi-compaction",
|
||||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-codebase-graph"
|
name = "kigi-codebase-graph"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-compaction"
|
name = "kigi-compaction"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config"
|
name = "kigi-config"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config-types"
|
name = "kigi-config-types"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-crash-handler"
|
name = "kigi-crash-handler"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-env"
|
name = "kigi-env"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fast-worktree"
|
name = "kigi-fast-worktree"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-file-utils"
|
name = "kigi-file-utils"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fsnotify"
|
name = "kigi-fsnotify"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"criterion",
|
"criterion",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-gix-status"
|
name = "kigi-gix-status"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gix",
|
"gix",
|
||||||
"kigi-test-utils",
|
"kigi-test-utils",
|
||||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks"
|
name = "kigi-hooks"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"kigi-config",
|
"kigi-config",
|
||||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks-plugins-types"
|
name = "kigi-hooks-plugins-types"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-http"
|
name = "kigi-http"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-auth",
|
"kigi-auth",
|
||||||
"kigi-log",
|
"kigi-log",
|
||||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hunk-tracker"
|
name = "kigi-hunk-tracker"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-interjection-core"
|
name = "kigi-interjection-core"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-log"
|
name = "kigi-log"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown"
|
name = "kigi-markdown"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"anstyle-lossy",
|
"anstyle-lossy",
|
||||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown-core"
|
name = "kigi-markdown-core"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mcp"
|
name = "kigi-mcp"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-memory"
|
name = "kigi-memory"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mermaid"
|
name = "kigi-mermaid"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fontdb",
|
"fontdb",
|
||||||
"image",
|
"image",
|
||||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-models"
|
name = "kigi-models"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-env",
|
"kigi-env",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5969,7 +5969,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-minimal"
|
name = "kigi-pager-minimal"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
@@ -5986,7 +5986,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-pty-harness"
|
name = "kigi-pager-pty-harness"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6011,7 +6011,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-render"
|
name = "kigi-pager-render"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -6063,7 +6063,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-paths"
|
name = "kigi-paths"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"camino",
|
"camino",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6073,7 +6073,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-prompt-queue"
|
name = "kigi-prompt-queue"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6081,7 +6081,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-proto-build"
|
name = "kigi-proto-build"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pbjson-build",
|
"pbjson-build",
|
||||||
@@ -6092,7 +6092,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-inline"
|
name = "kigi-ratatui-inline"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ansi-width",
|
"ansi-width",
|
||||||
"anstyle-parse 0.2.7",
|
"anstyle-parse 0.2.7",
|
||||||
@@ -6109,7 +6109,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-textarea"
|
name = "kigi-ratatui-textarea"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6130,7 +6130,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampler"
|
name = "kigi-sampler"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-openai",
|
"async-openai",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -6153,7 +6153,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampling-types"
|
name = "kigi-sampling-types"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assert_matches",
|
"assert_matches",
|
||||||
"async-openai",
|
"async-openai",
|
||||||
@@ -6169,7 +6169,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sandbox"
|
name = "kigi-sandbox"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6190,7 +6190,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-secrets"
|
name = "kigi-secrets"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6228,7 +6228,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell"
|
name = "kigi-shell"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6365,7 +6365,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell-base"
|
name = "kigi-shell-base"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6390,7 +6390,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sqlite-journal"
|
name = "kigi-sqlite-journal"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -6401,7 +6401,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-subagent-resolution"
|
name = "kigi-subagent-resolution"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-sampling-types",
|
"kigi-sampling-types",
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
@@ -6416,7 +6416,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-system-power"
|
name = "kigi-system-power"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"zbus",
|
"zbus",
|
||||||
@@ -6424,7 +6424,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-support"
|
name = "kigi-test-support"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6446,7 +6446,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-utils"
|
name = "kigi-test-utils"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"runfiles",
|
"runfiles",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6455,11 +6455,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-token-estimation"
|
name = "kigi-token-estimation"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-protocol"
|
name = "kigi-tool-protocol"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6470,7 +6470,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-runtime"
|
name = "kigi-tool-runtime"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -6488,7 +6488,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-types"
|
name = "kigi-tool-types"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"minijinja",
|
"minijinja",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
@@ -6498,7 +6498,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools"
|
name = "kigi-tools"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -6575,7 +6575,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools-api"
|
name = "kigi-tools-api"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-proto-build",
|
"kigi-proto-build",
|
||||||
"kigi-tool-protocol",
|
"kigi-tool-protocol",
|
||||||
@@ -6588,11 +6588,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tracing-macros"
|
name = "kigi-tracing-macros"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tty-utils"
|
name = "kigi-tty-utils"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"nix 0.30.1",
|
"nix 0.30.1",
|
||||||
@@ -6602,7 +6602,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tui"
|
name = "kigi-tui"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"ansi-to-tui",
|
"ansi-to-tui",
|
||||||
@@ -6689,7 +6689,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-update"
|
name = "kigi-update"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -6718,14 +6718,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-version"
|
name = "kigi-version"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace"
|
name = "kigi-workspace"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6804,7 +6804,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace-types"
|
name = "kigi-workspace-types"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -8838,7 +8838,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl"
|
name = "ptyctl"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -8856,7 +8856,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl-cli"
|
name = "ptyctl-cli"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
|||||||
@@ -1912,6 +1912,51 @@ impl Config {
|
|||||||
.default(true)
|
.default(true)
|
||||||
.resolve()
|
.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
|
/// Classifier, planner, and summary all default to goal mode itself: when
|
||||||
/// `/goal` is on they are on unless config/env/remote says otherwise.
|
/// `/goal` is on they are on unless config/env/remote says otherwise.
|
||||||
/// `goal_enabled` is the session's already-resolved master switch (the same
|
/// `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_signals: None,
|
||||||
persisted_plan_mode: None,
|
persisted_plan_mode: None,
|
||||||
persisted_goal_mode: None,
|
persisted_goal_mode: None,
|
||||||
|
persisted_graph_mode: None,
|
||||||
persisted_announcement_state: None,
|
persisted_announcement_state: None,
|
||||||
session_meta: arguments.meta.as_ref(),
|
session_meta: arguments.meta.as_ref(),
|
||||||
model_agent_type: model_agent_type.as_deref(),
|
model_agent_type: model_agent_type.as_deref(),
|
||||||
@@ -985,6 +986,7 @@ impl acp::Agent for MvpAgent {
|
|||||||
signals: persisted_signals,
|
signals: persisted_signals,
|
||||||
announcement_state: persisted_announcement_state,
|
announcement_state: persisted_announcement_state,
|
||||||
goal_mode_state: _persisted_goal_mode,
|
goal_mode_state: _persisted_goal_mode,
|
||||||
|
graph_mode_state: _persisted_graph_mode,
|
||||||
} = persistence_info;
|
} = persistence_info;
|
||||||
let restored_awaiting_plan_approval = persisted_plan_mode
|
let restored_awaiting_plan_approval = persisted_plan_mode
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1207,6 +1209,7 @@ impl acp::Agent for MvpAgent {
|
|||||||
persisted_signals,
|
persisted_signals,
|
||||||
persisted_plan_mode,
|
persisted_plan_mode,
|
||||||
persisted_goal_mode: _persisted_goal_mode,
|
persisted_goal_mode: _persisted_goal_mode,
|
||||||
|
persisted_graph_mode: _persisted_graph_mode,
|
||||||
persisted_announcement_state,
|
persisted_announcement_state,
|
||||||
session_meta: request_meta.as_ref(),
|
session_meta: request_meta.as_ref(),
|
||||||
model_agent_type: persisted_agent_name.as_deref(),
|
model_agent_type: persisted_agent_name.as_deref(),
|
||||||
|
|||||||
@@ -310,8 +310,13 @@ impl MvpAgent {
|
|||||||
pub(crate) fn command_availability(
|
pub(crate) fn command_availability(
|
||||||
&self,
|
&self,
|
||||||
) -> crate::session::slash_commands::CommandAvailability {
|
) -> crate::session::slash_commands::CommandAvailability {
|
||||||
|
let goal = self.cfg.borrow().resolve_goal().value;
|
||||||
crate::session::slash_commands::CommandAvailability {
|
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()
|
..crate::session::slash_commands::CommandAvailability::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1677,6 +1682,7 @@ impl MvpAgent {
|
|||||||
persisted_signals,
|
persisted_signals,
|
||||||
persisted_plan_mode,
|
persisted_plan_mode,
|
||||||
persisted_goal_mode,
|
persisted_goal_mode,
|
||||||
|
persisted_graph_mode,
|
||||||
persisted_announcement_state,
|
persisted_announcement_state,
|
||||||
session_meta,
|
session_meta,
|
||||||
model_agent_type,
|
model_agent_type,
|
||||||
@@ -2111,6 +2117,7 @@ impl MvpAgent {
|
|||||||
let web_fetch_config = self.prepare_web_fetch_config();
|
let web_fetch_config = self.prepare_web_fetch_config();
|
||||||
let write_file_enabled = self.cfg.borrow().resolve_write_file().value;
|
let write_file_enabled = self.cfg.borrow().resolve_write_file().value;
|
||||||
let goal_enabled = self.cfg.borrow().resolve_goal().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 subagents_enabled = self.cfg.borrow().subagents_enabled;
|
||||||
let ask_user_question_enabled = parse_ask_user_question_from_meta(session_meta)
|
let ask_user_question_enabled = parse_ask_user_question_from_meta(session_meta)
|
||||||
.unwrap_or_else(|| self.cfg.borrow().resolve_ask_user_question().value);
|
.unwrap_or_else(|| self.cfg.borrow().resolve_ask_user_question().value);
|
||||||
@@ -2314,6 +2321,7 @@ impl MvpAgent {
|
|||||||
persisted_signals,
|
persisted_signals,
|
||||||
persisted_plan_mode,
|
persisted_plan_mode,
|
||||||
persisted_goal_mode,
|
persisted_goal_mode,
|
||||||
|
persisted_graph_mode,
|
||||||
persisted_announcement_state,
|
persisted_announcement_state,
|
||||||
self.memory_config.clone(),
|
self.memory_config.clone(),
|
||||||
feedback_flags,
|
feedback_flags,
|
||||||
@@ -2328,6 +2336,7 @@ impl MvpAgent {
|
|||||||
app_builder_deployer_config,
|
app_builder_deployer_config,
|
||||||
write_file_enabled,
|
write_file_enabled,
|
||||||
goal_enabled,
|
goal_enabled,
|
||||||
|
graph_enabled,
|
||||||
subagents_enabled,
|
subagents_enabled,
|
||||||
ask_user_question_enabled,
|
ask_user_question_enabled,
|
||||||
client_hooks,
|
client_hooks,
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ pub(crate) struct SessionSpawnOptions<'a> {
|
|||||||
pub persisted_signals: Option<crate::session::signals::SessionSignals>,
|
pub persisted_signals: Option<crate::session::signals::SessionSignals>,
|
||||||
pub persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
pub persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
||||||
pub persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
|
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<
|
pub persisted_announcement_state: Option<
|
||||||
crate::session::announcement_state::AnnouncementState,
|
crate::session::announcement_state::AnnouncementState,
|
||||||
>,
|
>,
|
||||||
@@ -257,6 +258,7 @@ pub(crate) fn chat_session_spawn_options<'a>(
|
|||||||
persisted_signals: None,
|
persisted_signals: None,
|
||||||
persisted_plan_mode: None,
|
persisted_plan_mode: None,
|
||||||
persisted_goal_mode: None,
|
persisted_goal_mode: None,
|
||||||
|
persisted_graph_mode: None,
|
||||||
persisted_announcement_state: None,
|
persisted_announcement_state: None,
|
||||||
session_meta,
|
session_meta,
|
||||||
model_agent_type,
|
model_agent_type,
|
||||||
|
|||||||
@@ -1036,6 +1036,7 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
if verbatim_mirror_fork {
|
if verbatim_mirror_fork {
|
||||||
None
|
None
|
||||||
} else if let Some(scope) = agent_memory_scope {
|
} 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.app_builder_deployer_config.clone(),
|
||||||
ctx.write_file_enabled,
|
ctx.write_file_enabled,
|
||||||
ctx.goal_enabled,
|
ctx.goal_enabled,
|
||||||
|
// Graph mode is a depth-0 harness; child sessions never drive it.
|
||||||
|
false,
|
||||||
true,
|
true,
|
||||||
ctx.ask_user_question_enabled,
|
ctx.ask_user_question_enabled,
|
||||||
ctx.client_hooks.clone(),
|
ctx.client_hooks.clone(),
|
||||||
|
|||||||
@@ -897,6 +897,36 @@ pub enum SessionUpdate {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
usage: Option<PromptUsage>,
|
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.
|
/// Catch-all for unrecognized session update types.
|
||||||
/// Allows forward/backward compatibility when variants are added or removed.
|
/// Allows forward/backward compatibility when variants are added or removed.
|
||||||
/// All fields from the unrecognized variant are discarded during deserialization.
|
/// 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());
|
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};
|
pub use types::{TodoGateDecision, TodoGateReason};
|
||||||
#[path = "acp_session_impl/goal.rs"]
|
#[path = "acp_session_impl/goal.rs"]
|
||||||
mod goal;
|
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"]
|
#[path = "acp_session_impl/interjection.rs"]
|
||||||
mod interjection;
|
mod interjection;
|
||||||
#[path = "acp_session_impl/tool_calls.rs"]
|
#[path = "acp_session_impl/tool_calls.rs"]
|
||||||
@@ -589,6 +597,34 @@ pub(crate) struct SessionActor {
|
|||||||
/// Goal mode orchestration tracker. Session-scoped state for the
|
/// Goal mode orchestration tracker. Session-scoped state for the
|
||||||
/// Design-Execute-Verify loop. Modeled after `plan_mode` above.
|
/// Design-Execute-Verify loop. Modeled after `plan_mode` above.
|
||||||
pub(crate) goal_tracker: Arc<parking_lot::Mutex<crate::session::goal_tracker::GoalTracker>>,
|
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
|
/// `task_id`s of background tasks (and monitors) that originated during
|
||||||
/// the goal turn — either spawned by the goal model itself or reparented
|
/// the goal turn — either spawned by the goal model itself or reparented
|
||||||
/// from a harness verifier/planner subagent on its exit. Their late
|
/// from a harness verifier/planner subagent on its exit. Their late
|
||||||
@@ -985,6 +1021,9 @@ impl SessionActor {
|
|||||||
hooks: self.hook_registry.borrow().is_some(),
|
hooks: self.hook_registry.borrow().is_some(),
|
||||||
plugins: self.plugin_registry.borrow().is_some(),
|
plugins: self.plugin_registry.borrow().is_some(),
|
||||||
goal,
|
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.
|
/// 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"]
|
#[path = "acp_session_tests/goal/goal_summarizer_e2e_tests.rs"]
|
||||||
mod goal_summarizer_e2e_tests;
|
mod goal_summarizer_e2e_tests;
|
||||||
#[cfg(test)]
|
#[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"]
|
#[path = "acp_session_tests/idle_resume_tests.rs"]
|
||||||
mod idle_resume_tests;
|
mod idle_resume_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1904,6 +1904,29 @@ impl SessionActor {
|
|||||||
tokens_used,
|
tokens_used,
|
||||||
finished_marginal,
|
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!(
|
self.send_slash_command_output(&format!(
|
||||||
"Goal token budget reached ({tokens_used} of {budget} tokens) — goal \
|
"Goal token budget reached ({tokens_used} of {budget} tokens) — goal \
|
||||||
stopped. Use /goal clear, then /goal <objective> to start a new one."
|
stopped. Use /goal clear, then /goal <objective> to start a new one."
|
||||||
@@ -2265,18 +2288,21 @@ impl SessionActor {
|
|||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
|
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();
|
let mut tracker = self.goal_tracker.lock();
|
||||||
if tracker.status() != Some(crate::session::goal_tracker::GoalStatus::Active) {
|
if tracker.status() == Some(crate::session::goal_tracker::GoalStatus::Active) {
|
||||||
return false;
|
// Active is guaranteed here, so the transition succeeds.
|
||||||
}
|
|
||||||
// The early-return above guarantees `Active`, so the pause
|
|
||||||
// transition always succeeds here.
|
|
||||||
match message {
|
match message {
|
||||||
Some(msg) => tracker.pause_with_message(reason, msg),
|
Some(msg) => tracker.pause_with_message(reason, msg),
|
||||||
None => tracker.pause(reason),
|
None => tracker.pause(reason),
|
||||||
};
|
};
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if goal_paused {
|
||||||
self.clear_pending_classifier_completions();
|
self.clear_pending_classifier_completions();
|
||||||
let (tokens_used, finished_marginal) = self.goal_tokens(current_tokens);
|
let (tokens_used, finished_marginal) = self.goal_tokens(current_tokens);
|
||||||
let notify = self.goal_notify_sender();
|
let notify = self.goal_notify_sender();
|
||||||
@@ -2288,7 +2314,31 @@ impl SessionActor {
|
|||||||
self.emit_event(crate::session::events::Event::GoalAutoPaused {
|
self.emit_event(crate::session::events::Event::GoalAutoPaused {
|
||||||
reason: reason.into(),
|
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
|
/// 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)
|
ok_end_turn(0, None)
|
||||||
}
|
}
|
||||||
BuiltinAction::GoalPause => {
|
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;
|
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
|
||||||
use crate::session::goal_tracker::{GoalPauseReason, GoalStatus};
|
use crate::session::goal_tracker::{GoalPauseReason, GoalStatus};
|
||||||
let (msg, changed) = {
|
let (msg, changed) = {
|
||||||
let mut tracker = self.goal_tracker.lock();
|
let mut tracker = self.goal_tracker.lock();
|
||||||
match tracker.status() {
|
match tracker.status() {
|
||||||
Some(GoalStatus::Active) => {
|
Some(GoalStatus::Active) => {
|
||||||
debug_assert!(
|
// Side effect OUTSIDE the assert: debug_assert!
|
||||||
tracker.pause(GoalPauseReason::User),
|
// strips its condition in release builds, which
|
||||||
"Active goal must pause"
|
// 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)
|
("Goal paused. Use /goal resume to continue.", true)
|
||||||
}
|
}
|
||||||
Some(
|
Some(
|
||||||
@@ -790,27 +798,149 @@ impl SessionActor {
|
|||||||
unreachable!("GoalResume is intercepted in handle_prompt")
|
unreachable!("GoalResume is intercepted in handle_prompt")
|
||||||
}
|
}
|
||||||
BuiltinAction::GoalClear => {
|
BuiltinAction::GoalClear => {
|
||||||
self.goal_tracker.lock().clear();
|
if self.graph_owns_goal_engine() {
|
||||||
// `/goal clear` is a deliberate user reset — drop both
|
self.send_slash_command_output(
|
||||||
// streaks so stale counters from the previous goal
|
"A graph owns the goal engine. Use /graph clear instead.",
|
||||||
// can't leak into the next one.
|
)
|
||||||
self.goal_continuation_streak
|
.await;
|
||||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
return ok_end_turn(0, None);
|
||||||
self.goal_blocked_streak
|
}
|
||||||
.store(0, std::sync::atomic::Ordering::Relaxed);
|
// `/goal clear` is a deliberate user reset — the shared
|
||||||
// Drop goal-turn-origin task ids so a future goal's drain
|
// helper drops the tracker, both streaks, goal-turn task
|
||||||
// doesn't suppress the next goal's (or post-goal) tasks.
|
// ids, per-subagent token records, pending classifier
|
||||||
self.goal_turn_task_ids.lock().clear();
|
// claims, and notifies the pager. Shared with the graph
|
||||||
// Clear per-subagent token records so stale entries
|
// node boundary and `/graph clear`.
|
||||||
// from the previous goal don't leak into the next.
|
self.reset_goal_engine_state().await;
|
||||||
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;
|
|
||||||
self.send_slash_command_output("Goal cleared.").await;
|
self.send_slash_command_output("Goal cleared.").await;
|
||||||
ok_end_turn(0, None)
|
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_signals: Option<crate::session::signals::SessionSignals>,
|
||||||
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
||||||
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
|
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>,
|
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||||
memory_config: Option<crate::config::MemoryConfig>,
|
memory_config: Option<crate::config::MemoryConfig>,
|
||||||
feedback_flags: crate::session::feedback_manager::FeedbackFlags,
|
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,
|
app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig,
|
||||||
write_file_enabled: bool,
|
write_file_enabled: bool,
|
||||||
goal_enabled: bool,
|
goal_enabled: bool,
|
||||||
|
graph_enabled: bool,
|
||||||
subagents_enabled: bool,
|
subagents_enabled: bool,
|
||||||
ask_user_question_enabled: bool,
|
ask_user_question_enabled: bool,
|
||||||
client_hooks: crate::extensions::hooks::ClientHooks,
|
client_hooks: crate::extensions::hooks::ClientHooks,
|
||||||
@@ -429,6 +431,17 @@ pub(crate) async fn spawn_session_actor(
|
|||||||
};
|
};
|
||||||
Arc::new(parking_lot::Mutex::new(tracker))
|
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 current_prompt_mode = Arc::new(parking_lot::Mutex::new(PromptMode::Agent));
|
||||||
let turn_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());
|
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_enabled: std::sync::atomic::AtomicBool::new(false),
|
||||||
goal_harness_availability_reconciled: std::sync::atomic::AtomicBool::new(false),
|
goal_harness_availability_reconciled: std::sync::atomic::AtomicBool::new(false),
|
||||||
goal_tracker,
|
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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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;
|
.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 {
|
if let Some(ref display_cwd) = prompt_display_cwd {
|
||||||
session
|
session
|
||||||
.agent
|
.agent
|
||||||
@@ -1511,6 +1544,7 @@ pub(crate) async fn spawn_session_on_thread(
|
|||||||
persisted_signals: Option<crate::session::signals::SessionSignals>,
|
persisted_signals: Option<crate::session::signals::SessionSignals>,
|
||||||
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
||||||
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
|
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>,
|
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||||
memory_config: Option<crate::config::MemoryConfig>,
|
memory_config: Option<crate::config::MemoryConfig>,
|
||||||
feedback_flags: crate::session::feedback_manager::FeedbackFlags,
|
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,
|
app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig,
|
||||||
write_file_enabled: bool,
|
write_file_enabled: bool,
|
||||||
goal_enabled: bool,
|
goal_enabled: bool,
|
||||||
|
graph_enabled: bool,
|
||||||
subagents_enabled: bool,
|
subagents_enabled: bool,
|
||||||
ask_user_question_enabled: bool,
|
ask_user_question_enabled: bool,
|
||||||
client_hooks: crate::extensions::hooks::ClientHooks,
|
client_hooks: crate::extensions::hooks::ClientHooks,
|
||||||
@@ -1653,6 +1688,7 @@ pub(crate) async fn spawn_session_on_thread(
|
|||||||
persisted_signals,
|
persisted_signals,
|
||||||
persisted_plan_mode,
|
persisted_plan_mode,
|
||||||
persisted_goal_mode,
|
persisted_goal_mode,
|
||||||
|
persisted_graph_mode,
|
||||||
persisted_announcement_state,
|
persisted_announcement_state,
|
||||||
memory_config,
|
memory_config,
|
||||||
feedback_flags,
|
feedback_flags,
|
||||||
@@ -1667,6 +1703,7 @@ pub(crate) async fn spawn_session_on_thread(
|
|||||||
app_builder_deployer_config,
|
app_builder_deployer_config,
|
||||||
write_file_enabled,
|
write_file_enabled,
|
||||||
goal_enabled,
|
goal_enabled,
|
||||||
|
graph_enabled,
|
||||||
subagents_enabled,
|
subagents_enabled,
|
||||||
ask_user_question_enabled,
|
ask_user_question_enabled,
|
||||||
client_hooks,
|
client_hooks,
|
||||||
|
|||||||
@@ -478,6 +478,14 @@ impl SessionActor {
|
|||||||
if let Some(running_task) = running_task {
|
if let Some(running_task) = running_task {
|
||||||
running_task.abort();
|
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
|
// The aborted turn's `BlockingWaitGuard`s drop asynchronously (they
|
||||||
// live in tool futures owned by the drainer task / subagent spawn
|
// live in tool futures owned by the drainer task / subagent spawn
|
||||||
// task). Until they do, `queue_input` would read a stale depth > 0 and
|
// task). Until they do, `queue_input` would read a stale depth > 0 and
|
||||||
|
|||||||
@@ -305,10 +305,28 @@ impl SessionActor {
|
|||||||
objective,
|
objective,
|
||||||
token_budget,
|
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;
|
let reminder = self.setup_goal(&objective, token_budget).await;
|
||||||
vec![text_block(reminder), text_block(objective)]
|
vec![text_block(reminder), text_block(objective)]
|
||||||
}
|
}
|
||||||
BuiltinAction::GoalResume => match self.resume_goal().await {
|
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);
|
||||||
|
}
|
||||||
|
match self.resume_goal().await {
|
||||||
GoalResumeOutcome::Inference { reminder, user_msg } => {
|
GoalResumeOutcome::Inference { reminder, user_msg } => {
|
||||||
self.send_slash_command_output(&user_msg).await;
|
self.send_slash_command_output(&user_msg).await;
|
||||||
vec![text_block(reminder)]
|
vec![text_block(reminder)]
|
||||||
@@ -317,7 +335,33 @@ impl SessionActor {
|
|||||||
self.send_slash_command_output(&msg).await;
|
self.send_slash_command_output(&msg).await;
|
||||||
return ok_end_turn(0, None);
|
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,
|
_ => return self.execute_builtin_slash_command(action).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -688,13 +732,35 @@ impl SessionActor {
|
|||||||
self.goal_tracker.lock().status(),
|
self.goal_tracker.lock().status(),
|
||||||
);
|
);
|
||||||
if !goal_active {
|
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 {
|
match self.run_goal_round_end().await {
|
||||||
GoalRoundDecision::Continue(directive) => {
|
GoalRoundDecision::Continue(directive) => {
|
||||||
self.inject_goal_continuation_message(directive).await;
|
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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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",
|
"/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_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
|
||||||
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
|
||||||
goal_blocked_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 {
|
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 {
|
match self {
|
||||||
Self::User => GoalStatus::UserPaused,
|
Self::User => GoalStatus::UserPaused,
|
||||||
Self::BackOff => GoalStatus::BackOffPaused,
|
Self::BackOff => GoalStatus::BackOffPaused,
|
||||||
@@ -166,7 +168,8 @@ impl GoalPauseReason {
|
|||||||
|
|
||||||
/// Short, stable label stashed in the `GoalPaused` history entry's
|
/// Short, stable label stashed in the `GoalPaused` history entry's
|
||||||
/// `detail` so the pager's Recent History distinguishes pause causes.
|
/// `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 {
|
match self {
|
||||||
Self::User => "user",
|
Self::User => "user",
|
||||||
Self::BackOff => "back_off",
|
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_strategist;
|
||||||
pub(crate) mod goal_summarizer;
|
pub(crate) mod goal_summarizer;
|
||||||
pub mod goal_tracker;
|
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 mod helpers;
|
||||||
pub(crate) mod image_describe;
|
pub(crate) mod image_describe;
|
||||||
pub(crate) mod image_normalize;
|
pub(crate) mod image_normalize;
|
||||||
|
|||||||
@@ -335,6 +335,10 @@ pub enum PersistenceMsg {
|
|||||||
AnnouncementState(crate::session::announcement_state::AnnouncementState),
|
AnnouncementState(crate::session::announcement_state::AnnouncementState),
|
||||||
/// Persist goal mode orchestration state.
|
/// Persist goal mode orchestration state.
|
||||||
GoalModeState(crate::session::goal_tracker::GoalOrchestration),
|
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)
|
/// Persist a local feedback entry (user feedback)
|
||||||
Feedback(LocalFeedbackEntry),
|
Feedback(LocalFeedbackEntry),
|
||||||
/// Persist a /btw side question entry
|
/// Persist a /btw side question entry
|
||||||
@@ -1592,6 +1596,15 @@ impl SessionPersistence {
|
|||||||
tracing::warn!(?e, "failed to write goal mode state");
|
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) => {
|
PersistenceMsg::ContentChunk(content_chunks) => {
|
||||||
let content_part = content_chunks
|
let content_part = content_chunks
|
||||||
.content_chunks
|
.content_chunks
|
||||||
@@ -2095,6 +2108,8 @@ pub struct PersistedInfoLight {
|
|||||||
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||||
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
||||||
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
|
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.
|
/// Loads a session for streaming updates without reading them into memory.
|
||||||
@@ -2131,6 +2146,7 @@ pub(crate) async fn load_light(
|
|||||||
signals: persisted.signals,
|
signals: persisted.signals,
|
||||||
announcement_state: persisted.announcement_state,
|
announcement_state: persisted.announcement_state,
|
||||||
goal_mode_state: persisted.goal_mode_state,
|
goal_mode_state: persisted.goal_mode_state,
|
||||||
|
graph_mode_state: persisted.graph_mode_state,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (tx, rx) = mpsc::unbounded_channel::<PersistenceMsg>();
|
let (tx, rx) = mpsc::unbounded_channel::<PersistenceMsg>();
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ pub(crate) enum BuiltinGate {
|
|||||||
Hooks,
|
Hooks,
|
||||||
Plugins,
|
Plugins,
|
||||||
Goal,
|
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.
|
/// 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.
|
/// Split a trailing `--budget <tokens>` flag off a `/goal` objective.
|
||||||
@@ -387,6 +435,9 @@ pub(crate) struct CommandAvailability {
|
|||||||
pub hooks: bool,
|
pub hooks: bool,
|
||||||
pub plugins: bool,
|
pub plugins: bool,
|
||||||
pub goal: 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 {
|
impl CommandAvailability {
|
||||||
@@ -401,6 +452,7 @@ impl CommandAvailability {
|
|||||||
BuiltinGate::Hooks => self.hooks,
|
BuiltinGate::Hooks => self.hooks,
|
||||||
BuiltinGate::Plugins => self.plugins,
|
BuiltinGate::Plugins => self.plugins,
|
||||||
BuiltinGate::Goal => self.goal,
|
BuiltinGate::Goal => self.goal,
|
||||||
|
BuiltinGate::Graph => self.graph,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +468,7 @@ impl CommandAvailability {
|
|||||||
hooks: true,
|
hooks: true,
|
||||||
plugins: true,
|
plugins: true,
|
||||||
goal: true,
|
goal: true,
|
||||||
|
graph: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -660,6 +713,17 @@ pub(super) enum BuiltinAction {
|
|||||||
GoalPause,
|
GoalPause,
|
||||||
GoalResume,
|
GoalResume,
|
||||||
GoalClear,
|
GoalClear,
|
||||||
|
GraphSet {
|
||||||
|
objective: String,
|
||||||
|
token_budget: Option<i64>,
|
||||||
|
},
|
||||||
|
GraphStatus,
|
||||||
|
GraphShow,
|
||||||
|
GraphPause,
|
||||||
|
GraphResume {
|
||||||
|
extra_budget: Option<i64>,
|
||||||
|
},
|
||||||
|
GraphClear,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BuiltinAction {
|
impl BuiltinAction {
|
||||||
@@ -692,6 +756,12 @@ impl BuiltinAction {
|
|||||||
| BuiltinAction::GoalPause
|
| BuiltinAction::GoalPause
|
||||||
| BuiltinAction::GoalResume
|
| BuiltinAction::GoalResume
|
||||||
| BuiltinAction::GoalClear => "goal",
|
| 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::GoalPause
|
||||||
| BuiltinAction::GoalResume
|
| BuiltinAction::GoalResume
|
||||||
| BuiltinAction::GoalClear => false,
|
| 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",
|
"session-info",
|
||||||
"feedback",
|
"feedback",
|
||||||
"goal",
|
"goal",
|
||||||
|
"graph",
|
||||||
"loop",
|
"loop",
|
||||||
"commit",
|
"commit",
|
||||||
"deploy",
|
"deploy",
|
||||||
@@ -1606,6 +1683,88 @@ mod tests {
|
|||||||
assert!(!names.iter().any(|n| n == "goal"), "got: {names:?}");
|
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]
|
#[test]
|
||||||
fn goal_does_not_resolve_when_update_goal_unavailable() {
|
fn goal_does_not_resolve_when_update_goal_unavailable() {
|
||||||
let availability = CommandAvailability {
|
let availability = CommandAvailability {
|
||||||
@@ -1718,6 +1877,7 @@ mod tests {
|
|||||||
"memory",
|
"memory",
|
||||||
"feedback",
|
"feedback",
|
||||||
"goal",
|
"goal",
|
||||||
|
"graph",
|
||||||
"hooks-list",
|
"hooks-list",
|
||||||
"plugins",
|
"plugins",
|
||||||
"reload-plugins",
|
"reload-plugins",
|
||||||
@@ -2074,6 +2234,7 @@ mod tests {
|
|||||||
"dream",
|
"dream",
|
||||||
"feedback",
|
"feedback",
|
||||||
"goal",
|
"goal",
|
||||||
|
"graph",
|
||||||
"loop",
|
"loop",
|
||||||
"hooks-list",
|
"hooks-list",
|
||||||
"hooks-trust",
|
"hooks-trust",
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ impl JsonlStorageAdapter {
|
|||||||
fn goal_mode_state_file(&self, info: &Info) -> PathBuf {
|
fn goal_mode_state_file(&self, info: &Info) -> PathBuf {
|
||||||
self.session_dir(info).join("goal").join("state.json")
|
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 {
|
fn rewind_points_file(&self, info: &Info) -> PathBuf {
|
||||||
self.session_dir(info).join("rewind_points.jsonl")
|
self.session_dir(info).join("rewind_points.jsonl")
|
||||||
}
|
}
|
||||||
@@ -1095,6 +1098,29 @@ impl StorageAdapter for JsonlStorageAdapter {
|
|||||||
tokio::fs::write(&tmp, json).await?;
|
tokio::fs::write(&tmp, json).await?;
|
||||||
tokio::fs::rename(&tmp, &target).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> {
|
async fn load_session(&self, info: &Info) -> io::Result<PersistedData> {
|
||||||
let summary = self.read_summary_sync(info)?;
|
let summary = self.read_summary_sync(info)?;
|
||||||
let chat_history =
|
let chat_history =
|
||||||
@@ -1116,6 +1142,10 @@ impl StorageAdapter for JsonlStorageAdapter {
|
|||||||
.read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>(
|
.read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>(
|
||||||
&self.goal_mode_state_file(info),
|
&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 rewind_points = self.read_jsonl::<RewindPoint>(self.rewind_points_file(info))?;
|
||||||
let result = PersistedData {
|
let result = PersistedData {
|
||||||
summary,
|
summary,
|
||||||
@@ -1127,6 +1157,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
|||||||
signals,
|
signals,
|
||||||
announcement_state,
|
announcement_state,
|
||||||
goal_mode_state,
|
goal_mode_state,
|
||||||
|
graph_mode_state,
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
session_id = % info.id, num_chat_messages = result.chat_history.len(),
|
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>(
|
.read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>(
|
||||||
&self.goal_mode_state_file(info),
|
&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 {
|
let result = super::PersistedDataLight {
|
||||||
summary,
|
summary,
|
||||||
chat_history,
|
chat_history,
|
||||||
@@ -1172,6 +1207,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
|||||||
signals,
|
signals,
|
||||||
announcement_state,
|
announcement_state,
|
||||||
goal_mode_state,
|
goal_mode_state,
|
||||||
|
graph_mode_state,
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
session_id = % info.id, num_chat_messages = result.chat_history.len(),
|
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"
|
"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>,
|
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||||
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
||||||
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
|
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
|
/// 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>,
|
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||||
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
||||||
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
|
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
|
/// Result of copying session data
|
||||||
@@ -595,6 +599,14 @@ pub trait StorageAdapter: Send + Sync {
|
|||||||
state: &crate::session::goal_tracker::GoalOrchestration,
|
state: &crate::session::goal_tracker::GoalOrchestration,
|
||||||
) -> io::Result<()>;
|
) -> 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
|
/// Load all persisted data for a session
|
||||||
async fn load_session(&self, info: &Info) -> io::Result<PersistedData>;
|
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
|
||||||
|
```
|
||||||
@@ -977,6 +977,37 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
|
|||||||
XaiSessionUpdate::InteractionResolved { tool_call_id } => {
|
XaiSessionUpdate::InteractionResolved { tool_call_id } => {
|
||||||
agent.dismiss_resolved_interaction(&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!(
|
tracing::trace!(
|
||||||
"Ignoring {}: {:?}",
|
"Ignoring {}: {:?}",
|
||||||
|
|||||||
@@ -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`
|
/// Display state for an active goal, populated from `GoalUpdated`
|
||||||
/// session notifications emitted by the goal orchestrator.
|
/// session notifications emitted by the goal orchestrator.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -745,6 +745,9 @@ pub struct AgentView {
|
|||||||
/// Current goal orchestration state. Set by `GoalUpdated` session
|
/// Current goal orchestration state. Set by `GoalUpdated` session
|
||||||
/// notifications, cleared when a new session starts.
|
/// notifications, cleared when a new session starts.
|
||||||
pub goal_state: Option<super::agent::GoalDisplayState>,
|
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.
|
/// 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
|
/// Keyed by prompt id: a new turn naturally invalidates the slot with no
|
||||||
/// explicit clear site. See [`ParkedMarkerSlot`].
|
/// explicit clear site. See [`ParkedMarkerSlot`].
|
||||||
|
|||||||
@@ -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| {
|
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)
|
crate::views::agent_status::mcp_status_line(p, self.scrollback.animation_tick(), &theme)
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ impl AgentView {
|
|||||||
chat_kind: false,
|
chat_kind: false,
|
||||||
app_chat_mode: false,
|
app_chat_mode: false,
|
||||||
goal_state: None,
|
goal_state: None,
|
||||||
|
graph_state: None,
|
||||||
parked_wait_marker_for: None,
|
parked_wait_marker_for: None,
|
||||||
end_work_announced: false,
|
end_work_announced: false,
|
||||||
pending_stop_hooks: None,
|
pending_stop_hooks: None,
|
||||||
|
|||||||
@@ -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
|
// MCP connecting indicator
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
//! phase model: the terminator is the ellipse `x = cos(2πp)·√(1−y²)`, with
|
//! 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
|
//! 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
|
//! 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
|
//! Hidden entirely on legacy Windows consoles: the U+2800 braille block is
|
||||||
//! not covered by the ConHost raster fonts and would render as tofu.
|
//! 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.
|
/// Squared inner radius (normalized) of the dark-limb outline ring.
|
||||||
const RING_INNER_SQ: f32 = 0.82;
|
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.
|
/// One logo size tier, in braille cells.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
struct MoonSize {
|
struct MoonSize {
|
||||||
@@ -152,8 +177,8 @@ fn moon_cells(size: MoonSize, p: f32) -> Vec<Vec<Option<MoonCell>>> {
|
|||||||
.map(|cell_row| {
|
.map(|cell_row| {
|
||||||
(0..size.cols as i32)
|
(0..size.cols as i32)
|
||||||
.map(|cell_col| {
|
.map(|cell_col| {
|
||||||
let mut mask = 0u32;
|
let mut lit_mask = 0u32;
|
||||||
let mut lit = false;
|
let mut dark_mask = 0u32;
|
||||||
for (dot_col, col_bits) in DOT_BITS.iter().enumerate() {
|
for (dot_col, col_bits) in DOT_BITS.iter().enumerate() {
|
||||||
for (dot_row, bit) in col_bits.iter().enumerate() {
|
for (dot_row, bit) in col_bits.iter().enumerate() {
|
||||||
let x = cell_col * 2 + dot_col as i32;
|
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 {
|
if d_sq > 1.0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let mare = in_mare(dx, dy);
|
||||||
if dot_lit(dx, dy, p) {
|
if dot_lit(dx, dy, p) {
|
||||||
mask |= bit;
|
if mare {
|
||||||
lit = true;
|
// Dark blotch on the sunlit disc.
|
||||||
} else if d_sq >= RING_INNER_SQ {
|
dark_mask |= bit;
|
||||||
// Dark limb: keep the outline ring visible.
|
} else {
|
||||||
mask |= bit;
|
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 {
|
(mask != 0).then(|| MoonCell {
|
||||||
ch: char::from_u32(0x2800 + mask).expect("braille block"),
|
ch: char::from_u32(0x2800 + mask).expect("braille block"),
|
||||||
lit,
|
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]
|
#[test]
|
||||||
fn moon_raster_is_round_and_fills_the_grid() {
|
fn moon_raster_is_round_and_fills_the_grid() {
|
||||||
// The disc must span (nearly) the whole cell grid in both axes at
|
// The disc must span (nearly) the whole cell grid in both axes at
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -339,6 +339,21 @@ async fn scripted_goal_slash_presession() {
|
|||||||
run_scenario("goal_slash_presession.yaml").await;
|
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
|
/// Counterpart to `scripted_goal_slash_presession`: with the goal flag
|
||||||
/// explicitly off (`KIGI_GOAL=0`; goal mode defaults on), `/goal` must stay
|
/// explicitly off (`KIGI_GOAL=0`; goal mode defaults on), `/goal` must stay
|
||||||
/// hidden pre-session (gate fail-closed) while an
|
/// hidden pre-session (gate fail-closed) while an
|
||||||
@@ -516,6 +531,8 @@ fn scenarios_parse() {
|
|||||||
"path_space_hyperlink.yaml",
|
"path_space_hyperlink.yaml",
|
||||||
"goal_slash_presession.yaml",
|
"goal_slash_presession.yaml",
|
||||||
"goal_slash_presession_disabled.yaml",
|
"goal_slash_presession_disabled.yaml",
|
||||||
|
"graph_slash_presession.yaml",
|
||||||
|
"graph_slash_presession_disabled.yaml",
|
||||||
"folder_trust_prompt.yaml",
|
"folder_trust_prompt.yaml",
|
||||||
"dashboard_model_list_click.yaml",
|
"dashboard_model_list_click.yaml",
|
||||||
"paste_chip_double_click.yaml",
|
"paste_chip_double_click.yaml",
|
||||||
|
|||||||
@@ -2090,16 +2090,22 @@ async fn get_apply_context(worktree_path: &str) -> Result<ApplyContext> {
|
|||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_file_at_commit(worktree_path: &str, commit: &str, path: &str) -> Option<String> {
|
/// Raw blob bytes at `commit:path`, or `None` when absent. Byte-exact
|
||||||
git_cli(
|
/// (NOT `git_cli`, which lossy-decodes and trims): the 3-way merge below
|
||||||
Path::new(worktree_path),
|
/// must compare content byte-for-byte or binary files mis-merge.
|
||||||
&["show", &format!("{}:{}", commit, path)],
|
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
|
.await
|
||||||
.ok()
|
.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 {
|
match content {
|
||||||
Some(data) => {
|
Some(data) => {
|
||||||
if let Some(parent) = dest.parent() {
|
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> {
|
pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeResponse> {
|
||||||
let worktree_path = &req.worktree_path;
|
let worktree_path = &req.worktree_path;
|
||||||
let git_root = find_main_repo_root_from_path(Path::new(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 {
|
for file_change in ctx.changed_files {
|
||||||
let worktree_file = Path::new(worktree_path).join(&file_change.path);
|
let worktree_file = Path::new(worktree_path).join(&file_change.path);
|
||||||
let main_file = git_root.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 req.mode == ApplyMode::Overwrite {
|
||||||
if apply_file_content(&main_file, theirs.as_ref()).await {
|
if apply_file_content(&main_file, theirs.as_ref()).await {
|
||||||
@@ -2142,21 +2156,28 @@ pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeR
|
|||||||
continue;
|
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 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 {
|
if base == ours {
|
||||||
|
// Main side untouched: take the worktree's version.
|
||||||
if apply_file_content(&main_file, theirs.as_ref()).await {
|
if apply_file_content(&main_file, theirs.as_ref()).await {
|
||||||
files.push(file_change);
|
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 {
|
} else if base != theirs {
|
||||||
conflicts.push(FileConflict {
|
conflicts.push(FileConflict {
|
||||||
path: file_change.path,
|
path: file_change.path,
|
||||||
change_type: file_change.change_type,
|
change_type: file_change.change_type,
|
||||||
base,
|
base: conflict_text(&base),
|
||||||
ours,
|
ours: conflict_text(&ours),
|
||||||
theirs,
|
theirs: conflict_text(&theirs),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user