Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,129 @@ jobs:
if: always() && env.RUSTC_WRAPPER == 'sccache'
run: sccache --show-stats || true

# PROB-102: the `check` job above proves the semantic-search feature
# COMPILES. It has never proven the embedding engine is CORRECT — that
# needs `tests/embedding_reference.rs`, which is entirely behind the
# feature, so `test`'s plain `cargo nextest run --workspace` never even
# compiles it, let alone runs it. `0 passed` for a file with three real
# assertions read as success on every PR since v0.35.0 (the ONNX -> tract
# engine swap this oracle exists to catch).
#
# This job pays the cost the `check` job comment said "is not worth
# downloading per run": the model is ~2.1 GB, cached across runs by
# `actions/cache` keyed on the model repo name, so only the FIRST run
# after a cache eviction pays the download. `FORGEPLAN_REQUIRE_MODEL_IN_
# TESTS=1` makes a cold or broken cache a loud red failure instead of the
# oracle's normal quiet local-dev skip (an early `return` on a missing
# model reads as PASS to nextest — the same defect one level down inside
# the fix for it, so this job would silently prove nothing if the cache
# ever failed to warm and the guard were not here).
test-embedding-oracle:
name: Embedding correctness oracle
runs-on: ubuntu-latest
needs: check
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install Rust stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch @ 2026-03-27 (no tag; verify with git ls-remote)

- name: Install protoc
uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}

- name: Install sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10

- name: Probe sccache backend
# Same probe pattern as the check/test jobs — see comment there.
run: |
export SCCACHE_GHA_ENABLED=true
if timeout 30 sccache --start-server >/dev/null 2>&1; then
echo 'fn main() {}' > /tmp/probe.rs
if timeout 30 sccache rustc \
--edition 2021 \
--crate-name probe \
--crate-type bin \
/tmp/probe.rs \
-o /tmp/probe 2>/dev/null; then
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "✓ sccache probe passed — wrapper enabled"
else
sccache --stop-server >/dev/null 2>&1 || true
echo "✗ sccache probe failed (rustc invocation) — falling back to direct rustc"
fi
else
echo "✗ sccache probe failed (server start) — falling back to direct rustc"
fi

- name: Rust cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
key: embedding-oracle

- name: Cache embedding model
# `resolve_cache_dir()` (crates/forgeplan-core/src/embed/mod.rs) puts
# the model under the platform cache dir when `FORGEPLAN_MODEL_CACHE`
# is unset — `~/.cache/forgeplan/models` on this runner. Keyed on the
# model repo name, not a version tag: the model itself doesn't churn
# per-PR, and a stale-but-present cache is exactly what this job's
# `FORGEPLAN_REQUIRE_MODEL_IN_TESTS` guard is for — if the cached
# weights were ever wrong, the oracle's vector comparison would be
# the thing that catches it, not the cache key.
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v6.1.0
with:
path: ~/.cache/forgeplan/models
key: embedding-model-bge-m3

- name: Install cargo-nextest
uses: taiki-e/install-action@3d7d7cd5ac7f994c1892ae0c06165095b9139094 # v2.85.1
with:
tool: cargo-nextest

- name: cargo nextest run (embedding oracle)
# Two independent fail-closed paths, verified separately rather than
# assumed:
# 1. `--test embedding_reference` with the feature dropped compiles
# zero tests. Verified locally: nextest exits 4 with
# "error: no tests to run" on its own — no extra guard needed
# for that case.
# 2. The feature present but the model missing/broken: without
# `FORGEPLAN_REQUIRE_MODEL_IN_TESTS`, `embedder_or_skip` returns
# `None` and each test `return`s early, which nextest reports as
# PASS — this is the actual PROB-102 shape, one level inside the
# fix for it. `=1` turns that `None` into a panic instead.
# Verified locally by forcing `Embedder::new()` to `Err`: the
# job fails loudly with `=1` set, passes silently without it.
#
# `--test-threads=1`: found the hard way, on this job's own first
# cold-cache run. `dimension_is_unchanged` and `embeddings_match_
# the_captured_reference` both call `Embedder::new()`, and nextest
# defaults to running them concurrently. Against a genuinely empty
# cache both start downloading the same ~2.1 GB model into the same
# directory at once — one finished in 62s, the other failed 7s in
# trying to fetch `onnx/model.onnx_data`. Once `actions/cache` has
# populated the directory this race cannot occur (`find_snapshot`
# returns immediately, no write happens) — but the FIRST run after
# any cache eviction hits it every time, and that run reporting red
# for a race rather than a real defect is exactly the kind of noise
# that trains people to re-run and ignore, not investigate. Serial
# execution removes the download race outright; two tests don't
# need parallelism.
env:
FORGEPLAN_REQUIRE_MODEL_IN_TESTS: "1"
run: |
cargo nextest run -p forgeplan-core --features semantic-search \
--test embedding_reference --no-fail-fast --test-threads=1

