9 Commits
Author SHA1 Message Date
ZacharyZhang-NY d6f216facd Fix Windows build: portable lock-contention check in graph_project
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
libc::EWOULDBLOCK is unix-only (kigi-shell links libc behind cfg(unix));
the v0.1.3 windows-msvc release build failed on it. fs2 exposes
lock_contended_error() precisely as the cross-platform classifier
(EWOULDBLOCK on unix, ERROR_LOCK_VIOLATION on Windows) — use it instead
of the raw errno. No behavior change on unix; graph_project lock tests
green.
2026-07-20 21:20:43 -04:00
ZacharyZhang-NY 4d2cf7ffd4 Bump version to 0.1.3 2026-07-20 20:46:35 -04:00
ZacharyZhang-NY 02cf5deebd Add /graph G6: plan-boundary topology optimizer
A restricted optimizer pass now reviews the graph at plan boundaries —
right after initial planning and piggybacked on each replan version
boundary, never mid-execution. An optimizer subagent may emit four ops
over Waiting/Ready nodes only: remove_dep (delete a false dependency,
restoring parallelism — the highest-value edit), reorder (pending
priority for the serial scheduler), merge (fold two tiny nodes; specs
concatenate, deps union, dependents re-point, absorbed self-deps drop),
and split (2-3 focused replacements inheriting the original's deps and
dependents). The optimizer changes graph DATA only; the executor stays
pure deterministic Rust. KIGI_GRAPH_OPTIMIZER=0 disables it entirely.

apply_optimization enforces the contract twice: per-op checks
(pending-only targets, known ids, terminal node untouchable, dead-node
deps rejected as DeadDep, merge/split targets with non-pending
dependents rejected with the true reason instead of tripping the
immutable invariant later), then FINAL invariants — every non-pending
node byte-identical in the result, the gn-final gate rebuilt over all
survivors, node cap, whole-graph acyclicity, and a BIDIRECTIONAL status
re-derivation for pending nodes (adversarial review caught the critical
hole: a merge grafting unsatisfied deps onto a Ready node would
otherwise dispatch it ahead of its new prerequisites, since
recompute_ready is promote-only). Applied passes bump plan_version,
freeze an immutable baseline, and consume a slot of the SHARED replan
cap; an explicit {"ops": []} is a respected free no-op; any failure
degrades to keeping the current plan. Plumbing reuses a new shared
artifact-pass runner (stale-artifact delete, size cap, missing-file
fail-closed) extracted from the replanner.

Tests: remove_dep parallelism restore + loud no-such-dep, immutable and
terminal-node rejections across all four ops, merge/split dependent
rewiring incl. final-gate rebuild and intra-split dep resolution,
result-cycle rejection, dead-dep splits, Ready-demote-on-merge, and
three e2e flows — false-dep removal proven ACTUALLY parallel by the
held-reply fan-out gate, OPTIMIZER=0 spawning zero passes, and the
shared-cap guard. kigi-shell 4959 lib tests green; clippy clean.
2026-07-20 20:21:49 -04:00
ZacharyZhang-NY db05ed0751 Add /graph G5: box-drawing DAG rendering for /graph show
/graph show now renders the dependency graph as box-drawing text via a
deterministic Sugiyama-lite pipeline in session/graph_render.rs:
longest-path layering, dagre-style dummy pass-throughs so every drawn
edge spans exactly one layer gap (long edges route as vertical lanes
through intermediate bands), one-pass barycenter ordering, and greedy
bus-lane allocation in the connector gutters with box-drawing-aware
glyph merging (a bus crossing a pass-through renders ┼). Node boxes
carry a status glyph (✓ ▶ ○ · ✗ ⊘) and a clamped title; a legend line
closes the view. Only Blocks edges are drawn — DiscoveredFrom is audit
metadata whose origin is always terminal, so drawing it would double
edges without scheduling meaning.

The output rides ordinary scrollback (the pager already scrolls it), so
no new wire variant or pager view was needed. Honest ceiling: a graph
wider than the 120-column budget — or an empty/pre-planning graph —
degrades to the indented status tree, because box art wrapped by the
terminal is worse than no art; a cycle reaching the renderer (upstream
validation bypassed) refuses to render rather than looping.

