Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e53a66d113 | ||
|
|
f6eaafa3da | ||
|
|
13ccab980c | ||
|
|
b9b7e6c989 | ||
|
|
f403c38a94 | ||
|
|
27d009cb6e | ||
|
|
815bd99356 |
@@ -0,0 +1,85 @@
|
||||
name: Warm build cache
|
||||
|
||||
# Release builds run on TAG refs, and GitHub Actions cache isolation only
|
||||
# lets a run restore caches created on its OWN ref or the DEFAULT branch.
|
||||
# No workflow ran on `main`, so every release compiled the whole workspace
|
||||
# cold on all five targets (~50 min wall clock, gated by Windows). This
|
||||
# workflow builds the same `release-dist` profile on `main` so tag builds
|
||||
# restore a warm default-branch cache.
|
||||
#
|
||||
# Triggers: dependency-affecting pushes to main (each release's version-bump
|
||||
# commit warms the cache for the NEXT release), a weekly refresh so the
|
||||
# cache never hits GitHub's 7-day unused-eviction, and manual dispatch.
|
||||
#
|
||||
# The setup steps mirror release.yml's build job (toolchain, target,
|
||||
# dotslash/protoc, rust-cache key) — keep them in lockstep, or the cache
|
||||
# key won't match and releases go back to cold builds.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "Cargo.lock"
|
||||
- "Cargo.toml"
|
||||
- "rust-toolchain.toml"
|
||||
- ".github/workflows/warm-cache.yml"
|
||||
schedule:
|
||||
- cron: "17 5 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
concurrency:
|
||||
group: warm-cache
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
warm:
|
||||
name: warm (${{ matrix.target }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-14
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-14
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-24.04
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-24.04-arm
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-2022
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install toolchain (rust-toolchain.toml)
|
||||
run: rustup show
|
||||
|
||||
- name: Add build target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install dotslash (protoc launcher)
|
||||
run: cargo install dotslash --locked
|
||||
|
||||
- name: Install protoc (Windows PATH fallback)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
|
||||
Invoke-WebRequest -Uri $url -OutFile protoc.zip
|
||||
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
|
||||
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
|
||||
|
||||
# Same key as release.yml so tag builds restore this cache verbatim.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Build kigi (release-dist)
|
||||
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
|
||||
@@ -32,9 +32,19 @@ import) or any `KIMI_*` env var.
|
||||
- **Observability is local**: `kigi-log` (unified session log, `--debug`
|
||||
firehose, subsystem file logs, opt-in instrumentation) writes under
|
||||
`~/.kigi` only. Its zero-network property is a contract.
|
||||
- **Atomic file replace goes through `util::fs::replace_file`** (tmp+rename
|
||||
commit step; async callers wrap in `spawn_blocking`). Never inline a bare
|
||||
`fs::rename` replace: Windows `MoveFileExW(REPLACE_EXISTING)` fails with a
|
||||
sharing violation while AV/indexer/cloud-sync holds the destination open —
|
||||
the "persists on macOS, silently doesn't on Windows" class (a /model
|
||||
switch that never stuck). Plain rename stays correct only for true moves
|
||||
whose destination doesn't pre-exist (worktree-pool markers, corrupt-file
|
||||
backups). Write failures must at least `warn!` — never `let _ =`.
|
||||
- The root `Cargo.toml` is hand-maintained (upstream's generator is not in
|
||||
this repo). Members sorted; versions inherited from
|
||||
`workspace.package.version` (0.1.0).
|
||||
`workspace.package.version` — the single source of truth for the release
|
||||
version (`kigi_version::VERSION` derives from it; the release workflow
|
||||
gates the `v*` tag against it).
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -79,8 +89,9 @@ 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`).
|
||||
- Enabled by default (`KIGI_GRAPH=0` is the off-switch; the G0 gray
|
||||
release is over); 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
|
||||
|
||||
Generated
+62
-62
@@ -5442,7 +5442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-acp-lib"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent-lifecycle"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"tokio",
|
||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-auth"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"http 1.4.2",
|
||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-bin"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-chat-state"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"kigi-compaction",
|
||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-codebase-graph"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"clap",
|
||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-compaction"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blake3",
|
||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config-types"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"indexmap",
|
||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-crash-handler"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"libc",
|
||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-env"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fast-worktree"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-file-utils"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-config",
|
||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fsnotify"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"dunce",
|
||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-gix-status"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"gix",
|
||||
"kigi-test-utils",
|
||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"kigi-config",
|
||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks-plugins-types"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-http"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"kigi-auth",
|
||||
"kigi-log",
|
||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hunk-tracker"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-interjection-core"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-log"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-lossy",
|
||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown-core"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"pulldown-cmark",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mcp"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-memory"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mermaid"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"fontdb",
|
||||
"image",
|
||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-models"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"kigi-env",
|
||||
"serde",
|
||||
@@ -5970,7 +5970,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-minimal"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crossterm",
|
||||
@@ -5987,7 +5987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-pty-harness"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -6012,7 +6012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-render"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anstyle",
|
||||
@@ -6064,7 +6064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-paths"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"serde",
|
||||
@@ -6074,7 +6074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-prompt-queue"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6082,7 +6082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-proto-build"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pbjson-build",
|
||||
@@ -6093,7 +6093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-inline"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"ansi-width",
|
||||
"anstyle-parse 0.2.7",
|
||||
@@ -6110,7 +6110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-textarea"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampler"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"async-openai",
|
||||
"async-stream",
|
||||
@@ -6154,7 +6154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampling-types"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"assert_matches",
|
||||
"async-openai",
|
||||
@@ -6171,7 +6171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sandbox"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6192,7 +6192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-secrets"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde_json",
|
||||
@@ -6230,7 +6230,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6367,7 +6367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell-base"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6392,7 +6392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sqlite-journal"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rusqlite",
|
||||
@@ -6403,7 +6403,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-subagent-resolution"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"kigi-sampling-types",
|
||||
"kigi-tool-types",
|
||||
@@ -6418,7 +6418,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-system-power"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"zbus",
|
||||
@@ -6426,7 +6426,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-support"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-utils"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"runfiles",
|
||||
"tracing",
|
||||
@@ -6457,11 +6457,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-token-estimation"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-protocol"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"kigi-tool-types",
|
||||
"serde",
|
||||
@@ -6472,7 +6472,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-runtime"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-types"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"minijinja",
|
||||
"schemars 1.2.1",
|
||||
@@ -6500,7 +6500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -6577,7 +6577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools-api"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"kigi-proto-build",
|
||||
"kigi-tool-protocol",
|
||||
@@ -6590,11 +6590,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tracing-macros"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tty-utils"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"nix 0.30.1",
|
||||
@@ -6604,7 +6604,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tui"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"ansi-to-tui",
|
||||
@@ -6691,7 +6691,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-update"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
@@ -6720,14 +6720,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-version"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6806,7 +6806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace-types"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
@@ -8840,7 +8840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -8858,7 +8858,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl-cli"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Filesystem primitives shared across the shell.
|
||||
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
/// Replace `dest` with `tmp` — the commit step of every tmp+rename atomic
|
||||
/// write in the product. This is the ONE place that knows how to make that
|
||||
/// commit stick on Windows; call sites must never inline a bare
|
||||
/// `fs::rename` replace again.
|
||||
///
|
||||
/// Unix `rename(2)` replaces atomically and needs no help. Windows
|
||||
/// `MoveFileExW(REPLACE_EXISTING)` fails with a sharing violation while
|
||||
/// ANOTHER process (antivirus scanner, search indexer, cloud sync) holds
|
||||
/// `dest` open — the classic "persists on macOS, silently doesn't on
|
||||
/// Windows" failure (a model switch that never sticks, a stale models
|
||||
/// cache). On Windows a failed rename therefore deletes the destination
|
||||
/// first (the pattern `auth/storage.rs` shipped first) and retries with two
|
||||
/// short back-offs for scanners that hold the file for a few milliseconds.
|
||||
///
|
||||
/// On final failure the tmp file is removed (no litter) and the error is
|
||||
/// returned — callers decide severity, but MUST at least log it (errors
|
||||
/// never pass silently).
|
||||
pub fn replace_file(tmp: &Path, dest: &Path) -> io::Result<()> {
|
||||
let result = replace_file_inner(tmp, dest);
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn replace_file_inner(tmp: &Path, dest: &Path) -> io::Result<()> {
|
||||
std::fs::rename(tmp, dest)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn replace_file_inner(tmp: &Path, dest: &Path) -> io::Result<()> {
|
||||
let mut last = match std::fs::rename(tmp, dest) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => e,
|
||||
};
|
||||
for backoff_ms in [0u64, 10, 50] {
|
||||
if backoff_ms > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
|
||||
}
|
||||
// Delete-first: marks an open-with-delete-sharing file for deletion
|
||||
// and clears the way for a plain rename; harmless when absent.
|
||||
let _ = std::fs::remove_file(dest);
|
||||
match std::fs::rename(tmp, dest) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The common contract on every platform: replace over an existing
|
||||
/// destination, create a missing one, and error (cleaning the tmp)
|
||||
/// when the tmp itself is missing.
|
||||
#[test]
|
||||
fn replace_file_commits_and_cleans_up() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let dest = dir.path().join("target.json");
|
||||
let tmp = dir.path().join("target.json.tmp");
|
||||
|
||||
// Create-missing.
|
||||
std::fs::write(&tmp, b"v1").unwrap();
|
||||
replace_file(&tmp, &dest).expect("create");
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"v1");
|
||||
assert!(!tmp.exists(), "tmp must be consumed");
|
||||
|
||||
// Replace-existing.
|
||||
std::fs::write(&tmp, b"v2").unwrap();
|
||||
replace_file(&tmp, &dest).expect("replace");
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"v2");
|
||||
assert!(!tmp.exists());
|
||||
|
||||
// Missing tmp → error, dest untouched.
|
||||
let err = replace_file(&tmp, &dest).expect_err("missing tmp must fail");
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"v2");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod event_id;
|
||||
pub mod fs;
|
||||
pub mod kigi_home;
|
||||
pub mod secure_file;
|
||||
pub mod tips;
|
||||
|
||||
@@ -109,21 +109,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
t = asset_triple
|
||||
);
|
||||
|
||||
// Transient CDN hiccups (502/503/timeouts) are retried with backoff: a
|
||||
// single flaky response must not kill a ~50-minute release build (it
|
||||
// did — the v0.1.5 tag build failed on one 502). Genuine failures
|
||||
// (404, offline) still error out with the offline-build hint.
|
||||
let bytes: Vec<u8> = {
|
||||
let resp = reqwest::blocking::get(&url).map_err(|e| {
|
||||
format!(
|
||||
"Failed to download ripgrep: {}\nSet KIGI_SHELL_BUNDLE_RG_PATH to a local rg for offline builds.",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"HTTP {} downloading ripgrep. Set KIGI_SHELL_BUNDLE_RG_PATH for offline builds.",
|
||||
resp.status()
|
||||
)
|
||||
.into());
|
||||
let mut last_err = String::new();
|
||||
let mut bytes = None;
|
||||
for (attempt, backoff_secs) in [0u64, 2, 8].into_iter().enumerate() {
|
||||
if backoff_secs > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(backoff_secs));
|
||||
}
|
||||
match reqwest::blocking::get(&url) {
|
||||
Ok(resp) if resp.status().is_success() => match resp.bytes() {
|
||||
Ok(b) => {
|
||||
bytes = Some(b.to_vec());
|
||||
break;
|
||||
}
|
||||
Err(e) => last_err = format!("reading ripgrep body: {e}"),
|
||||
},
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
last_err = format!("HTTP {status} downloading ripgrep");
|
||||
// Only server-side/transient statuses are worth retrying.
|
||||
if !(status.is_server_error() || status.as_u16() == 429) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => last_err = format!("Failed to download ripgrep: {e}"),
|
||||
}
|
||||
println!(
|
||||
"cargo:warning=ripgrep download attempt {} failed: {last_err}",
|
||||
attempt + 1
|
||||
);
|
||||
}
|
||||
resp.bytes()?.to_vec()
|
||||
bytes.ok_or_else(|| {
|
||||
format!("{last_err}. Set KIGI_SHELL_BUNDLE_RG_PATH for offline builds.")
|
||||
})?
|
||||
};
|
||||
|
||||
let gz = flate2::read::GzDecoder::new(&bytes[..]);
|
||||
|
||||
@@ -170,9 +170,7 @@ fn write_data_file_atomic(
|
||||
let json = serde_json::to_string_pretty(sessions)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
fs::write(tmp_path, json.as_bytes())?;
|
||||
fs::rename(tmp_path, data_path).inspect_err(|_| {
|
||||
let _ = fs::remove_file(tmp_path);
|
||||
})
|
||||
crate::util::fs::replace_file(tmp_path, data_path)
|
||||
}
|
||||
|
||||
fn is_pid_alive(pid: u32) -> bool {
|
||||
|
||||
@@ -1881,12 +1881,14 @@ impl Config {
|
||||
.default(true)
|
||||
.resolve()
|
||||
}
|
||||
/// Graph mode (`/graph`) master switch. Default OFF — gray-released via
|
||||
/// `KIGI_GRAPH=1` only (plan.md G0 gate). Graph mode additionally
|
||||
/// Graph mode (`/graph`) master switch. Default ON — the gray release
|
||||
/// (plan.md G0, `KIGI_GRAPH=1` only) is over; every install gets the
|
||||
/// same commands (its absence on non-dev machines read as a platform
|
||||
/// bug). `KIGI_GRAPH=0` remains the off-switch. 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()
|
||||
BoolFlag::env("KIGI_GRAPH").default(true).resolve()
|
||||
}
|
||||
/// Max graph nodes running concurrently (`KIGI_GRAPH_CONCURRENCY`).
|
||||
/// 1 = serial (G0-identical); clamped to [1, 8] — the coordinator has
|
||||
@@ -4337,6 +4339,24 @@ mod tests {
|
||||
/// Catalog key of the bundled fallback default (`default_models.json`):
|
||||
/// `{platform_id}/{model_id}` for `crate::models::default_model()`.
|
||||
const BUNDLED_DEFAULT_KEY: &str = "kimi-code/kimi-for-coding";
|
||||
|
||||
/// `/graph` ships ON by default: the G0 gray release (`KIGI_GRAPH=1`
|
||||
/// only) made the command exist solely on machines with the dev env
|
||||
/// var — which read as "missing on Windows". A fresh install with no
|
||||
/// env must resolve `true`; `KIGI_GRAPH=0` stays the off-switch.
|
||||
#[test]
|
||||
#[serial]
|
||||
fn graph_defaults_on_and_env_zero_disables() {
|
||||
let cfg = Config::default();
|
||||
{
|
||||
let _unset = EnvGuard::unset("KIGI_GRAPH");
|
||||
assert!(cfg.resolve_graph().value, "fresh install must offer /graph");
|
||||
}
|
||||
{
|
||||
let _off = EnvGuard::set("KIGI_GRAPH", "0");
|
||||
assert!(!cfg.resolve_graph().value, "KIGI_GRAPH=0 must disable");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn main_cli_tools_override_preserves_profile_injection_policy() {
|
||||
let overrides = CliAgentOverrides {
|
||||
|
||||
@@ -1637,16 +1637,31 @@ impl ModelsCacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync; see `load_fresh` note.
|
||||
/// Unique tmp suffix (PID + nanos) so concurrent writers never share an
|
||||
/// inode (mirrors `util::config::persist`).
|
||||
fn tmp_path(&self) -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
self.path
|
||||
.with_extension(format!("json.tmp.{}.{}", std::process::id(), nanos))
|
||||
}
|
||||
|
||||
/// Sync; see `load_fresh` note. Best-effort, but NEVER silent: a failed
|
||||
/// cache write leaves a stale catalog on disk, which on Windows (sharing
|
||||
/// violations) previously diverged picker behavior with zero trace.
|
||||
fn atomic_write(&self, cache: &ModelsCache) {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
if let Ok(json) = serde_json::to_vec_pretty(cache)
|
||||
&& std::fs::write(&tmp, &json).is_ok()
|
||||
{
|
||||
let _ = std::fs::rename(&tmp, &self.path);
|
||||
let tmp = self.tmp_path();
|
||||
let result = serde_json::to_vec_pretty(cache)
|
||||
.map_err(std::io::Error::other)
|
||||
.and_then(|json| std::fs::write(&tmp, &json))
|
||||
.and_then(|()| crate::util::fs::replace_file(&tmp, &self.path));
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(error = %e, path = %self.path.display(), "models cache write failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1654,12 +1669,25 @@ impl ModelsCacheManager {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
let Ok(json) = serde_json::to_vec_pretty(cache) else {
|
||||
return;
|
||||
let tmp = self.tmp_path();
|
||||
let json = match serde_json::to_vec_pretty(cache) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "models cache serialize failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if tokio::fs::write(&tmp, &json).await.is_ok() {
|
||||
let _ = tokio::fs::rename(&tmp, &self.path).await;
|
||||
let result = match tokio::fs::write(&tmp, &json).await {
|
||||
Ok(()) => {
|
||||
let dest = self.path.clone();
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(std::io::Error::other(e)))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(error = %e, path = %self.path.display(), "models cache write failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,17 +430,12 @@ fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomic write: tmp + rename. Unix `rename(2)` replaces atomically;
|
||||
/// Windows `rename` requires removing the target first.
|
||||
/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`,
|
||||
/// which this site's inline delete-first pattern graduated into).
|
||||
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
|
||||
let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id()));
|
||||
write_store_to(&tmp, auth_store)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = std::fs::remove_file(auth_file);
|
||||
}
|
||||
std::fs::rename(&tmp, auth_file)?;
|
||||
Ok(())
|
||||
crate::util::fs::replace_file(&tmp, auth_file)
|
||||
}
|
||||
|
||||
/// Non-atomic fallback: truncate and rewrite `auth.json` in place.
|
||||
|
||||
@@ -670,10 +670,7 @@ fn write_import_marker(config_path: &Path) -> anyhow::Result<()> {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e.into());
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, config_path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e.into());
|
||||
}
|
||||
crate::util::fs::replace_file(&tmp, config_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -854,7 +851,7 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&tmp, &toml_str)?;
|
||||
std::fs::rename(&tmp, config_path)?;
|
||||
crate::util::fs::replace_file(&tmp, config_path)?;
|
||||
info!(
|
||||
path = %config_path.display(),
|
||||
count,
|
||||
@@ -1204,7 +1201,7 @@ fn apply_hooks_to_dir(hooks_dir: &Path, items: &[ImportableItem]) -> anyhow::Res
|
||||
let json_str = serde_json::to_string_pretty(&root)?;
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, &json_str)?;
|
||||
std::fs::rename(&tmp, &target)?;
|
||||
crate::util::fs::replace_file(&tmp, &target)?;
|
||||
info!(
|
||||
path = %target.display(),
|
||||
count,
|
||||
|
||||
@@ -90,7 +90,7 @@ pub fn save_import_state(state: &ImportState) -> std::io::Result<()> {
|
||||
// `claude_import_state.json.tmp` (the last extension is replaced).
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, &json)?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
crate::util::fs::replace_file(&tmp, &path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -528,10 +528,7 @@ pub fn apply_at(plan: &KimiImportPlan, kigi_home: &Path) -> anyhow::Result<KimiA
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e.into());
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, &config_path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e.into());
|
||||
}
|
||||
crate::util::fs::replace_file(&tmp, &config_path)?;
|
||||
info!(
|
||||
path = %config_path.display(),
|
||||
added = applied.total_added(),
|
||||
|
||||
@@ -1147,7 +1147,7 @@ fn persist_chat_history_jsonl_sync(session_info: &SessionInfo, conversation: &[C
|
||||
buf.push(b'\n');
|
||||
}
|
||||
std::fs::File::create(&tmp_path)?.write_all(&buf)?;
|
||||
std::fs::rename(&tmp_path, &final_path)?;
|
||||
crate::util::fs::replace_file(&tmp_path, &final_path)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
|
||||
@@ -594,10 +594,10 @@ async fn write_patch_file_atomic(path: &Path, body: &str) -> std::io::Result<()>
|
||||
.unwrap_or("goal-classifier.patch");
|
||||
let tmp = dir.join(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7()));
|
||||
tokio::fs::write(&tmp, body).await?;
|
||||
if let Err(err) = tokio::fs::rename(&tmp, path).await {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
return Err(err);
|
||||
}
|
||||
let dest = path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -895,7 +895,8 @@ impl GoalTracker {
|
||||
};
|
||||
let dest = goal_dir.join(name);
|
||||
let _ = std::fs::create_dir_all(&goal_dir);
|
||||
if std::fs::rename(&src, &dest).is_ok() || copy_no_follow(&src, &dest).is_ok() {
|
||||
if crate::util::fs::replace_file(&src, &dest).is_ok() || copy_no_follow(&src, &dest).is_ok()
|
||||
{
|
||||
append_skeptic_reports(&scratch_root, &dest);
|
||||
o.last_classifier_details_path = Some(dest.to_string_lossy().into_owned());
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ pub fn project(dir: &Path, state: &GraphOrchestration) -> std::io::Result<()> {
|
||||
f.write_all(&buf)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, &target)
|
||||
crate::util::fs::replace_file(&tmp, &target)
|
||||
}
|
||||
|
||||
/// Load the projected graph, `Ok(None)` when absent. Malformed content
|
||||
|
||||
@@ -98,7 +98,7 @@ pub fn truncate_if_needed(cwd: &str) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::rename(temp_path, path)?;
|
||||
crate::util::fs::replace_file(&temp_path, &path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,16 @@ use std::fs::OpenOptions;
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// Commit `tmp` over `target` with the shared Windows-safe replace
|
||||
/// (`util::fs::replace_file`): a bare async rename silently lost session
|
||||
/// state — including the switched model — on Windows whenever AV/indexer
|
||||
/// held the destination open.
|
||||
async fn replace_file_async(tmp: PathBuf, target: PathBuf) -> io::Result<()> {
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &target))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(io::Error::other(e)))
|
||||
}
|
||||
/// How the adapter resolves the session directory on disk.
|
||||
///
|
||||
/// - `FromRoot` (default): computes `{root}/sessions/{urlencoded(cwd)}/{session_id}/`
|
||||
@@ -289,7 +299,7 @@ impl JsonlStorageAdapter {
|
||||
}
|
||||
let tmp = path.with_extension("jsonl.tmp");
|
||||
tokio::fs::write(&tmp, &content).await?;
|
||||
tokio::fs::rename(&tmp, &path).await
|
||||
replace_file_async(tmp, path).await
|
||||
}
|
||||
fn read_jsonl<T: serde::de::DeserializeOwned>(&self, path: PathBuf) -> io::Result<Vec<T>> {
|
||||
if !path.exists() {
|
||||
@@ -378,7 +388,7 @@ impl JsonlStorageAdapter {
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
let tmp = summary_path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, &bytes)?;
|
||||
std::fs::rename(&tmp, &summary_path)
|
||||
crate::util::fs::replace_file(&tmp, &summary_path)
|
||||
}
|
||||
fn read_summary_sync(&self, info: &Info) -> io::Result<Summary> {
|
||||
let path = self.summary_file(info);
|
||||
@@ -1057,7 +1067,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
let target = self.plan_mode_state_file(info);
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
replace_file_async(tmp, target).await
|
||||
}
|
||||
async fn write_signals(
|
||||
&self,
|
||||
@@ -1069,7 +1079,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
let target = self.signals_file(info);
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, signals_json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
replace_file_async(tmp, target).await
|
||||
}
|
||||
async fn write_announcement_state(
|
||||
&self,
|
||||
@@ -1081,7 +1091,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
let target = self.announcement_state_file(info);
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
replace_file_async(tmp, target).await
|
||||
}
|
||||
async fn write_goal_mode_state(
|
||||
&self,
|
||||
@@ -1096,7 +1106,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
}
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
replace_file_async(tmp, target).await
|
||||
}
|
||||
async fn write_graph_mode_state(
|
||||
&self,
|
||||
@@ -1119,7 +1129,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
}
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
replace_file_async(tmp, target).await
|
||||
}
|
||||
async fn load_session(&self, info: &Info) -> io::Result<PersistedData> {
|
||||
let summary = self.read_summary_sync(info)?;
|
||||
|
||||
@@ -230,7 +230,10 @@ fn write_summary_atomic(summary_path: &Path, summary: &Summary) -> io::Result<()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
let tmp = summary_path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, &bytes)?;
|
||||
std::fs::rename(&tmp, summary_path)
|
||||
// Windows-safe replace: this is the write that persists a session's
|
||||
// CURRENT MODEL — a bare rename made a switched model silently revert
|
||||
// on resume whenever AV/indexer held summary.json open on Windows.
|
||||
crate::util::fs::replace_file(&tmp, summary_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -114,9 +114,7 @@ fn dismiss_campaign_ids_at(
|
||||
let nonce = DISMISS_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = path.with_extension(format!("json.{}.{}.tmp", std::process::id(), nonce));
|
||||
std::fs::write(&tmp, &json)?;
|
||||
std::fs::rename(&tmp, &path).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
crate::util::fs::replace_file(&tmp, &path)
|
||||
}
|
||||
|
||||
/// `KIGI_CAMPAIGNS_OVERRIDE` JSON array replaces all sources (`[]` = none; beats
|
||||
|
||||
@@ -367,7 +367,9 @@ pub async fn save_mcp_disabled_tools(server_name: &str, disabled_tools: &[String
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
tokio::fs::write(&tmp, &toml_str).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -419,7 +421,9 @@ pub async fn save_mcp_server_enabled(server_name: &str, enabled: bool) -> Result
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
tokio::fs::write(&tmp, &toml_str).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -476,7 +480,10 @@ pub async fn save_mcp_server_config_at(
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
tokio::fs::write(&tmp, &toml_str).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
let dest = path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -553,7 +560,12 @@ pub async fn delete_mcp_server_config_at(
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
tokio::fs::write(&tmp, &toml_str).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
{
|
||||
let dest = path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
}
|
||||
|
||||
// Clean up OAuth credentials for the deleted server.
|
||||
if let Ok(mut cred_store) = kigi_mcp::credentials::McpCredentialStore::load_default() {
|
||||
|
||||
@@ -88,7 +88,12 @@ pub async fn save_config(config: &Config) -> Result<()> {
|
||||
}
|
||||
let _ = prior_mode;
|
||||
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
// Windows-safe replace (delete-first + retry on sharing violations) —
|
||||
// a bare rename made `/model` persistence silently fail on Windows
|
||||
// whenever AV/indexer/cloud-sync held config.toml open.
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("config replace task: {e}"))??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -138,11 +143,8 @@ pub(crate) fn atomic_write_string(path: &std::path::Path, content: &str) -> std:
|
||||
}
|
||||
let _ = prior_mode;
|
||||
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
// Windows-safe replace; cleans up the tmp file on failure itself.
|
||||
crate::util::fs::replace_file(&tmp, path)
|
||||
}
|
||||
|
||||
/// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use super::setters::{
|
||||
pr13_effective_default, set_ask_user_question_timeout_enabled_inner, set_auto_dark_theme_inner,
|
||||
set_auto_light_theme_inner, set_auto_update_inner, set_collapsed_edit_blocks_inner,
|
||||
set_compact_mode, set_compact_mode_inner, set_contextual_hint_inner, set_default_model_inner,
|
||||
set_compact_mode, set_compact_mode_inner, set_contextual_hint_inner,
|
||||
set_default_selected_permission_inner, set_display_refresh_auto_cadence_inner,
|
||||
set_fork_secondary_model_inner, set_group_tool_verbs_inner, set_hunk_tracker_mode_inner,
|
||||
set_invert_scroll_inner, set_keep_text_selection_inner, set_max_thoughts_width_inner,
|
||||
@@ -844,7 +844,7 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
|
||||
rollback_value: &crate::settings::SettingValue,
|
||||
) -> Vec<Effect> {
|
||||
use crate::settings::SettingValue;
|
||||
let mut companion_effects: Vec<Effect> = Vec::new();
|
||||
let companion_effects: Vec<Effect> = Vec::new();
|
||||
match (key, rollback_value) {
|
||||
("compact_mode", SettingValue::Bool(b)) => set_compact_mode_inner(app, *b),
|
||||
("show_timestamps", SettingValue::Bool(b)) => set_timestamps_inner(app, *b),
|
||||
@@ -934,65 +934,24 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
|
||||
// other rollback arms must not clobber it from the global canonical.
|
||||
sync_active_auto_flag(app);
|
||||
}
|
||||
// default_model: best-effort rollback. If the prior model no
|
||||
// longer resolves, leave optimistic value + log.
|
||||
("default_model", SettingValue::String(s)) => {
|
||||
if s.is_empty() {
|
||||
tracing::warn!(
|
||||
target: "settings",
|
||||
key = "default_model",
|
||||
"rollback to empty string requested but no \
|
||||
'clear current model' API exists — leaving live \
|
||||
state at optimistic value (next session reload \
|
||||
will resolve via shell default-resolution chain)",
|
||||
);
|
||||
} else {
|
||||
// Resolve the prior model ID back to a ModelId
|
||||
// and call the typed inner. If resolution fails
|
||||
// (catalog changed mid-flight), log + leave
|
||||
// optimistic.
|
||||
let (resolved, session_id) = if let ActiveView::Agent(aid) = app.active_view
|
||||
&& let Some(agent) = app.agents.get(&aid)
|
||||
{
|
||||
(
|
||||
agent.session.models.resolve_by_name_or_id(s),
|
||||
agent.session.session_id.clone(),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
match resolved {
|
||||
Some(id) => {
|
||||
let _ = set_default_model_inner(app, &id);
|
||||
// Emit reverse SwitchModel so the ACP session
|
||||
// matches the rolled-back pager mirror.
|
||||
if let ActiveView::Agent(aid) = app.active_view
|
||||
&& let Some(sid) = session_id
|
||||
{
|
||||
if let Some(agent) = app.agents.get_mut(&aid) {
|
||||
agent.session.model_switch_pending = true;
|
||||
}
|
||||
companion_effects.push(Effect::SwitchModel {
|
||||
agent_id: aid,
|
||||
session_id: sid,
|
||||
model_id: id,
|
||||
effort: None,
|
||||
prev_model_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "settings",
|
||||
key = "default_model",
|
||||
value = %s,
|
||||
"rollback model id no longer resolves in catalog — \
|
||||
in-memory state stays at optimistic value; ACP session \
|
||||
may diverge from pager mirror until next setter dispatch",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// default_model: deliberately NO revert. The session switch already
|
||||
// succeeded independently (its own failure path reports via
|
||||
// `handle_switch_model_complete`); this arm fires when only the
|
||||
// DISK write of the next-launch default failed — which must not
|
||||
// undo a working switch. Same policy as `PersistPreferredModel`
|
||||
// ("still active for this session"). Regression: reverting here
|
||||
// (plus a reverse SwitchModel) made every picker selection appear
|
||||
// to not take on Windows, where AV/indexer file locks routinely
|
||||
// fail config.toml persists.
|
||||
("default_model", SettingValue::String(prior)) => {
|
||||
tracing::warn!(
|
||||
target: "settings",
|
||||
key = "default_model",
|
||||
prior = %prior,
|
||||
"default-model persist failed; keeping the live session's \
|
||||
model (the switch succeeded) — only the next-launch \
|
||||
default is unsaved",
|
||||
);
|
||||
}
|
||||
// max_thoughts_width: direct inner call.
|
||||
("max_thoughts_width", SettingValue::Int(i)) => set_max_thoughts_width_inner(app, *i),
|
||||
|
||||
@@ -1592,6 +1592,78 @@ fn rollback_reverts_thread_local_cache_too() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A default_model persist failure must NOT revert the live session's
|
||||
/// model. The switch already succeeded in the session (its own failure
|
||||
/// path reports separately); a DISK write failure only means the default
|
||||
/// won't stick for the next launch — same policy as PersistPreferredModel
|
||||
/// ("still active for this session"). Regression: the rollback arm
|
||||
/// re-showed the ORIGINAL model and issued a reverse SwitchModel, which
|
||||
/// on Windows (persist failures from AV/indexer file locks) made every
|
||||
/// picker selection appear to not take.
|
||||
#[test]
|
||||
fn default_model_persist_failure_keeps_live_model() {
|
||||
use crate::settings::SettingValue;
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
for (mid, name) in [("old-model", "Old Model"), ("new-model", "New Model")] {
|
||||
let model_id = acp::ModelId::new(std::sync::Arc::from(mid));
|
||||
let info = acp::ModelInfo::new(model_id.clone(), name.to_string());
|
||||
app.agents
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.session
|
||||
.models
|
||||
.available
|
||||
.insert(model_id.clone(), info.clone());
|
||||
app.models.available.insert(model_id, info);
|
||||
}
|
||||
// User picked the new model; optimistic update applied.
|
||||
let _ = dispatch(
|
||||
Action::SetDefaultModel(acp::ModelId::new(std::sync::Arc::from("new-model"))),
|
||||
&mut app,
|
||||
);
|
||||
assert_eq!(
|
||||
app.agents[&id]
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|m| m.0.as_ref()),
|
||||
Some("new-model"),
|
||||
);
|
||||
|
||||
// Disk persist fails (the Windows sharing-violation shape).
|
||||
let effects = dispatch(
|
||||
Action::TaskComplete(TaskResult::SettingPersistFailed {
|
||||
key: "default_model",
|
||||
rollback_value: SettingValue::String("Old Model".into()),
|
||||
error: "Access is denied. (os error 5)".into(),
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
app.agents[&id]
|
||||
.session
|
||||
.models
|
||||
.current
|
||||
.as_ref()
|
||||
.map(|m| m.0.as_ref()),
|
||||
Some("new-model"),
|
||||
"a persist failure must not revert the live session's model"
|
||||
);
|
||||
assert!(
|
||||
!effects
|
||||
.iter()
|
||||
.any(|e| matches!(e, Effect::SwitchModel { .. })),
|
||||
"no reverse SwitchModel may be issued for a disk-persist failure"
|
||||
);
|
||||
assert!(
|
||||
read_toast(&app).contains("Could not save"),
|
||||
"the save failure must still be surfaced"
|
||||
);
|
||||
}
|
||||
|
||||
/// `set_yolo_mode_inner` is the backstop: even a (stale) rollback
|
||||
/// value of "always-approve" must not re-enable yolo under the pin.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user