- name: sccache stats
if: always() && env.RUSTC_WRAPPER == 'sccache'
run: sccache --show-stats || true

smoke-e2e:
name: End-to-end smoke test
runs-on: ubuntu-latest
Expand Down
15 changes: 13 additions & 2 deletions crates/forgeplan-cli/src/commands/deprecate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,21 @@ pub async fn run(id: &str, reason: &str) -> anyhow::Result<()> {
.await
.map_err(|e| anyhow::anyhow!("{}\nFix: forgeplan validate {}", e, id))?;

// Re-render projection with updated status
// Re-render projection with the updated status AND the appended body.
//
// #478: this used `render_projection`, which is files-first — it discards
// the body it is handed whenever the file already has one (RFC-004, so a
// user's edits survive `link`/`tag`/`activate`). The status reached the
// file because status lives in frontmatter; the `## Deprecation` section
// did not. `lance/` is gitignored, and the next lifecycle command syncs
// the section-less file body back over the DB, so the reason ended up
// nowhere at all.
//
// Forcing is safe *here* because `sync_file_to_store` ran above: at this
// point the DB body is the file body plus the section just appended.
if let Some(record) = store.get_record(id).await? {
let links = store.get_relations(id).await.unwrap_or_default();
projection::render_projection(
projection::render_projection_with_body(
&ws,
&record.id,
&record.kind,
Expand Down
6 changes: 4 additions & 2 deletions crates/forgeplan-cli/src/commands/renew.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ pub async fn run(id: &str, reason: &str, until: &str) -> anyhow::Result<()> {

let result = lifecycle::renew(&store, id, reason, until).await?;

// Re-render projection with updated status
// Re-render projection with the updated status AND the appended body.
// #478 — `render_projection` is files-first and would drop the
// `## Renewal` section. Safe to force: `sync_file_to_store` ran above.
if let Some(record) = store.get_record(id).await? {
let links = store.get_relations(id).await.unwrap_or_default();
projection::render_projection(
projection::render_projection_with_body(
&ws,
&record.id,
&record.kind,
Expand Down
11 changes: 9 additions & 2 deletions crates/forgeplan-cli/src/commands/reopen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,20 @@ pub async fn run(id: &str, reason: &str) -> anyhow::Result<()> {
.await
.map_err(|e| anyhow::anyhow!("{}\nFix: forgeplan validate {}", e, id))?;

// Render projections for both old (deprecated) and new (draft)
// Render projections for both old (deprecated) and new (draft).
//
// #478 — the OLD artifact needs the forcing variant: `render_projection`
// is files-first and dropped its `## Reopened` section, so the record of
// why it was retired never reached the file. Safe to force here because
// `sync_file_to_store` ran above. The NEW artifact below keeps the plain
// call: its file does not exist yet, so the renderer already takes the
// passed body.
if let Some(old_record) = store.get_record(&result.old_id).await? {
let links = store
.get_relations(&result.old_id)
.await
.unwrap_or_default();
projection::render_projection(
projection::render_projection_with_body(
&ws,
&old_record.id,
&old_record.kind,
Expand Down
182 changes: 182 additions & 0 deletions crates/forgeplan-cli/tests/cli_integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2918,6 +2918,188 @@ fn e2e_full_lifecycle_deprecate() {
.stdout(predicate::str::contains("deprecated"));
}

// -----------------------------------------------------------------------
// #478: the lifecycle reason must reach the FILE, not just LanceDB.
//
// The tests above, and every unit test in `lifecycle/mod.rs`, assert through
// `forgeplan get` — which reads the store. The store was never the broken
// half. `deprecate` appended its `## Deprecation` section to LanceDB and the
// files-first renderer discarded it, so the status reached the markdown and
// the reason did not. `lance/` is gitignored, so on a fresh clone the reason
// did not exist anywhere; worse, the next mutation synced the section-less
// file body back over the store, erasing it there too.
//
// These read the `.md` off disk. Asserting through the store is precisely
// what let the defect ship.
// -----------------------------------------------------------------------

/// Read an artifact's markdown projection from a workspace.
fn read_projection(workspace: &std::path::Path, dir: &str, prefix: &str) -> String {
let d = workspace.join(".forgeplan").join(dir);
let entry = std::fs::read_dir(&d)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", d.display()))
.filter_map(Result::ok)
.find(|e| e.file_name().to_string_lossy().starts_with(prefix))
.unwrap_or_else(|| panic!("no file starting with {prefix} in {}", d.display()));
std::fs::read_to_string(entry.path()).unwrap()
}

#[test]
fn deprecate_writes_the_reason_into_the_markdown_file() {
let tmp = TempDir::new().unwrap();
forgeplan()
.args(["init", "-y"])
.current_dir(tmp.path())
.assert()
.success();
forgeplan()
.args(["new", "note", "Lifecycle Test"])
.current_dir(tmp.path())
.assert()
.success();
forgeplan()
.args(["activate", "NOTE-001"])
.current_dir(tmp.path())
.assert()
.success();
forgeplan()
.args(["deprecate", "NOTE-001", "--reason", "replaced by REPRO-478"])
.current_dir(tmp.path())
.assert()
.success();

let md = read_projection(tmp.path(), "notes", "NOTE-001");
assert!(
md.contains("## Deprecation"),
"the markdown file must carry the Deprecation section, got:\n{md}"
);
assert!(
md.contains("Reason: replaced by REPRO-478"),
"the markdown file must carry the reason itself, got:\n{md}"
);
assert!(
md.contains("status: deprecated"),
"status must still project, got:\n{md}"
);
}

/// The permanent-loss half. When the file lacks a section the store has, the
/// two disagree — and `read_file_body_if_newer` compares content, so the next
/// mutation writes the file body over the store and the reason is gone from
/// both. Agreement after the command is what makes the loss impossible.
#[test]
fn after_deprecate_the_file_and_the_store_agree() {
let tmp = TempDir::new().unwrap();
forgeplan()
.args(["init", "-y"])
.current_dir(tmp.path())
.assert()
.success();
forgeplan()
.args(["new", "note", "Divergence Test"])
.current_dir(tmp.path())
.assert()
.success();
forgeplan()
.args(["activate", "NOTE-001"])
.current_dir(tmp.path())
.assert()
.success();
forgeplan()
.args(["deprecate", "NOTE-001", "--reason", "AGREEMENT-MARKER"])
.current_dir(tmp.path())
.assert()
.success();

let md = read_projection(tmp.path(), "notes", "NOTE-001");
assert!(
md.contains("AGREEMENT-MARKER"),
"file lost the reason:\n{md}"
);

// `get` reads the store. Both surfaces must show it.
forgeplan()
.args(["get", "NOTE-001"])
.current_dir(tmp.path())
.assert()
.success()
.stdout(predicate::str::contains("AGREEMENT-MARKER"));
}

#[test]
fn reopen_writes_its_reason_into_the_retired_artifacts_file() {
let tmp = TempDir::new().unwrap();
forgeplan()
.args(["init", "-y"])
.current_dir(tmp.path())
.assert()
.success();
forgeplan()
.args(["new", "note", "Reopen Test"])
.current_dir(tmp.path())
.assert()
.success();
forgeplan()
.args(["activate", "NOTE-001"])
.current_dir(tmp.path())
.assert()
.success();
forgeplan()
.args(["reopen", "NOTE-001", "--reason", "REOPEN-MARKER-478"])
.current_dir(tmp.path())
.assert()
.success();

// The OLD artifact is the one that loses its section — the new one's file
// does not exist yet at render time, so it always kept its body.
let md = read_projection(tmp.path(), "notes", "NOTE-001");
assert!(
md.contains("## Reopened"),
"the retired artifact must record why it was retired, got:\n{md}"
);
assert!(
md.contains("REOPEN-MARKER-478"),
"the retired artifact must carry the reason, got:\n{md}"
);
}

#[test]
fn renew_writes_its_reason_into_the_markdown_file() {
let tmp = TempDir::new().unwrap();
forgeplan()
.args(["init", "-y"])
.current_dir(tmp.path())
.assert()
.success();
forgeplan()
.args(["new", "note", "Renew Test"])
.current_dir(tmp.path())
.assert()
.success();
forgeplan()
.args([
"renew",
"NOTE-001",
"--reason",
"RENEW-MARKER-478",
"--until",
"2099-01-01",
])
.current_dir(tmp.path())
.assert()
.success();

let md = read_projection(tmp.path(), "notes", "NOTE-001");
assert!(
md.contains("## Renewal"),
"the markdown file must carry the Renewal section, got:\n{md}"
);
assert!(
md.contains("RENEW-MARKER-478"),
"the markdown file must carry the reason, got:\n{md}"
);
}

// -----------------------------------------------------------------------
// ADR-005: draft → deprecated directly is NOT allowed
// -----------------------------------------------------------------------
Expand Down
Loading
Loading