Tests: six-node structural snapshot (layering order, fan-in bands,
arrowheads, corners, legend, no trailing whitespace, width bound),
determinism across runs, too-wide fallback, dummy-lane routing through
intermediate bands, DiscoveredFrom exclusion, title clamping, empty
graph; plus a handle_prompt e2e driving /graph show end to end (no-graph
degrade + seeded chain rendering). kigi-shell 4949 lib tests green;
workspace clippy clean.
2026-07-20 19:53:41 -04:00
ZacharyZhang-NY 771de1822d Add /graph G4: project-level shared graph in .kigi/graph.jsonl
The graph now follows the REPOSITORY, not the session. Every checkpoint
projects the orchestration to .kigi/graph.jsonl at the git root in a
beads-style, line-mergeable shape: line 1 is the header (orchestration
minus nodes — omitted entirely, and a header carrying inline nodes is
rejected on load rather than silently duplicating the per-line entries),
then one content-hash-id node per line. The session tracker stays the
single source of truth; projection failures warn loudly but never block
progress. kigi only writes the file — committing it stays a user decision.

Single-writer discipline via an fs2 flock on a sidecar .lock: the session
that creates or resumes a graph owns the projection; other instances get a
read-only /graph status view rendered from the file and an explicit
refusal on resume. Cross-session revive: /graph resume in a fresh session
loads the file UNDER the lock (locking after reading raced the owner's
final checkpoint and could resurrect a just-cleared graph), sanitizes it
with the from_snapshot demotions, and re-dispatches.

Holding the lock proves nobody writes NOW — not that the file's content is
yours. Every lock-then-mutate site therefore identity-checks the projected
graph_id: /graph <objective> refuses to overwrite a foreign non-Complete
projection (revive-or-clear guidance, mirroring the session-level guard);
session-restored graphs claim writership on resume (and best-effort at
spawn re-emit, so the shared file learns the demoted truth immediately)
but refuse when the projection belongs to a different graph; /graph clear
skips projection teardown entirely when no session graph exists, leaves
foreign projections in place, and warns instead of swallowing lock errors.
Resume arms validate their flags before taking the lock.

Tests: projection round-trip pinning the one-line-per-node shape,
inline-nodes rejection, malformed-content loud errors, exclusive-lock
semantics across handles, and an e2e driving create → checkpoint
projection → second-instance read-only refusal → owner death → fresh-
session revive (demoted node relaunches) → clear removing the projection.
kigi-shell 4941 lib tests green; workspace clippy clean.
2026-07-20 19:41:54 -04:00
ZacharyZhang-NY bb5cbff62d Add /graph G3: dynamic replan from DISCOVERED work items
Workers, verifiers, and serial node goals can now surface out-of-scope work
as line-anchored 'DISCOVERED: <text>' markers (fence-stripped and
placeholder-filtered — the templates' own examples are fenced so verbatim
echoes never parse; worker summaries embedded in verifier prompts get the
marker neutralized alongside NODE_RESULT/NODE_VERDICT). Discoveries queue on
the orchestration as persisted state and fold into the graph at dispatch
boundaries: a replanner subagent produces a strictly APPEND-ONLY appendix,
validated against the live graph (existing-id deps allowed; edges onto
gn-final rejected — they would cycle the moment the final-gating extension
lands; Blocks deps on Failed/Blocked nodes rejected as DeadDep so the
attempt-2 feedback loop repairs the artifact). Installing an appendix bumps
plan_version, freezes an immutable graph.baseline.v{N}.json next to the
prior versions, extends gn-final's gate (demoting a Ready final back to
Waiting), and recomputes readiness.

DiscoveredFrom edges are audit metadata, never scheduling gates: an origin
is always terminal at replan time, so gating on it is either a no-op or a
permanent wedge — and a failed node's discoveries are still real work.
Replanning is bounded by KIGI_GRAPH_REPLAN_CAP (default 3; 0 disables it
quietly): past the cap, after the final node has achieved, or on replan
failure, discoveries drain to history only — a working graph is never
paused for a failed enhancement pass, and it always converges. The budget
gate now precedes the replan boundary (a budget-dead graph keeps its
discoveries queued for a later --budget top-up instead of spending two
replanner runs first), and both planner runners delete stale artifacts
before spawning so a child that responds without writing can never get a
previous pass's file validated as its own output.

