Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3198309db5 | ||
|
|
59481515b5 | ||
|
|
a6cce683f5 | ||
|
|
2b0e8e6f10 |
@@ -1671,16 +1671,21 @@
|
||||
"fn init_content_store"
|
||||
],
|
||||
"server/src/db/gc.rs": [
|
||||
"fn purge_ephemeral_content_keys_for_story",
|
||||
"fn purge_content_keys_for_story",
|
||||
"fn spawn_content_gc_subscriber",
|
||||
"fn sweep_zombie_content_on_startup"
|
||||
"fn sweep_zombie_content_on_startup",
|
||||
"fn backfill_evicted_story_content"
|
||||
],
|
||||
"server/src/db/mod.rs": [
|
||||
"mod content_store",
|
||||
"mod gc",
|
||||
"mod ops",
|
||||
"mod recover",
|
||||
"mod shadow_write"
|
||||
"mod shadow_write",
|
||||
"mod tests",
|
||||
"static SHADOW_RT",
|
||||
"fn ensure_shadow_db"
|
||||
],
|
||||
"server/src/db/ops.rs": [
|
||||
"struct ItemMeta",
|
||||
|
||||
Generated
+60
-49
@@ -100,9 +100,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "anymap2"
|
||||
@@ -221,13 +221,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
version = "0.1.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1925,7 +1925,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "huskies"
|
||||
version = "0.14.1"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"async-stream",
|
||||
@@ -2351,7 +2351,7 @@ dependencies = [
|
||||
"jni-sys",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -2612,7 +2612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0"
|
||||
dependencies = [
|
||||
"matrix-pickle-derive",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2676,7 +2676,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
@@ -2709,7 +2709,7 @@ dependencies = [
|
||||
"ruma",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"unicode-normalization",
|
||||
@@ -2730,7 +2730,7 @@ dependencies = [
|
||||
"ruma",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -2769,7 +2769,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -2803,7 +2803,7 @@ dependencies = [
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
@@ -2833,7 +2833,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"vodozemac",
|
||||
@@ -2858,7 +2858,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2880,7 +2880,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -3333,7 +3333,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"sse-codec",
|
||||
"sync_wrapper",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite 0.27.0",
|
||||
@@ -3510,7 +3510,7 @@ dependencies = [
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -3723,22 +3723,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.25"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
|
||||
checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.25"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
|
||||
checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3910,7 +3910,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_html_form",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"url",
|
||||
"web-time",
|
||||
]
|
||||
@@ -3939,7 +3939,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_html_form",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -3963,7 +3963,7 @@ dependencies = [
|
||||
"ruma-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"web-time",
|
||||
"wildmatch",
|
||||
@@ -3989,7 +3989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432"
|
||||
dependencies = [
|
||||
"js_int",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4229,9 +4229,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
@@ -4260,22 +4260,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4622,7 +4622,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -4661,7 +4661,7 @@ dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-sqlite",
|
||||
"syn 2.0.119",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
@@ -4688,7 +4688,7 @@ dependencies = [
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"sqlx-core",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -4722,7 +4722,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"whoami",
|
||||
]
|
||||
@@ -4746,7 +4746,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"sqlx-core",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
@@ -4843,6 +4843,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -4917,11 +4928,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.18",
|
||||
"thiserror-impl 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4937,13 +4948,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5277,7 +5288,7 @@ dependencies = [
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"sha1 0.10.7",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
@@ -5296,7 +5307,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1 0.11.0",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5477,7 +5488,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"version": "0.14.1",
|
||||
"version": "0.14.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "huskies",
|
||||
"version": "0.14.1",
|
||||
"version": "0.14.2",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"private": true,
|
||||
"version": "0.14.1",
|
||||
"version": "0.14.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "huskies"
|
||||
version = "0.14.1"
|
||||
version = "0.14.2"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
+309
-34
@@ -1,8 +1,10 @@
|
||||
//! Content-store garbage collection: TransitionFired subscriber and startup sweep.
|
||||
//! Content-store garbage collection: TransitionFired subscriber, startup
|
||||
//! sweep, and story-content backfill.
|
||||
//!
|
||||
//! When a pipeline item reaches a terminal stage (Done, Archived, Abandoned,
|
||||
//! Superseded, Rejected) every `ContentKey::*` entry for that story is purged
|
||||
//! from the in-memory content store. There are two purge paths:
|
||||
//! Two purge paths clear the *ephemeral* `ContentKey::*` working-state keys
|
||||
//! (gate output, respawn counters, merge-fixup flags, etc.) when a pipeline
|
||||
//! item reaches a terminal stage (Done, Archived, Abandoned, Superseded,
|
||||
//! Rejected):
|
||||
//!
|
||||
//! 1. **Subscriber** ([`spawn_content_gc_subscriber`]) — reacts to
|
||||
//! [`crate::pipeline_state::TransitionFired`] events and runs for new
|
||||
@@ -10,19 +12,35 @@
|
||||
//!
|
||||
//! 2. **Startup sweep** ([`sweep_zombie_content_on_startup`]) — cleans up
|
||||
//! zombie entries left over from sessions that predate the subscriber.
|
||||
//!
|
||||
//! `ContentKey::Story` — the story's markdown body — is deliberately
|
||||
//! excluded from both paths (story 1222): reaching a terminal stage is not
|
||||
//! deletion, and `show()` must keep returning content for Done/Archived
|
||||
//! stories indefinitely. The story body is only fully purged
|
||||
//! ([`purge_content_keys_for_story`]) when a story is genuinely tombstoned
|
||||
//! (absent from the live CRDT index — `evict_item`/`purge_story`), which
|
||||
//! the startup sweep still detects and cleans up.
|
||||
//!
|
||||
//! [`backfill_evicted_story_content`] is an idempotent, run-on-every-startup
|
||||
//! repair pass that restores `ContentKey::Story` for any terminal-stage
|
||||
//! story whose in-memory content was wiped by the pre-1222 purge bug, using
|
||||
//! the durable SQLite `pipeline_items.content` shadow column (which the
|
||||
//! purge never touched). Stories with no SQLite copy either are reported as
|
||||
//! unrecoverable.
|
||||
|
||||
use crate::db::{ContentKey, all_content_ids, delete_content};
|
||||
use crate::db::{ContentKey, all_content_ids, delete_content, get_shared_pool, write_content};
|
||||
use crate::pipeline_state::{Pipeline, Stage, Status};
|
||||
use crate::slog;
|
||||
use crate::slog_warn;
|
||||
|
||||
/// Purge every [`ContentKey`] variant for `story_id` from the in-memory content store.
|
||||
/// Purge the eight ephemeral (non-body) `ContentKey` variants for
|
||||
/// `story_id` — everything except `ContentKey::Story`.
|
||||
///
|
||||
/// All eight key namespaces are deleted unconditionally — deletes for absent
|
||||
/// keys are no-ops. Call this when a work item reaches a terminal stage to
|
||||
/// prevent long-lived zombie entries from accumulating in the process heap.
|
||||
pub(crate) fn purge_content_keys_for_story(story_id: &str) {
|
||||
delete_content(ContentKey::Story(story_id));
|
||||
/// Call this when a work item reaches a terminal stage: agent working-state
|
||||
/// (gate output, respawn counters, merge-fixup flags) is no longer needed,
|
||||
/// but the story's markdown body must be retained indefinitely so `show()`
|
||||
/// keeps working (story 1222). Deletes for absent keys are no-ops.
|
||||
pub(crate) fn purge_ephemeral_content_keys_for_story(story_id: &str) {
|
||||
delete_content(ContentKey::GateOutput(story_id));
|
||||
delete_content(ContentKey::AbortRespawnCount(story_id));
|
||||
delete_content(ContentKey::MergeMasterSpawnCount(story_id));
|
||||
@@ -33,13 +51,28 @@ pub(crate) fn purge_content_keys_for_story(story_id: &str) {
|
||||
delete_content(ContentKey::MergeReport(story_id));
|
||||
}
|
||||
|
||||
/// Spawn a background task that purges content-store entries when a story reaches a terminal stage.
|
||||
/// Purge every `ContentKey` variant for `story_id`, including the markdown
|
||||
/// body (`ContentKey::Story`), from the in-memory content store.
|
||||
///
|
||||
/// Subscribes to [`crate::pipeline_state::subscribe_transitions`]. On each
|
||||
/// Use this ONLY for stories that are genuinely gone — tombstoned / absent
|
||||
/// from the live CRDT index via `evict_item`/`purge_story`. A terminal
|
||||
/// pipeline stage (Done, Archived, …) is NOT deletion — use
|
||||
/// [`purge_ephemeral_content_keys_for_story`] for that case so `show()`
|
||||
/// keeps returning content (story 1222). Deletes for absent keys are no-ops.
|
||||
pub(crate) fn purge_content_keys_for_story(story_id: &str) {
|
||||
delete_content(ContentKey::Story(story_id));
|
||||
purge_ephemeral_content_keys_for_story(story_id);
|
||||
}
|
||||
|
||||
/// Spawn a background task that purges ephemeral content-store entries when a story reaches a terminal stage.
|
||||
///
|
||||
/// Subscribes to [`crate::pipeline_state::subscribe_transitions`]. On each
|
||||
/// [`crate::pipeline_state::TransitionFired`] where `after` is `Done`,
|
||||
/// `Archived`, `Abandoned`, `Superseded`, or `Rejected`, all `ContentKey::*`
|
||||
/// entries for that story are purged. Lag events are logged as warnings —
|
||||
/// a missed event leaves zombie entries that the next startup sweep will remove.
|
||||
/// `Archived`, `Abandoned`, `Superseded`, or `Rejected`, the eight ephemeral
|
||||
/// `ContentKey::*` entries for that story are purged — the markdown body
|
||||
/// (`ContentKey::Story`) is retained (story 1222). Lag events are logged as
|
||||
/// warnings — a missed event leaves zombie entries that the next startup
|
||||
/// sweep will remove.
|
||||
pub(crate) fn spawn_content_gc_subscriber() {
|
||||
let mut rx = crate::pipeline_state::subscribe_transitions();
|
||||
tokio::spawn(async move {
|
||||
@@ -50,9 +83,9 @@ pub(crate) fn spawn_content_gc_subscriber() {
|
||||
let story_id = &fired.story_id.0;
|
||||
slog!(
|
||||
"[content-gc] Story '{story_id}' reached terminal stage; \
|
||||
purging all content-store entries."
|
||||
purging ephemeral content-store entries (body retained)."
|
||||
);
|
||||
purge_content_keys_for_story(story_id);
|
||||
purge_ephemeral_content_keys_for_story(story_id);
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
@@ -70,7 +103,12 @@ pub(crate) fn spawn_content_gc_subscriber() {
|
||||
/// One-shot startup sweep: purge content-store entries for stories that have
|
||||
/// already reached terminal stages or are absent from the CRDT.
|
||||
///
|
||||
/// Idempotent — safe to call more than once. Intended to clean up zombie
|
||||
/// Genuinely tombstoned/absent stories get a full purge (including the
|
||||
/// markdown body). Stories that are still live in the CRDT but sitting in a
|
||||
/// terminal stage only have their ephemeral working-state keys purged — the
|
||||
/// markdown body is retained so `show()` keeps working (story 1222).
|
||||
///
|
||||
/// Idempotent — safe to call more than once. Intended to clean up zombie
|
||||
/// entries left over from server sessions that predate the GC subscriber.
|
||||
pub(crate) fn sweep_zombie_content_on_startup() {
|
||||
let raw_keys = all_content_ids();
|
||||
@@ -92,25 +130,94 @@ pub(crate) fn sweep_zombie_content_on_startup() {
|
||||
|
||||
let mut swept = 0usize;
|
||||
for story_id in &story_ids {
|
||||
let should_purge = match crate::crdt_state::read_item(story_id) {
|
||||
// Tombstoned or absent from the live CRDT index — purge.
|
||||
None => true,
|
||||
Some(item) => is_terminal_stage(item.stage()),
|
||||
};
|
||||
if should_purge {
|
||||
purge_content_keys_for_story(story_id);
|
||||
swept += 1;
|
||||
match crate::crdt_state::read_item(story_id) {
|
||||
// Tombstoned or absent from the live CRDT index — genuinely
|
||||
// gone, safe to purge everything including the story body.
|
||||
None => {
|
||||
purge_content_keys_for_story(story_id);
|
||||
swept += 1;
|
||||
}
|
||||
// Still a live item, just sitting in a terminal stage — retain
|
||||
// the story body (story 1222), only purge ephemeral state.
|
||||
Some(item) if is_terminal_stage(item.stage()) => {
|
||||
purge_ephemeral_content_keys_for_story(story_id);
|
||||
swept += 1;
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
if swept > 0 {
|
||||
slog!(
|
||||
"[content-gc] Startup sweep purged content-store entries for \
|
||||
"[content-gc] Startup sweep purged ephemeral content-store entries for \
|
||||
{swept} zombie story(s)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore in-memory `ContentKey::Story` content for terminal-stage stories
|
||||
/// whose markdown body was wiped by the pre-1222 purge bug (which deleted
|
||||
/// `ContentKey::Story` on every terminal transition), using the durable
|
||||
/// SQLite `pipeline_items.content` shadow column — `delete_content` only
|
||||
/// ever touched the in-memory map, so the SQLite copy survived.
|
||||
///
|
||||
/// Idempotent and cheap to run on every startup: stories that already have
|
||||
/// in-memory content are skipped. Returns the story IDs that could not be
|
||||
/// recovered (also absent from the SQLite shadow column) — the caller logs
|
||||
/// this as a report (story 1222, AC4).
|
||||
pub(crate) async fn backfill_evicted_story_content() -> Vec<String> {
|
||||
let Some(pool) = get_shared_pool() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(items) = crate::crdt_state::read_all_items() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut restored = 0usize;
|
||||
let mut unrecoverable = Vec::new();
|
||||
|
||||
for item in items {
|
||||
if !is_terminal_stage(item.stage()) {
|
||||
continue;
|
||||
}
|
||||
let story_id = item.story_id();
|
||||
if crate::db::read_content(ContentKey::Story(story_id)).is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let row: Result<Option<(Option<String>,)>, sqlx::Error> =
|
||||
sqlx::query_as("SELECT content FROM pipeline_items WHERE id = ?1")
|
||||
.bind(story_id)
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
|
||||
match row {
|
||||
Ok(Some((Some(content),))) => {
|
||||
write_content(ContentKey::Story(story_id), &content);
|
||||
restored += 1;
|
||||
}
|
||||
_ => unrecoverable.push(story_id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
if restored > 0 {
|
||||
slog!(
|
||||
"[content-gc] Backfill restored content for {restored} terminal-stage \
|
||||
story(s) from the SQLite shadow table."
|
||||
);
|
||||
}
|
||||
if !unrecoverable.is_empty() {
|
||||
slog_warn!(
|
||||
"[content-gc] Backfill could not recover content for {} story(s) — \
|
||||
compacted away with no SQLite copy: {}",
|
||||
unrecoverable.len(),
|
||||
unrecoverable.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
unrecoverable
|
||||
}
|
||||
|
||||
/// Return `true` when `stage` is one of the terminal pipeline classifications.
|
||||
///
|
||||
/// Story 1086: matches via the [`Status`] projection (Done / Abandoned /
|
||||
@@ -181,6 +288,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Assert the seven ephemeral ContentKey variants seeded by
|
||||
/// `seed_all_keys` are absent, but `ContentKey::Story` (the markdown
|
||||
/// body) is still present — story 1222.
|
||||
fn assert_ephemeral_keys_absent_but_story_present(story_id: &str) {
|
||||
assert!(
|
||||
read_content(ContentKey::Story(story_id)).is_some(),
|
||||
"Story key must be RETAINED across a terminal-stage transition (story 1222)"
|
||||
);
|
||||
assert!(
|
||||
read_content(ContentKey::GateOutput(story_id)).is_none(),
|
||||
"GateOutput key must be absent"
|
||||
);
|
||||
assert!(
|
||||
read_content(ContentKey::AbortRespawnCount(story_id)).is_none(),
|
||||
"AbortRespawnCount key must be absent"
|
||||
);
|
||||
assert!(
|
||||
read_content(ContentKey::MergeMasterSpawnCount(story_id)).is_none(),
|
||||
"MergeMasterSpawnCount key must be absent"
|
||||
);
|
||||
assert!(
|
||||
read_content(ContentKey::RunTestsOk(story_id)).is_none(),
|
||||
"RunTestsOk key must be absent"
|
||||
);
|
||||
assert!(
|
||||
read_content(ContentKey::CommitRecoveryPending(story_id)).is_none(),
|
||||
"CommitRecoveryPending key must be absent"
|
||||
);
|
||||
assert!(
|
||||
read_content(ContentKey::MergeFixupPending(story_id)).is_none(),
|
||||
"MergeFixupPending key must be absent"
|
||||
);
|
||||
assert!(
|
||||
read_content(ContentKey::MergeFailureKind(story_id)).is_none(),
|
||||
"MergeFailureKind key must be absent"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC1: purge_content_keys_for_story removes all eight ContentKey namespaces.
|
||||
#[test]
|
||||
fn purge_clears_all_eight_content_key_namespaces() {
|
||||
@@ -216,10 +361,11 @@ mod tests {
|
||||
assert_all_keys_absent(id);
|
||||
}
|
||||
|
||||
/// AC1 + AC4: the GC subscriber reacts to an Abandoned terminal transition and
|
||||
/// purges all content-store entries for the story.
|
||||
/// Story 1222, AC1: the GC subscriber reacts to an Abandoned terminal
|
||||
/// transition and purges ephemeral content-store entries for the story,
|
||||
/// but RETAINS the story body so `show()` keeps working.
|
||||
#[tokio::test]
|
||||
async fn subscriber_purges_content_on_terminal_transition() {
|
||||
async fn subscriber_purges_ephemeral_keys_but_retains_story_content_on_terminal_transition() {
|
||||
crate::crdt_state::init_for_test();
|
||||
ensure_content_store();
|
||||
|
||||
@@ -242,7 +388,7 @@ mod tests {
|
||||
// Give the subscriber task time to run.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
assert_all_keys_absent(story_id);
|
||||
assert_ephemeral_keys_absent_but_story_present(story_id);
|
||||
}
|
||||
|
||||
/// AC4: the subscriber does NOT purge content for stories that remain in
|
||||
@@ -272,8 +418,9 @@ mod tests {
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Terminal story's content must be gone.
|
||||
assert_all_keys_absent(terminal_id);
|
||||
// Terminal story's ephemeral keys must be gone, but body retained
|
||||
// (story 1222).
|
||||
assert_ephemeral_keys_absent_but_story_present(terminal_id);
|
||||
|
||||
// Active story's main content key must still be present.
|
||||
assert!(
|
||||
@@ -309,6 +456,49 @@ mod tests {
|
||||
assert_all_keys_absent(story_id);
|
||||
}
|
||||
|
||||
/// Story 1222, AC1 + AC3: a story that reaches Done but is still LIVE in
|
||||
/// the CRDT (not tombstoned) must keep its body across the startup sweep
|
||||
/// — only ephemeral working-state keys are purged.
|
||||
#[test]
|
||||
fn startup_sweep_retains_story_content_for_live_done_item() {
|
||||
crate::crdt_state::init_for_test();
|
||||
ensure_content_store();
|
||||
|
||||
let story_id = "1222_test_sweep_live_done";
|
||||
write_item_with_content(
|
||||
story_id,
|
||||
"5_done",
|
||||
"---\nname: Live Done Test\n---\n",
|
||||
ItemMeta::named("Live Done Test"),
|
||||
);
|
||||
seed_all_keys(story_id);
|
||||
|
||||
sweep_zombie_content_on_startup();
|
||||
|
||||
assert_ephemeral_keys_absent_but_story_present(story_id);
|
||||
}
|
||||
|
||||
/// Story 1222, AC1 + AC3: same as above but for Archived, the other
|
||||
/// stage explicitly named in the AC.
|
||||
#[test]
|
||||
fn startup_sweep_retains_story_content_for_live_archived_item() {
|
||||
crate::crdt_state::init_for_test();
|
||||
ensure_content_store();
|
||||
|
||||
let story_id = "1222_test_sweep_live_archived";
|
||||
write_item_with_content(
|
||||
story_id,
|
||||
"6_archived",
|
||||
"---\nname: Live Archived Test\n---\n",
|
||||
ItemMeta::named("Live Archived Test"),
|
||||
);
|
||||
seed_all_keys(story_id);
|
||||
|
||||
sweep_zombie_content_on_startup();
|
||||
|
||||
assert_ephemeral_keys_absent_but_story_present(story_id);
|
||||
}
|
||||
|
||||
/// AC2: sweep_zombie_content_on_startup leaves active stories' content intact.
|
||||
#[test]
|
||||
fn startup_sweep_preserves_active_story_content() {
|
||||
@@ -342,4 +532,89 @@ mod tests {
|
||||
sweep_zombie_content_on_startup();
|
||||
sweep_zombie_content_on_startup();
|
||||
}
|
||||
|
||||
/// Story 1222, AC4: with no shared SQLite pool initialised, the backfill
|
||||
/// is a safe no-op (returns no unrecoverable IDs, does not panic).
|
||||
#[tokio::test]
|
||||
async fn backfill_is_noop_without_shared_pool() {
|
||||
crate::crdt_state::init_for_test();
|
||||
ensure_content_store();
|
||||
|
||||
let unrecoverable = backfill_evicted_story_content().await;
|
||||
assert!(unrecoverable.is_empty());
|
||||
}
|
||||
|
||||
/// Story 1222, AC4: the backfill restores in-memory Story content for a
|
||||
/// terminal-stage story whose body was wiped from the in-memory store,
|
||||
/// using the durable SQLite shadow column that the (buggy, now-fixed)
|
||||
/// terminal-transition purge never touched.
|
||||
#[tokio::test]
|
||||
async fn backfill_restores_story_content_from_sqlite_shadow_column() {
|
||||
crate::crdt_state::init_for_test();
|
||||
ensure_content_store();
|
||||
crate::db::tests::ensure_shadow_db().await;
|
||||
|
||||
let story_id = "1222_test_backfill_restore";
|
||||
let body = "---\nname: Backfill Restore Test\n---\n# Body\n";
|
||||
|
||||
write_item_with_content(
|
||||
story_id,
|
||||
"5_done",
|
||||
body,
|
||||
ItemMeta::named("Backfill Restore Test"),
|
||||
);
|
||||
|
||||
// Let the shadow-write background task flush the insert to SQLite.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
|
||||
// Simulate the pre-1222 bug: wipe the in-memory Story key only.
|
||||
// SQLite still has it — delete_content never touches the shadow
|
||||
// table.
|
||||
delete_content(ContentKey::Story(story_id));
|
||||
assert!(read_content(ContentKey::Story(story_id)).is_none());
|
||||
|
||||
let unrecoverable = backfill_evicted_story_content().await;
|
||||
|
||||
assert!(
|
||||
!unrecoverable.contains(&story_id.to_string()),
|
||||
"story with a surviving SQLite copy must not be reported unrecoverable"
|
||||
);
|
||||
assert_eq!(
|
||||
read_content(ContentKey::Story(story_id)).as_deref(),
|
||||
Some(body),
|
||||
"backfill must restore the body from the SQLite shadow column"
|
||||
);
|
||||
}
|
||||
|
||||
/// Story 1222, AC4: a terminal-stage story with no SQLite copy either
|
||||
/// (never shadow-written) is reported as unrecoverable, not silently
|
||||
/// dropped.
|
||||
#[tokio::test]
|
||||
async fn backfill_reports_unrecoverable_story_with_no_sqlite_copy() {
|
||||
crate::crdt_state::init_for_test();
|
||||
ensure_content_store();
|
||||
crate::db::tests::ensure_shadow_db().await;
|
||||
|
||||
let story_id = "1222_test_backfill_unrecoverable";
|
||||
|
||||
// Write directly to the CRDT only — bypass write_item_with_content
|
||||
// so no shadow-write message is ever sent, and never write
|
||||
// in-memory content either, simulating a story whose SQLite copy
|
||||
// was never captured (or was compacted away).
|
||||
crate::crdt_state::write_item_str(
|
||||
story_id,
|
||||
"5_done",
|
||||
Some("Unrecoverable Test"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let unrecoverable = backfill_evicted_story_content().await;
|
||||
|
||||
assert!(
|
||||
unrecoverable.contains(&story_id.to_string()),
|
||||
"story with no in-memory AND no SQLite content must be reported unrecoverable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -37,8 +37,10 @@ pub use shadow_write::{check_schema_drift, get_shared_pool, init};
|
||||
#[cfg(test)]
|
||||
pub use content_store::ensure_content_store;
|
||||
|
||||
/// Shared test helpers for the `db` module, including [`tests::ensure_shadow_db`]
|
||||
/// which `db::gc::tests` reuses for backfill tests (story 1222).
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
@@ -597,10 +599,16 @@ mod tests {
|
||||
/// Park the init on a leaked multi-thread runtime so the bg task lives for
|
||||
/// the whole test process; mirrors `db::ops::tests::ensure_shadow_db`.
|
||||
#[cfg(test)]
|
||||
static SHADOW_RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
|
||||
pub(crate) static SHADOW_RT: std::sync::OnceLock<tokio::runtime::Runtime> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// Shared test helper: initialise the shadow SQLite DB exactly once per
|
||||
/// test binary, parked on a leaked multi-thread runtime so the
|
||||
/// background write task survives past any single `#[tokio::test]`'s
|
||||
/// per-test runtime teardown. Reused by `db::gc::tests` for backfill
|
||||
/// tests (story 1222) — do not duplicate this dance elsewhere.
|
||||
#[cfg(test)]
|
||||
async fn ensure_shadow_db() {
|
||||
pub(crate) async fn ensure_shadow_db() {
|
||||
static INIT: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
if INIT.get().is_some() {
|
||||
return;
|
||||
|
||||
@@ -350,7 +350,25 @@ pub async fn gateway_mcp_post_handler(
|
||||
Some(project) => {
|
||||
proxy_and_respond_for_project(&state, project, &bytes, rpc.id).await
|
||||
}
|
||||
None => proxy_and_respond(&state, &bytes, rpc.id).await,
|
||||
None if is_create_tool(tool_name) => {
|
||||
// Story 1225 AC 3: with >1 project registered, a
|
||||
// create call omitting `project` is ambiguous — fail
|
||||
// loudly instead of silently filing into whichever
|
||||
// project happens to be active.
|
||||
let project_count = state.projects.read().await.len();
|
||||
if project_count > 1 {
|
||||
to_json_response(JsonRpcResponse::error(
|
||||
rpc.id,
|
||||
-32602,
|
||||
format!(
|
||||
"'{tool_name}' requires an explicit `project` argument when more than one project is registered (see list_projects) — the active project is not used implicitly for creates."
|
||||
),
|
||||
))
|
||||
} else {
|
||||
proxy_and_respond_with_resolved_project(&state, &bytes, rpc.id).await
|
||||
}
|
||||
}
|
||||
None => proxy_and_respond_with_resolved_project(&state, &bytes, rpc.id).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,6 +394,50 @@ async fn proxy_and_respond(state: &GatewayState, bytes: &[u8], id: Option<Value>
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` for tool names that create a new work item or resource
|
||||
/// (`create_bug`, `create_story`, `create_worktree`, ...), used to gate
|
||||
/// implicit active-project routing behind an explicit `project` argument
|
||||
/// when the destination is ambiguous (story 1225 AC 3).
|
||||
fn is_create_tool(tool_name: &str) -> bool {
|
||||
tool_name.starts_with("create_")
|
||||
}
|
||||
|
||||
/// Proxy a request to the active project and, on success, annotate the
|
||||
/// response with which project actually handled it (story 1225 AC 3) — so a
|
||||
/// caller who omitted `project` can see whether their call landed where they
|
||||
/// expected instead of silently trusting `active_project`.
|
||||
async fn proxy_and_respond_with_resolved_project(
|
||||
state: &GatewayState,
|
||||
bytes: &[u8],
|
||||
id: Option<Value>,
|
||||
) -> Response {
|
||||
let active = state.active_project.read().await.clone();
|
||||
match state.proxy_active_mcp(bytes).await {
|
||||
Ok(resp_body) => Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(annotate_resolved_project(&resp_body, &active))),
|
||||
Err(e) => to_json_response(JsonRpcResponse::error(
|
||||
id,
|
||||
-32603,
|
||||
format!("proxy error: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a `resolved_project` field into a JSON-RPC `result` object,
|
||||
/// leaving the bytes unchanged if they don't parse as JSON or `result` isn't
|
||||
/// an object (e.g. an error response, which has no `result` at all).
|
||||
fn annotate_resolved_project(bytes: &[u8], project: &str) -> Vec<u8> {
|
||||
let Ok(mut value) = serde_json::from_slice::<Value>(bytes) else {
|
||||
return bytes.to_vec();
|
||||
};
|
||||
if let Some(result) = value.get_mut("result").and_then(|r| r.as_object_mut()) {
|
||||
result.insert("resolved_project".to_string(), json!(project));
|
||||
}
|
||||
serde_json::to_vec(&value).unwrap_or_else(|_| bytes.to_vec())
|
||||
}
|
||||
|
||||
/// Proxy a request to an explicitly named project (story 1208 AC 1) rather
|
||||
/// than whatever project is currently active, so a single ops/LLM session
|
||||
/// can address any registered project per-call without a prior
|
||||
@@ -1310,6 +1372,7 @@ async fn handle_pipeline_get(state: &GatewayState, id: Option<Value>) -> JsonRpc
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::service::gateway::config::{GatewayConfig, ProjectEntry};
|
||||
use poem::EndpointExt as _;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -1688,4 +1751,232 @@ mod tests {
|
||||
inject_project_arg_schema(&mut tools);
|
||||
assert_eq!(tools[0], json!({"name": "no_schema"}));
|
||||
}
|
||||
|
||||
// ── project arg is honored end-to-end, not silently misrouted (story 1225) ─
|
||||
|
||||
/// Register a fake sled connection for `name` whose background task
|
||||
/// answers every `mcp_request` with a canned response naming itself, so
|
||||
/// tests can assert *which* project actually handled a routed call.
|
||||
async fn spawn_fake_sled(name: &str) -> gateway::SledConnection {
|
||||
let (tx, mut rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<crate::sled_uplink::UplinkEnvelope>();
|
||||
let in_flight: Arc<
|
||||
tokio::sync::Mutex<
|
||||
std::collections::HashMap<String, tokio::sync::oneshot::Sender<Value>>,
|
||||
>,
|
||||
> = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
let in_flight_task = Arc::clone(&in_flight);
|
||||
let label = name.to_string();
|
||||
tokio::spawn(async move {
|
||||
while let Some(env) = rx.recv().await {
|
||||
if let Some(sender) = in_flight_task.lock().await.remove(&env.req_id) {
|
||||
let _ = sender.send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": { "content": [{ "type": "text", "text": format!("handled_by:{label}") }] }
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
gateway::SledConnection {
|
||||
tx,
|
||||
last_heartbeat_ms: Arc::new(std::sync::atomic::AtomicI64::new(
|
||||
chrono::Utc::now().timestamp_millis(),
|
||||
)),
|
||||
in_flight,
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_two_project_state(dir: &std::path::Path) -> Arc<GatewayState> {
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert("alpha".to_string(), ProjectEntry::with_url("http://a:3001"));
|
||||
projects.insert("beta".to_string(), ProjectEntry::with_url("http://b:3002"));
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
release_channels: BTreeMap::new(),
|
||||
};
|
||||
let state = Arc::new(GatewayState::new(config, dir.to_path_buf(), 3000).unwrap());
|
||||
assert_eq!(*state.active_project.read().await, "alpha");
|
||||
state
|
||||
.register_sled_connection("alpha".to_string(), spawn_fake_sled("alpha").await)
|
||||
.await;
|
||||
state
|
||||
.register_sled_connection("beta".to_string(), spawn_fake_sled("beta").await)
|
||||
.await;
|
||||
state
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_tool_with_explicit_project_lands_in_non_active_project() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = make_two_project_state(dir.path()).await;
|
||||
let app = poem::Route::new()
|
||||
.at("/mcp", poem::post(gateway_mcp_post_handler))
|
||||
.data(state);
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
|
||||
let rpc_body = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "create_bug",
|
||||
"arguments": { "project": "beta", "title": "x", "description": "y" }
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
let parsed: Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(
|
||||
parsed["result"]["content"][0]["text"], "handled_by:beta",
|
||||
"explicit project=beta must land in beta even though alpha is active: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_tool_with_explicit_project_reads_from_non_active_project() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = make_two_project_state(dir.path()).await;
|
||||
let app = poem::Route::new()
|
||||
.at("/mcp", poem::post(gateway_mcp_post_handler))
|
||||
.data(state);
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
|
||||
let rpc_body = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_pipeline_status",
|
||||
"arguments": { "project": "beta" }
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
let parsed: Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(
|
||||
parsed["result"]["content"][0]["text"], "handled_by:beta",
|
||||
"explicit project=beta must read from beta even though alpha is active: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_tool_without_project_errors_when_multiple_projects_registered() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = make_two_project_state(dir.path()).await;
|
||||
let app = poem::Route::new()
|
||||
.at("/mcp", poem::post(gateway_mcp_post_handler))
|
||||
.data(state);
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
|
||||
let rpc_body = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "create_bug",
|
||||
"arguments": { "title": "x", "description": "y" }
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
let parsed: Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert!(
|
||||
parsed["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("requires an explicit `project`"),
|
||||
"create call omitting `project` with >1 project registered must error, not \
|
||||
silently file into the active project: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_tool_without_project_annotates_resolved_project() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = make_two_project_state(dir.path()).await;
|
||||
let app = poem::Route::new()
|
||||
.at("/mcp", poem::post(gateway_mcp_post_handler))
|
||||
.data(state);
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
|
||||
let rpc_body = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_pipeline_status",
|
||||
"arguments": {}
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
let parsed: Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(
|
||||
parsed["result"]["content"][0]["text"], "handled_by:alpha",
|
||||
"sanity: implicit routing hit the active project: {parsed}"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["result"]["resolved_project"], "alpha",
|
||||
"omitting `project` must echo which project was actually resolved: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotate_resolved_project_inserts_field_into_result_object() {
|
||||
let bytes = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": { "content": [{ "type": "text", "text": "ok" }] }
|
||||
}))
|
||||
.unwrap();
|
||||
let annotated = annotate_resolved_project(&bytes, "alpha");
|
||||
let parsed: Value = serde_json::from_slice(&annotated).unwrap();
|
||||
assert_eq!(parsed["result"]["resolved_project"], "alpha");
|
||||
assert_eq!(parsed["result"]["content"][0]["text"], "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotate_resolved_project_leaves_error_response_untouched() {
|
||||
let bytes = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": { "code": -32603, "message": "boom" }
|
||||
}))
|
||||
.unwrap();
|
||||
let annotated = annotate_resolved_project(&bytes, "alpha");
|
||||
let parsed: Value = serde_json::from_slice(&annotated).unwrap();
|
||||
assert_eq!(parsed["error"]["message"], "boom");
|
||||
assert!(parsed.get("result").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_create_tool_matches_create_prefixed_names_only() {
|
||||
assert!(is_create_tool("create_bug"));
|
||||
assert!(is_create_tool("create_story"));
|
||||
assert!(is_create_tool("create_worktree"));
|
||||
assert!(!is_create_tool("get_pipeline_status"));
|
||||
assert!(!is_create_tool("show"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,6 +382,48 @@ mod tests {
|
||||
assert_eq!(depends_on[1], 200);
|
||||
}
|
||||
|
||||
/// Story 1222 regression: `show` must return full content for a story
|
||||
/// after it reaches Done and again after it reaches Archived — the
|
||||
/// terminal-stage content purge must never evict the story body, only
|
||||
/// its ephemeral bookkeeping keys.
|
||||
#[tokio::test]
|
||||
async fn tool_show_returns_content_after_done_and_after_archived() {
|
||||
let tmp = tempdir().unwrap();
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
|
||||
let story_id = "1222_story_done_archived_test";
|
||||
let story_content = "# Story\n\n## Acceptance Criteria\n\n- [ ] Ship it\n";
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"5_done",
|
||||
story_content,
|
||||
crate::db::ItemMeta::named("Done Archived Test"),
|
||||
);
|
||||
|
||||
// Simulate the terminal-transition purge that fires when a story
|
||||
// reaches Done (story 996 GC subscriber / sweep).
|
||||
crate::db::gc::purge_ephemeral_content_keys_for_story(story_id);
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||
let result = tool_show(&json!({"story_id": story_id}), &ctx)
|
||||
.await
|
||||
.expect("show must succeed for a Done story, not return content unavailable");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["story_id"], story_id);
|
||||
assert_eq!(parsed["front_matter"]["name"], "Done Archived Test");
|
||||
|
||||
// Move to Archived and purge again — content must still be readable.
|
||||
crate::db::move_item_stage(story_id, "6_archived", None);
|
||||
crate::db::gc::purge_ephemeral_content_keys_for_story(story_id);
|
||||
|
||||
let result = tool_show(&json!({"story_id": story_id}), &ctx)
|
||||
.await
|
||||
.expect("show must succeed for an Archived story, not return content unavailable");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["story_id"], story_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_show_returns_story_data() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -588,6 +588,10 @@ pub(crate) async fn run_reconcile_pass(
|
||||
// Content-GC: purge content-store entries for terminal/tombstoned stories.
|
||||
crate::db::gc::sweep_zombie_content_on_startup();
|
||||
|
||||
// Content backfill: restore story bodies wiped by the pre-1222 buggy
|
||||
// terminal-transition purge from the durable SQLite shadow column.
|
||||
crate::db::gc::backfill_evicted_story_content().await;
|
||||
|
||||
// Worktree create: ensure every Coding story has a worktree.
|
||||
crate::agents::pool::worktree_lifecycle::reconcile_worktree_create(root, agents.port()).await;
|
||||
|
||||
@@ -749,12 +753,16 @@ mod tests {
|
||||
"run_reconcile_pass must not broadcast through the transition channel (no Lagged)"
|
||||
);
|
||||
|
||||
// ── Assert: zombie content purged for all 200 Abandoned stories ────
|
||||
// ── Assert: story content is RETAINED for all 200 Abandoned stories ──
|
||||
// Story 1222: content must never be evicted on a pipeline transition
|
||||
// (Done/Archived/Abandoned/etc.) — only genuinely deleted/tombstoned
|
||||
// stories get their body purged. These stories are still live in the
|
||||
// CRDT (merely Abandoned), so their content-store entry must survive.
|
||||
for i in 0..200u32 {
|
||||
let id = format!("1066_abandoned_{i:04}");
|
||||
assert!(
|
||||
crate::db::read_content(ContentKey::Story(&id)).is_none(),
|
||||
"zombie content must be purged for abandoned story {id}"
|
||||
crate::db::read_content(ContentKey::Story(&id)).is_some(),
|
||||
"story content must be retained for abandoned story {id} (story 1222)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user