Tests: validate_replan unit coverage (existing-id resolution,
DiscoveredFrom dedup, collisions, dead deps vs dead origins, terminal-node
edges, combined-graph cycles), tracker appendix/regate/audit-edge tests,
and two e2e flows — discovery → replan → appended node runs to Achieved
with both baselines frozen, and cap-0 draining to history while the graph
still converges. kigi-shell 4935 lib tests green; workspace clippy clean.
2026-07-20 19:17:24 -04:00
ZacharyZhang-NY 1579558b56 Add /graph G2: resumable budget, GraphUpdated status chip, PTY + turn-level coverage
BudgetLimited is now a resumable state: a budget trip demotes in-flight
nodes to Ready (a resource stop, not a verdict — no forever-Running node is
ever persisted) and '/graph resume --budget <tokens>' re-arms the graph with
fresh headroom (new budget = spent-so-far + extra). The tripped node's
partial burn is charged into tokens_spent_nodes at BOTH cascade sites before
the demotion clears current_node, so the top-up arithmetic never runs on an
under-counted ledger. Any input starting with 'resume' resolves to a resume
(case-insensitive; malformed top-ups surface the usage hint) and setup_graph
refuses to replace any non-Complete graph — a typo can no longer silently
destroy a resumable graph. An explicit --budget on a merely-paused graph is
rejected loudly instead of silently discarded; all trip-time messages now
advertise the top-up.

The pager gains a graph status chip: a new GraphUpdated wire variant
(extensions/notification.rs, old pagers degrade via #[serde(other)]) is
emitted from the single persist_graph_state chokepoint — every transition is
both a checkpoint and a badge tick — with a 'cleared' sentinel on /graph
clear and a one-shot re-emit after session restore (the replayed updates log
otherwise shows the pre-shutdown Active state that from_snapshot just
demoted in memory). TUI side: GraphDisplayState, session-notification arm,
and a goal-idiom chip with node progress, clamped current-node title, and
budget-aware spend. Pre-session command availability now advertises /graph
from the flags (it was fail-closed to the in-session path only, so the
welcome-screen slash menu never showed it).

Coverage: GraphUpdated wire round-trip + minimal-payload + unknown-tag
tests; PTY scenarios graph_slash_presession{,_disabled}.yaml (both run
green against the real pager binary); handle_prompt-level e2e for terminal
slash outcomes (/graph status|resume|pause, /goal refusals while the graph
owns the engine); budget top-up e2e driving a BudgetLimited diamond back to
Complete. Not shimmed: pre-G2 persisted snapshots with budget-Failed nodes
(the KIGI_GRAPH flag has never shipped enabled, so none exist).

kigi-shell 4927 and kigi-tui 6610 lib tests green; workspace clippy clean.
2026-07-20 16:35:02 -04:00
ZacharyZhang-NY 4d1e4fdc52 Add /graph G1: parallel fan-out with worktree isolation and merge-back
With KIGI_GRAPH_CONCURRENCY > 1 (default 3, clamp [1,8]) and >=2 Ready nodes,
drive_graph — the single dispatch loop shared by setup/advance/resume — runs
parallel batches: each node executes as a bounded worker<->verifier subagent
loop (KIGI_GRAPH_NODE_ROUNDS, default 3; general-purpose children with the
full implementer toolset; worktree isolation on round 1, resume keeps context
and worktree on later rounds; NODE_RESULT/NODE_VERDICT terminal contracts
parsed fail-closed with fence-stripping and line anchoring). Achieved nodes
merge back SEQUENTIALLY via kigi_workspace apply_worktree in Merge mode; a
conflict fails the node, block_dependents fires, and surviving chains keep
going. gn-final always runs serially on the full goal engine. Concurrency=1
is byte-identical to the serial G0 path; non-git projects degrade to serial.

Merge primitive hardened for real use (kigi-workspace): the 3-way apply is
now byte-safe (binary files no longer read as UTF-8 and silently deleted)
and gains the identical-content rule (ours==theirs => already present, not a
conflict) — without it, any dirty file inherited via PreserveWorkingTree
false-conflicted every node merge and multi-wave graphs self-poisoned.

Adversarial review pass (16 confirmed findings, all fixed): in-session
resume demotes orphaned Running nodes instead of wedge-pausing forever after
a mid-batch Esc; cancel re-sweeps subagents AFTER the turn abort so workers
spawned in the cancel window die too; empty child ids are never adopted as
resume targets (no unisolated escape to the shared tree); a successful
isolated round returning no worktree fails the node (soft-fallback can no
longer put N writers in one tree); main-HEAD movement during a batch aborts
merges instead of reverse-applying external commits; failed nodes still
charge the token budget; budget trips terminally fail the in-flight node;
runaway (>600s) rounds are cancelled by spawn id and retried via resume;
worker summaries are marker-sanitized before verifier embedding. Merged
worktrees are removed immediately (storage discipline); failed nodes keep
theirs for postmortem.

Tests: 4922 kigi-shell lib tests green (58 graph-specific), including
fan-out proven by a held-reply gate, real-git batch merge with cleanup
assertions, budget charging across verdicts, cap trimming, resume-after-
cancelled-batch, and backgrounded-round cancel semantics.
2026-07-20 15:32:35 -04:00
ZacharyZhang-NY 4361b03259 Add /graph G0: serial graph engineering mode over the goal engine
A deterministic DAG scheduler layered on the existing goal engine: /graph
<objective> decomposes the objective via a graph-planner subagent, gates the
result through Agentproof-style static validation (cycles, unknown deps,
duplicate slugs, caps), appends a structural final-verification node, then
executes each node as one ordinary goal — planner, worker loop, adversarial
verifier, budget and pause machinery all reused verbatim. The in-turn loop
advances nodes within the same turn (multi-loop closed loop); goal-side
auto-pauses cascade to the graph at a single chokepoint; node goals are armed
with the remaining graph budget so mid-node overruns trip graph-wide.

Gated by KIGI_GRAPH=1 (default off) + the goal harness. State persists to
<session_dir>/graph/state.json with a clear-tombstone; per-version immutable
baselines and per-node artifact archives live under graph/<graph_id>/.
Restore demotes Active->UserPaused and Running->Ready (verifier-gated re-run).

Review hardening (adversarial multi-agent pass, 24 confirmed findings fixed):
the goal-inactive loop break now consults the graph seam (mid-turn
classifier-disabled completions can no longer strand an Active graph), the
pause cascade fires even when no node goal is in flight (cancel during
planning), node bookkeeping precedes the long setup await, /graph clear only
resets the engine it owns, budget trips terminally fail the in-flight node,
and the pause transitions in /goal pause + /graph pause no longer hide inside
debug_assert! (a release-build no-op inherited from upstream).

Tests: 4908 kigi-shell lib tests green, including a serial 4-node closed-loop
e2e, restore/resume, cascade, mutual-exclusion, planning-retry, persistence
round-trip + tombstone, and status rendering.
2026-07-20 14:07:45 -04:00
54 changed files with 10288 additions and 130 deletions
+94
View File
@@ -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
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-acp-lib" name = "kigi-acp-lib"
version = "0.1.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" version = "0.1.3"
dependencies = [ dependencies = [
"serde", "serde",
] ]
[[package]] [[package]]
name = "kigi-log" name = "kigi-log"
version = "0.1.2" 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.2" 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.2" version = "0.1.3"
dependencies = [ dependencies = [
"pulldown-cmark", "pulldown-cmark",
] ]
[[package]] [[package]]
name = "kigi-mcp" name = "kigi-mcp"
version = "0.1.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" 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.2" version = "0.1.3"
[[package]] [[package]]
name = "kigi-tool-protocol" name = "kigi-tool-protocol"
version = "0.1.2" 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.2" 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.2" 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.2" 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.2" 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.2" version = "0.1.3"
[[package]] [[package]]
name = "kigi-tty-utils" name = "kigi-tty-utils"
version = "0.1.2" 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.2" 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.2" 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.2" version = "0.1.3"
dependencies = [ dependencies = [
"semver", "semver",
] ]
[[package]] [[package]]
name = "kigi-workspace" name = "kigi-workspace"
version = "0.1.2" 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.2" version = "0.1.3"
dependencies = [ dependencies = [
"base64", "base64",
"chrono", "chrono",
@@ -8838,7 +8838,7 @@ dependencies = [
[[package]] [[package]]
name = "ptyctl" name = "ptyctl"
version = "0.1.2" 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.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.1.2" 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: &sections,
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: &current_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),
@@ -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),
@@ -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 {}: {:?}",
+19
View File
@@ -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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -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),
}); });
} }
} }