Compare commits

...

5 Commits

Author SHA1 Message Date
Yeachan-Heo
6a957560bd Make recovery handoffs explain why a lane resumed instead of leaking control prose
Recent OMX dogfooding kept surfacing raw `[OMX_TMUX_INJECT]`
messages as lane results, which told operators that tmux reinjection
happened but not why or what lane/state it applied to. The lane-finished
persistence path now recognizes that control prose, stores structured
recovery metadata, and emits a human-meaningful fallback summary instead
of preserving the raw marker as the primary result.

Constraint: Keep the fix in the existing lane-finished metadata surface rather than inventing a new runtime channel
Rejected: Treat all reinjection prose as ordinary quality-floor mush | loses the recovery cause and target lane operators actually need
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Recovery classification is heuristic; extend the parser only when new operator phrasing shows up in real dogfood evidence
Tested: cargo fmt --all --check
Tested: cargo clippy --workspace --all-targets -- -D warnings
Tested: cargo test --workspace
Tested: LSP diagnostics on rust/crates/tools/src/lib.rs (0 errors)
Tested: Architect review (APPROVE)
Not-tested: Additional reinjection phrasings beyond the currently observed `[OMX_TMUX_INJECT]` / current-mode-state variants
Related: ROADMAP #68
2026-04-12 15:50:39 +00:00
Yeachan-Heo
42bb6cdba6 Keep local clawhip artifacts from tripping routine repo work
Dogfooding kept reproducing OMX team merge conflicts on
`.clawhip/state/prompt-submit.json`, so the init bootstrap now
teaches repos to ignore `.clawhip/` alongside the existing local
`.claw/` artifacts. This also updates the current repo ignore list
so the fix helps immediately instead of only on future `claw init`
runs.

Constraint: Keep the fix narrow and centered on repo-local ignore hygiene
Rejected: Broader team merge-hygiene changes | unnecessary for the proven local root cause
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If more runtime-local artifact directories appear, extend the shared init gitignore list instead of patching repos ad hoc
Tested: cargo fmt --all --check
Tested: cargo clippy --workspace --all-targets -- -D warnings
Tested: cargo test --workspace
Tested: Architect review (APPROVE)
Not-tested: Existing clones with already-tracked `.clawhip` files still need manual cleanup
Related: ROADMAP #75
2026-04-12 14:47:40 +00:00
Yeachan-Heo
f91d156f85 Keep poisoned test locks from cascading across unrelated regressions
The repo-local backlog was effectively exhausted, so this sweep promoted the
newly observed test-lock poisoning pain point into ROADMAP #74 and fixed it in
place. Test-only env/cwd lock acquisition now recovers poisoned mutexes in the
remaining strict call sites, and each affected surface has a regression that
proves a panic no longer permanently poisons later tests.

Constraint: Keep the fix test-only and avoid widening runtime behavior changes
Rejected: Refactor shared helper signatures across broader call paths | unnecessary churn beyond the remaining strict test sites
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: These guards only recover the mutex; tests that mutate env or cwd still must restore process-global state explicitly
Tested: cargo fmt --all --check
Tested: cargo clippy --workspace --all-targets -- -D warnings
Tested: cargo test --workspace
Tested: Architect review (APPROVE)
Not-tested: Additional fault-injection around partially restored env/cwd state after panic
Related: ROADMAP #74
2026-04-12 13:52:41 +00:00
Yeachan-Heo
6b4bb4ac26 Keep finished lanes from leaving stale reminders armed
The next repo-local sweep target was ROADMAP #66: reminder/cron
state could stay enabled after the associated lane had already
finished, which left stale nudges firing into completed work. The
fix teaches successful lane persistence to disable matching enabled
cron entries and record which reminder ids were shut down on the
finished event.

Constraint: Preserve existing cron/task registries and add the shutdown behavior only on the successful lane-finished path
Rejected: Add a separate reminder-cleanup command that operators must remember to run | leaves the completion leak unfixed at the source
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If cron-matching heuristics change later, update `disable_matching_crons`, its regression, and the ROADMAP closeout together
Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace; architect review APPROVE
Not-tested: Cross-process cron/reminder persistence beyond the in-memory registry used in this repo
2026-04-12 12:52:27 +00:00
Yeachan-Heo
e75d67dfd3 Make successful lanes explain what artifacts they actually produced
The next repo-local sweep target was ROADMAP #64: downstream consumers
still had to infer artifact provenance from prose even though the repo
already emitted structured lane events. The fix extends `lane.finished`
metadata with structured artifact provenance so successful completions
can report roadmap ids, files, diff stat, verification state, and commit
sha without relying on narration alone.

Constraint: Preserve the existing commit-created event and lane-finished metadata paths while adding structured provenance to successful completions
Rejected: Introduce a separate artifact event type first | unnecessary for this focused closeout because `lane.finished` already carries structured data and existing consumers can read it there
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If artifact provenance extraction rules change later, update `extract_artifact_provenance`, its regression payload, and the ROADMAP closeout together
Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace; architect review APPROVE
Not-tested: Downstream consumers that ignore `lane.finished.data.artifactProvenance` and still parse only prose output
2026-04-12 11:56:00 +00:00
7 changed files with 548 additions and 51 deletions

1
.gitignore vendored
View File

@@ -8,4 +8,5 @@ archive/
# Claw Code local artifacts
.claw/settings.local.json
.claw/sessions/
.clawhip/
status-help.txt

View File

@@ -496,19 +496,19 @@ Model name prefix now wins unconditionally over env-var presence. Regression tes
62. **Worker state file surface not implemented****done (verified 2026-04-12):** current `main` already wires `emit_state_file(worker)` into the worker transition path in `rust/crates/runtime/src/worker_boot.rs`, atomically writes `.claw/worker-state.json`, and exposes the documented reader surface through `claw state` / `claw state --output-format json` in `rust/crates/rusty-claude-cli/src/main.rs`. Fresh proof exists in `runtime` regression `emit_state_file_writes_worker_status_on_transition`, the end-to-end `tools` regression `recovery_loop_state_file_reflects_transitions`, and direct CLI parsing coverage for `state` / `state --output-format json`. Source: Jobdori dogfood.
**Scope note (verified 2026-04-12):** ROADMAP #31, #43, and #63-#68 currently appear to describe acpx/droid or upstream OMX/server orchestration behavior, not claw-code source already present in this repository. Repo-local searches for `acpx`, `use-droid`, `run-acpx`, `commit-wrapper`, `ultraclaw`, `roadmap-nudge-10min`, `OMX_TMUX_INJECT`, `/hooks/health`, and `/hooks/status` found no implementation hits outside `ROADMAP.md`, and the earlier state-surface note already records that the HTTP server is not owned by claw-code. With #45, #65, #67, and #69 now fixed, the remaining unresolved items in this section look like external tracking notes rather than confirmed repo-local backlog; re-check if new repo-local evidence appears.
**Scope note (verified 2026-04-12):** ROADMAP #31, #43, and #63 currently appear to describe acpx/droid or upstream OMX/server orchestration behavior, not claw-code source already present in this repository. Repo-local searches for `acpx`, `use-droid`, `run-acpx`, `commit-wrapper`, `ultraclaw`, `/hooks/health`, and `/hooks/status` found no implementation hits outside `ROADMAP.md`, and the earlier state-surface note already records that the HTTP server is not owned by claw-code. With #45, #64-#69, and #75 now fixed, the remaining unresolved items in this section still look like external tracking notes rather than confirmed repo-local backlog; re-check if new repo-local evidence appears.
63. **Droid session completion semantics broken: code arrives after "status: completed"** — dogfooded 2026-04-12. Ultraclaw droid sessions (use-droid via acpx) report `session.status: completed` before file writes are fully flushed/synced to the working tree. Discovered +410 lines of "late-arriving" droid output that appeared after I had already assessed 8 sessions as "no code produced." This creates false-negative assessments and duplicate work. **Fix shape:** (a) droid agent should only report completion after explicit file-write confirmation (fsync or existence check); (b) or, claw-code should expose a `pending_writes` status that indicates "agent responded, disk flush pending"; (c) lane orchestrators should poll for file changes for N seconds after completion before final assessment. **Blocker:** none. Source: Jobdori ultraclaw dogfood 2026-04-12.
64. **Artifact provenance is post-hoc narration, not structured events**dogfooded 2026-04-12. The ultraclaw batch delivered 4 ROADMAP items and 3 commits, but the event stream only contained log-shaped text ("+410 lines detected", "committing...", "pushed"). Downstream consumers (clawhip, lane orchestrators, monitors) must reconstruct provenance from chat messages rather than consuming first-class events. **Fix shape:** emit structured artifact/result events with: `sourceLanes`, `roadmapIds`, `files`, `diffStat`, `verification: tested|committed|pushed|merged`, `commitSha`. Remove dependency on human/bot narration layer to explain what actually landed. Blocker: none. Source: gaebal-gajae dogfood analysis 2026-04-12.
64. **Artifact provenance is post-hoc narration, not structured events****done (verified 2026-04-12):** completed lane persistence in `rust/crates/tools/src/lib.rs` now attaches structured `artifactProvenance` metadata to `lane.finished`, including `sourceLanes`, `roadmapIds`, `files`, `diffStat`, `verification`, and `commitSha`, while keeping the existing `lane.commit.created` provenance event intact. Regression coverage locks a successful completion payload that carries roadmap ids, file paths, diff stat, verification states, and commit sha without relying on prose re-parsing. **Original filing below.**
65. **Backlog-scanning team lanes emit opaque stops, not structured selection outcomes****done (verified 2026-04-12):** completed lane persistence in `rust/crates/tools/src/lib.rs` now recognizes backlog-scan selection summaries and records structured `selectionOutcome` metadata on `lane.finished`, including `chosenItems`, `skippedItems`, `action`, and optional `rationale`, while preserving existing non-selection and review-lane behavior. Regression coverage locks the structured backlog-scan payload alongside the earlier quality-floor and review-verdict paths. **Original filing below.**
66. **Completion-aware reminder shutdown missing**dogfooded 2026-04-12. Ultraclaw batch completed and was reported as done, but 10-minute cron reminder (`roadmap-nudge-10min`) kept firing into channel as if work still pending. Reminder/cron state not coupled to terminal task state. **Fix shape:** (a) cron jobs should check task completion state before firing; (b) or, provide explicit `cron.remove` on task completion; (c) or, reminders should include "work complete" detection and auto-expire. Blocker: none. Source: gaebal-gajae dogfood analysis 2026-04-12.
66. **Completion-aware reminder shutdown missing****done (verified 2026-04-12):** completed lane persistence in `rust/crates/tools/src/lib.rs` now disables matching enabled cron reminders when the associated lane finishes successfully, and records the affected cron ids in `lane.finished.data.disabledCronIds`. Regression coverage locks the path where a ROADMAP-linked reminder is disabled on successful completion while leaving incomplete work untouched. **Original filing below.**
67. **Scoped review lanes do not emit structured verdicts****done (verified 2026-04-12):** completed lane persistence in `rust/crates/tools/src/lib.rs` now recognizes review-style `APPROVE`/`REJECT`/`BLOCKED` results and records structured `reviewVerdict`, `reviewTarget`, and `reviewRationale` metadata on the `lane.finished` event while preserving existing non-review lane behavior. Regression coverage locks both the normal completion path and a scoped review-lane completion payload. **Original filing below.**
68. **Internal reinjection/resume paths leak opaque control prose**dogfooded 2026-04-12. OMX lanes stopping with `Continue from current mode state. [OMX_TMUX_INJECT]` expose internal implementation details instead of operator-meaningful state. The event tells us *that* tmux reinjection happened, but not *why* (retry after failure? resume after idle? manual recovery?), *what state was preserved*, or *what the lane was trying to do*. **Fix shape:** recovery/reinject events should emit structured cause like: `resume_after_stop`, `retry_after_tool_failure`, `tmux_reinject_after_idle`, `manual_recovery` plus preserved state / target lane info. Never leak bare internal markers like `[OMX_TMUX_INJECT]` as the primary summary. Blocker: none. Source: gaebal-gajae dogfood analysis 2026-04-12.
68. **Internal reinjection/resume paths leak opaque control prose****done (verified 2026-04-12):** completed lane persistence in `rust/crates/tools/src/lib.rs` now recognizes `[OMX_TMUX_INJECT]`-style recovery control prose and records structured `recoveryOutcome` metadata on `lane.finished`, including `cause`, optional `targetLane`, and optional `preservedState`. Recovery-style summaries now normalize to a human-meaningful fallback instead of surfacing the raw internal marker as the primary lane result. Regression coverage locks both the tmux-idle reinjection path and the `Continue from current mode state` resume path. Source: gaebal-gajae / Jobdori dogfood 2026-04-12.
69. **Lane stop summaries have no minimum quality floor****done (verified 2026-04-12):** completed lane persistence in `rust/crates/tools/src/lib.rs` now normalizes vague/control-only stop summaries into a contextual fallback that includes the lane target and status, while preserving structured metadata about whether the quality floor fired (`qualityFloorApplied`, `rawSummary`, `reasons`, `wordCount`). Regression coverage locks both the pass-through path for good summaries and the fallback path for mushy summaries like `commit push everyting, keep sweeping $ralph`. **Original filing below.**
@@ -518,3 +518,7 @@ Model name prefix now wins unconditionally over env-var presence. Regression tes
72. **`latest` managed-session selection depends on filesystem mtime before semantic session recency** — **done (verified 2026-04-12):** managed-session summaries now carry `updated_at_ms`, `SessionStore::list_sessions()` sorts by semantic recency before filesystem mtime, and regression coverage locks the case where `latest` must prefer the newer session payload even when file mtimes point the other way. The CLI session-summary wrapper now stays in sync with the runtime field so `latest` resolution uses the same ordering signal everywhere. **Original filing below.**
73. **Session timestamps are not monotonic enough for latest-session ordering under tight loops****done (verified 2026-04-12):** runtime session timestamps now use a process-local monotonic millisecond source, so back-to-back saves still produce increasing `updated_at_ms` even when the wall clock does not advance. The temporary sleep hack was removed from the resume-latest regression, and fresh workspace verification stayed green with the semantic-recency ordering path from #72. **Original filing below.**
74. **Poisoned test locks cascade into unrelated Rust regressions****done (verified 2026-04-12):** test-only env/cwd lock acquisition in `rust/crates/tools/src/lib.rs`, `rust/crates/plugins/src/lib.rs`, `rust/crates/commands/src/lib.rs`, and `rust/crates/rusty-claude-cli/src/main.rs` now recovers poisoned mutexes via `PoisonError::into_inner`, and new regressions lock that behavior so one panic no longer causes later tests to fail just by touching the shared env/cwd locks. Source: Jobdori dogfood 2026-04-12.
75. **`claw init` leaves `.clawhip/` runtime artifacts unignored** — **done (verified 2026-04-12):** `rust/crates/rusty-claude-cli/src/init.rs` now treats `.clawhip/` as a first-class local artifact alongside `.claw/` paths, and regression coverage locks both the create and idempotent update paths so `claw init` adds the ignore entry exactly once. The repo `.gitignore` now also ignores `.clawhip/` for immediate dogfood relief, preventing repeated OMX team merge conflicts on `.clawhip/state/prompt-submit.json`. Source: Jobdori dogfood 2026-04-12.

View File

@@ -4152,6 +4152,24 @@ mod tests {
LOCK.get_or_init(|| Mutex::new(()))
}
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn env_guard_recovers_after_poisoning() {
let poisoned = std::thread::spawn(|| {
let _guard = env_guard();
panic!("poison env lock");
})
.join();
assert!(poisoned.is_err(), "poisoning thread should panic");
let _guard = env_guard();
}
fn restore_env_var(key: &str, original: Option<OsString>) {
match original {
Some(value) => std::env::set_var(key, value),
@@ -5214,7 +5232,7 @@ mod tests {
#[test]
fn discovers_omc_skills_from_project_and_user_compatibility_roots() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let workspace = temp_dir("skills-omc-workspace");
let user_home = temp_dir("skills-omc-home");
let claude_config_dir = temp_dir("skills-omc-claude-config");

View File

@@ -2294,6 +2294,12 @@ fn env_lock() -> &'static std::sync::Mutex<()> {
mod tests {
use super::*;
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -2302,6 +2308,18 @@ mod tests {
std::env::temp_dir().join(format!("plugins-{label}-{nanos}"))
}
#[test]
fn env_guard_recovers_after_poisoning() {
let poisoned = std::thread::spawn(|| {
let _guard = env_guard();
panic!("poison env lock");
})
.join();
assert!(poisoned.is_err(), "poisoning thread should panic");
let _guard = env_guard();
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("parent dir");
@@ -2485,7 +2503,7 @@ mod tests {
#[test]
fn load_plugin_from_directory_validates_required_fields() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let root = temp_dir("manifest-required");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
@@ -2500,7 +2518,7 @@ mod tests {
#[test]
fn load_plugin_from_directory_reads_root_manifest_and_validates_entries() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let root = temp_dir("manifest-root");
write_loader_plugin(&root);
@@ -2530,7 +2548,7 @@ mod tests {
#[test]
fn load_plugin_from_directory_supports_packaged_manifest_path() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let root = temp_dir("manifest-packaged");
write_external_plugin(&root, "packaged-demo", "1.0.0");
@@ -2544,7 +2562,7 @@ mod tests {
#[test]
fn load_plugin_from_directory_defaults_optional_fields() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let root = temp_dir("manifest-defaults");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
@@ -2566,7 +2584,7 @@ mod tests {
#[test]
fn load_plugin_from_directory_rejects_duplicate_permissions_and_commands() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let root = temp_dir("manifest-duplicates");
write_file(
root.join("commands").join("sync.sh").as_path(),
@@ -2862,7 +2880,7 @@ mod tests {
#[test]
fn discovers_builtin_and_bundled_plugins() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let manager = PluginManager::new(PluginManagerConfig::new(temp_dir("discover")));
let plugins = manager.list_plugins().expect("plugins should list");
assert!(plugins
@@ -2875,7 +2893,7 @@ mod tests {
#[test]
fn installs_enables_updates_and_uninstalls_external_plugins() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("home");
let source_root = temp_dir("source");
write_external_plugin(&source_root, "demo", "1.0.0");
@@ -2924,7 +2942,7 @@ mod tests {
#[test]
fn auto_installs_bundled_plugins_into_the_registry() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("bundled-home");
let bundled_root = temp_dir("bundled-root");
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
@@ -2956,7 +2974,7 @@ mod tests {
#[test]
fn default_bundled_root_loads_repo_bundles_as_installed_plugins() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("default-bundled-home");
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
@@ -2975,7 +2993,7 @@ mod tests {
#[test]
fn bundled_sync_prunes_removed_bundled_registry_entries() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("bundled-prune-home");
let bundled_root = temp_dir("bundled-prune-root");
let stale_install_path = config_home
@@ -3039,7 +3057,7 @@ mod tests {
#[test]
fn installed_plugin_discovery_keeps_registry_entries_outside_install_root() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("registry-fallback-home");
let bundled_root = temp_dir("registry-fallback-bundled");
let install_root = config_home.join("plugins").join("installed");
@@ -3094,7 +3112,7 @@ mod tests {
#[test]
fn installed_plugin_discovery_prunes_stale_registry_entries() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("registry-prune-home");
let bundled_root = temp_dir("registry-prune-bundled");
let install_root = config_home.join("plugins").join("installed");
@@ -3140,7 +3158,7 @@ mod tests {
#[test]
fn persists_bundled_plugin_enable_state_across_reloads() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("bundled-state-home");
let bundled_root = temp_dir("bundled-state-root");
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
@@ -3174,7 +3192,7 @@ mod tests {
#[test]
fn persists_bundled_plugin_disable_state_across_reloads() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("bundled-disabled-home");
let bundled_root = temp_dir("bundled-disabled-root");
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", true);
@@ -3208,7 +3226,7 @@ mod tests {
#[test]
fn validates_plugin_source_before_install() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("validate-home");
let source_root = temp_dir("validate-source");
write_external_plugin(&source_root, "validator", "1.0.0");
@@ -3223,7 +3241,7 @@ mod tests {
#[test]
fn plugin_registry_tracks_enabled_state_and_lookup() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("registry-home");
let source_root = temp_dir("registry-source");
write_external_plugin(&source_root, "registry-demo", "1.0.0");
@@ -3251,7 +3269,7 @@ mod tests {
#[test]
fn plugin_registry_report_collects_load_failures_without_dropping_valid_plugins() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
// given
let config_home = temp_dir("report-home");
let external_root = temp_dir("report-external");
@@ -3296,7 +3314,7 @@ mod tests {
#[test]
fn installed_plugin_registry_report_collects_load_failures_from_install_root() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
// given
let config_home = temp_dir("installed-report-home");
let bundled_root = temp_dir("installed-report-bundled");
@@ -3327,7 +3345,7 @@ mod tests {
#[test]
fn rejects_plugin_sources_with_missing_hook_paths() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
// given
let config_home = temp_dir("broken-home");
let source_root = temp_dir("broken-source");
@@ -3355,7 +3373,7 @@ mod tests {
#[test]
fn rejects_plugin_sources_with_missing_failure_hook_paths() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
// given
let config_home = temp_dir("broken-failure-home");
let source_root = temp_dir("broken-failure-source");
@@ -3383,7 +3401,7 @@ mod tests {
#[test]
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("lifecycle-home");
let source_root = temp_dir("lifecycle-source");
let _ = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
@@ -3407,7 +3425,7 @@ mod tests {
#[test]
fn aggregates_and_executes_plugin_tools() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("tool-home");
let source_root = temp_dir("tool-source");
write_tool_plugin(&source_root, "tool-demo", "1.0.0");
@@ -3436,7 +3454,7 @@ mod tests {
#[test]
fn list_installed_plugins_scans_install_root_without_registry_entries() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("installed-scan-home");
let bundled_root = temp_dir("installed-scan-bundled");
let install_root = config_home.join("plugins").join("installed");
@@ -3468,7 +3486,7 @@ mod tests {
#[test]
fn list_installed_plugins_scans_packaged_manifests_in_install_root() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
let config_home = temp_dir("installed-packaged-scan-home");
let bundled_root = temp_dir("installed-packaged-scan-bundled");
let install_root = config_home.join("plugins").join("installed");
@@ -3502,7 +3520,7 @@ mod tests {
/// host `~/.claw/plugins/` from bleeding into test runs.
#[test]
fn claw_config_home_isolation_prevents_host_plugin_leakage() {
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
// Create a temp directory to act as our isolated CLAW_CONFIG_HOME
let config_home = temp_dir("isolated-home");
@@ -3556,7 +3574,7 @@ mod tests {
use std::sync::Arc;
use std::thread;
let _guard = env_lock().lock().expect("env lock");
let _guard = env_guard();
// Shared base directory for all threads
let base_dir = temp_dir("parallel-base");

View File

@@ -9,7 +9,7 @@ const STARTER_CLAW_JSON: &str = concat!(
"}\n",
);
const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
const GITIGNORE_ENTRIES: [&str; 2] = [".claw/settings.local.json", ".claw/sessions/"];
const GITIGNORE_ENTRIES: [&str; 3] = [".claw/settings.local.json", ".claw/sessions/", ".clawhip/"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InitStatus {
@@ -375,6 +375,7 @@ mod tests {
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
assert!(gitignore.contains(".claw/settings.local.json"));
assert!(gitignore.contains(".claw/sessions/"));
assert!(gitignore.contains(".clawhip/"));
let claude_md = fs::read_to_string(root.join("CLAUDE.md")).expect("read claude md");
assert!(claude_md.contains("Languages: Rust."));
assert!(claude_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
@@ -407,6 +408,7 @@ mod tests {
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
assert_eq!(gitignore.matches(".claw/settings.local.json").count(), 1);
assert_eq!(gitignore.matches(".claw/sessions/").count(), 1);
assert_eq!(gitignore.matches(".clawhip/").count(), 1);
fs::remove_dir_all(root).expect("cleanup temp dir");
}

View File

@@ -10534,7 +10534,7 @@ UU conflicted.rs",
#[test]
fn managed_sessions_default_to_jsonl_and_resolve_legacy_json() {
let _guard = cwd_lock().lock().expect("cwd lock");
let _guard = cwd_guard();
let workspace = temp_workspace("session-resolution");
std::fs::create_dir_all(&workspace).expect("workspace should create");
let previous = std::env::current_dir().expect("cwd");
@@ -10573,7 +10573,7 @@ UU conflicted.rs",
#[test]
fn latest_session_alias_resolves_most_recent_managed_session() {
let _guard = cwd_lock().lock().expect("cwd lock");
let _guard = cwd_guard();
let workspace = temp_workspace("latest-session-alias");
std::fs::create_dir_all(&workspace).expect("workspace should create");
let previous = std::env::current_dir().expect("cwd");
@@ -10606,7 +10606,7 @@ UU conflicted.rs",
#[test]
fn load_session_reference_rejects_workspace_mismatch() {
let _guard = cwd_lock().lock().expect("cwd lock");
let _guard = cwd_guard();
let workspace_a = temp_workspace("session-mismatch-a");
let workspace_b = temp_workspace("session-mismatch-b");
std::fs::create_dir_all(&workspace_a).expect("workspace a should create");
@@ -10680,6 +10680,24 @@ UU conflicted.rs",
LOCK.get_or_init(|| Mutex::new(()))
}
fn cwd_guard() -> MutexGuard<'static, ()> {
cwd_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn cwd_guard_recovers_after_poisoning() {
let poisoned = std::thread::spawn(|| {
let _guard = cwd_guard();
panic!("poison cwd lock");
})
.join();
assert!(poisoned.is_err(), "poisoning thread should panic");
let _guard = cwd_guard();
}
fn temp_workspace(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)

View File

@@ -3764,7 +3764,8 @@ fn persist_agent_terminal_state(
.push(LaneEvent::failed(iso8601_now(), &blocker));
} else {
next_manifest.current_blocker = None;
let finished_summary = build_lane_finished_summary(&next_manifest, result);
let mut finished_summary = build_lane_finished_summary(&next_manifest, result);
finished_summary.data.disabled_cron_ids = disable_matching_crons(&next_manifest, result);
next_manifest.lane_events.push(
LaneEvent::finished(iso8601_now(), finished_summary.detail).with_data(
serde_json::to_value(&finished_summary.data)
@@ -3844,6 +3845,12 @@ struct LaneFinishedSummaryData {
review_rationale: Option<String>,
#[serde(rename = "selectionOutcome", skip_serializing_if = "Option::is_none")]
selection_outcome: Option<SelectionOutcome>,
#[serde(rename = "recoveryOutcome", skip_serializing_if = "Option::is_none")]
recovery_outcome: Option<RecoveryOutcome>,
#[serde(rename = "artifactProvenance", skip_serializing_if = "Option::is_none")]
artifact_provenance: Option<ArtifactProvenance>,
#[serde(rename = "disabledCronIds", skip_serializing_if = "Vec::is_empty")]
disabled_cron_ids: Vec<String>,
}
#[derive(Debug, Clone)]
@@ -3858,6 +3865,7 @@ struct LaneSummaryAssessment {
reasons: Vec<String>,
word_count: usize,
review_outcome: Option<ReviewLaneOutcome>,
recovery_outcome: Option<RecoveryOutcome>,
}
#[derive(Debug, Clone)]
@@ -3877,6 +3885,31 @@ struct SelectionOutcome {
rationale: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct RecoveryOutcome {
cause: String,
#[serde(rename = "targetLane", skip_serializing_if = "Option::is_none")]
target_lane: Option<String>,
#[serde(rename = "preservedState", skip_serializing_if = "Option::is_none")]
preserved_state: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct ArtifactProvenance {
#[serde(rename = "sourceLanes", skip_serializing_if = "Vec::is_empty")]
source_lanes: Vec<String>,
#[serde(rename = "roadmapIds", skip_serializing_if = "Vec::is_empty")]
roadmap_ids: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
files: Vec<String>,
#[serde(rename = "diffStat", skip_serializing_if = "Option::is_none")]
diff_stat: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
verification: Vec<String>,
#[serde(rename = "commitSha", skip_serializing_if = "Option::is_none")]
commit_sha: Option<String>,
}
fn build_lane_finished_summary(
manifest: &AgentOutput,
result: Option<&str>,
@@ -3885,15 +3918,21 @@ fn build_lane_finished_summary(
let assessment = assess_lane_summary_quality(raw_summary.unwrap_or_default());
let detail = match raw_summary {
Some(summary) if !assessment.apply_quality_floor => Some(compress_summary_text(summary)),
Some(summary) => Some(compose_lane_summary_fallback(manifest, Some(summary))),
None => Some(compose_lane_summary_fallback(manifest, None)),
Some(summary) => Some(compose_lane_summary_fallback(
manifest,
Some(summary),
assessment.recovery_outcome.as_ref(),
)),
None => Some(compose_lane_summary_fallback(manifest, None, None)),
};
let review_outcome = assessment.review_outcome.clone();
let recovery_outcome = assessment.recovery_outcome.clone();
let review_target = review_outcome
.as_ref()
.map(|_| manifest.description.trim())
.filter(|value| !value.is_empty())
.map(str::to_string);
let artifact_provenance = extract_artifact_provenance(manifest, raw_summary);
LaneFinishedSummary {
detail,
@@ -3908,6 +3947,9 @@ fn build_lane_finished_summary(
review_target,
review_rationale: review_outcome.and_then(|outcome| outcome.rationale),
selection_outcome: extract_selection_outcome(raw_summary.unwrap_or_default()),
recovery_outcome,
artifact_provenance,
disabled_cron_ids: Vec::new(),
},
}
}
@@ -3926,6 +3968,10 @@ fn assess_lane_summary_quality(summary: &str) -> LaneSummaryAssessment {
}
let review_outcome = extract_review_outcome(summary);
let recovery_outcome = extract_recovery_outcome(summary);
if recovery_outcome.is_some() {
reasons.push(String::from("recovery_control_prose"));
}
let control_only = !words.is_empty()
&& words
@@ -3952,10 +3998,15 @@ fn assess_lane_summary_quality(summary: &str) -> LaneSummaryAssessment {
reasons,
word_count,
review_outcome,
recovery_outcome,
}
}
fn compose_lane_summary_fallback(manifest: &AgentOutput, raw_summary: Option<&str>) -> String {
fn compose_lane_summary_fallback(
manifest: &AgentOutput,
raw_summary: Option<&str>,
recovery_outcome: Option<&RecoveryOutcome>,
) -> String {
let target = manifest.description.trim();
let base = format!(
"Completed lane `{}` for target: {}. Status: completed.",
@@ -3966,6 +4017,25 @@ fn compose_lane_summary_fallback(manifest: &AgentOutput, raw_summary: Option<&st
target
}
);
if let Some(outcome) = recovery_outcome {
let mut detail = format!(
"{base} Recovery handoff observed via tmux reinjection (cause: `{}`).",
outcome.cause
);
if let Some(target_lane) = &outcome.target_lane {
let _ = std::fmt::Write::write_fmt(
&mut detail,
format_args!(" Target lane: `{target_lane}`."),
);
}
if let Some(preserved_state) = &outcome.preserved_state {
let _ = std::fmt::Write::write_fmt(
&mut detail,
format_args!(" Preserved state: {preserved_state}."),
);
}
return detail;
}
match raw_summary {
Some(summary) => format!(
"{base} Original stop summary was too vague to keep as the lane result: \"{}\".",
@@ -4062,6 +4132,59 @@ fn extract_selection_outcome(summary: &str) -> Option<SelectionOutcome> {
})
}
fn extract_recovery_outcome(summary: &str) -> Option<RecoveryOutcome> {
let trimmed = summary.trim();
if trimmed.is_empty() {
return None;
}
let lowered = trimmed.to_ascii_lowercase();
let has_tmux_inject_marker = lowered.contains("omx_tmux_inject");
let has_recovery_phrase = lowered.contains("continue from current mode state")
|| (lowered.starts_with("team ") && lowered.contains(" next:"));
if !has_tmux_inject_marker && !has_recovery_phrase {
return None;
}
let cause = if lowered.contains("current mode state") {
"resume_after_stop"
} else if lowered.contains("tool failure") {
"retry_after_tool_failure"
} else if lowered.contains("worker panes stalled")
|| lowered.contains("no progress")
|| lowered.contains("leader stale")
|| lowered.contains("all workers idle")
|| lowered.contains("all 1 worker idle")
|| lowered.contains("pane(s) active")
{
"tmux_reinject_after_idle"
} else {
"manual_recovery"
};
let target_lane = trimmed.lines().map(str::trim).find_map(|line| {
let lower = line.to_ascii_lowercase();
if !lower.starts_with("team ") {
return None;
}
line[5..]
.split_once(':')
.map(|(name, _)| name.trim())
.filter(|name| !name.is_empty())
.map(str::to_string)
});
let preserved_state = lowered
.contains("current mode state")
.then(|| String::from("current mode state"));
Some(RecoveryOutcome {
cause: cause.to_string(),
target_lane,
preserved_state,
})
}
fn extract_roadmap_items(line: &str) -> Vec<String> {
let mut items = Vec::new();
let mut chars = line.chars().peekable();
@@ -4084,6 +4207,142 @@ fn extract_roadmap_items(line: &str) -> Vec<String> {
items
}
fn extract_artifact_provenance(
manifest: &AgentOutput,
raw_summary: Option<&str>,
) -> Option<ArtifactProvenance> {
let summary = raw_summary?;
let mut roadmap_ids = extract_roadmap_items(summary);
roadmap_ids.extend(extract_roadmap_items(&manifest.description));
roadmap_ids.sort();
roadmap_ids.dedup();
let mut files = extract_file_paths(summary);
files.sort();
files.dedup();
let mut verification = Vec::new();
let lowered = summary.to_ascii_lowercase();
for (needle, label) in [
("tested", "tested"),
("committed", "committed"),
("pushed", "pushed"),
("merged", "merged"),
] {
if lowered.contains(needle) {
verification.push(label.to_string());
}
}
let commit_sha = extract_commit_sha(summary);
let diff_stat = extract_diff_stat(summary);
let source_lanes = vec![manifest.name.clone()];
if roadmap_ids.is_empty()
&& files.is_empty()
&& verification.is_empty()
&& commit_sha.is_none()
&& diff_stat.is_none()
{
return None;
}
Some(ArtifactProvenance {
source_lanes,
roadmap_ids,
files,
diff_stat,
verification,
commit_sha,
})
}
fn extract_file_paths(summary: &str) -> Vec<String> {
summary
.split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | '(' | ')' | '[' | ']'))
.map(|token| {
token
.trim_matches('`')
.trim_matches('"')
.trim_matches('\'')
.trim_end_matches('.')
})
.filter(|token| {
token.contains('.')
&& !token.starts_with("http")
&& !token
.chars()
.all(|ch| ch.is_ascii_digit() || ch == '.' || ch == '+' || ch == '-')
})
.map(str::to_string)
.collect()
}
fn extract_diff_stat(summary: &str) -> Option<String> {
summary
.split('\n')
.map(str::trim)
.find_map(|line| {
line.find("Diff stat:")
.map(|index| normalize_diff_stat(&line[(index + "Diff stat:".len())..]))
.or_else(|| {
line.find("Diff:")
.map(|index| normalize_diff_stat(&line[(index + "Diff:".len())..]))
})
})
.filter(|value| !value.is_empty())
}
fn normalize_diff_stat(value: &str) -> String {
let trimmed = value.trim();
for marker in [" Tested", " Committed", " committed", " pushed", " merged"] {
if let Some((prefix, _)) = trimmed.split_once(marker) {
return prefix.trim().to_string();
}
}
trimmed.to_string()
}
fn disable_matching_crons(manifest: &AgentOutput, result: Option<&str>) -> Vec<String> {
let tokens = cron_match_tokens(manifest, result);
if tokens.is_empty() {
return Vec::new();
}
let mut disabled = Vec::new();
for entry in global_cron_registry().list(true) {
let haystack = format!(
"{} {}",
entry.prompt,
entry.description.as_deref().unwrap_or_default()
)
.to_ascii_lowercase();
if tokens.iter().any(|token| haystack.contains(token))
&& global_cron_registry().disable(&entry.cron_id).is_ok()
{
disabled.push(entry.cron_id);
}
}
disabled.sort();
disabled
}
fn cron_match_tokens(manifest: &AgentOutput, result: Option<&str>) -> Vec<String> {
let mut tokens = extract_roadmap_items(manifest.description.as_str())
.into_iter()
.chain(extract_roadmap_items(result.unwrap_or_default()))
.map(|item| item.to_ascii_lowercase())
.collect::<Vec<_>>();
if tokens.is_empty() && !manifest.name.trim().is_empty() {
tokens.push(manifest.name.trim().to_ascii_lowercase());
}
tokens.sort();
tokens.dedup();
tokens
}
fn derive_agent_state(
status: &str,
result: Option<&str>,
@@ -5868,11 +6127,11 @@ mod tests {
use super::{
agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure,
derive_agent_state, execute_agent_with_spawn, execute_tool, final_assistant_text,
maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin,
persist_agent_terminal_state, push_output_block, run_task_packet, AgentInput, AgentJob,
GlobalToolRegistry, LaneEventName, LaneFailureClass, ProviderRuntimeClient,
SubagentToolExecutor,
derive_agent_state, execute_agent_with_spawn, execute_tool, extract_recovery_outcome,
final_assistant_text, global_cron_registry, maybe_commit_provenance, mvp_tool_specs,
permission_mode_from_plugin, persist_agent_terminal_state, push_output_block,
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass,
ProviderRuntimeClient, SubagentToolExecutor,
};
use api::OutputContentBlock;
use runtime::ProviderFallbackConfig;
@@ -5887,6 +6146,24 @@ mod tests {
LOCK.get_or_init(|| Mutex::new(()))
}
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn env_guard_recovers_after_poisoning() {
let poisoned = std::thread::spawn(|| {
let _guard = env_guard();
panic!("poison env lock");
})
.join();
assert!(poisoned.is_err(), "poisoning thread should panic");
let _guard = env_guard();
}
fn temp_path(name: &str) -> PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -7007,7 +7284,7 @@ mod tests {
#[test]
fn skill_loads_local_skill_prompt() {
let _guard = env_lock().lock().expect("env lock should acquire");
let _guard = env_guard();
let home = temp_path("skills-home");
let skill_dir = home.join(".agents").join("skills").join("help");
fs::create_dir_all(&skill_dir).expect("skill dir should exist");
@@ -7064,7 +7341,7 @@ mod tests {
#[test]
fn skill_resolves_project_local_skills_and_legacy_commands() {
let _guard = env_lock().lock().expect("env lock should acquire");
let _guard = env_guard();
let root = temp_path("project-skills");
let skill_dir = root.join(".claw").join("skills").join("plan");
let command_dir = root.join(".claw").join("commands");
@@ -7108,7 +7385,7 @@ mod tests {
#[test]
fn skill_loads_project_local_claude_skill_prompt() {
let _guard = env_lock().lock().expect("env lock should acquire");
let _guard = env_guard();
let root = temp_path("project-skills");
let home = root.join("home");
let workspace = root.join("workspace");
@@ -7159,7 +7436,7 @@ mod tests {
#[test]
fn skill_loads_project_local_omc_and_agents_skill_prompts() {
let _guard = env_lock().lock().expect("env lock should acquire");
let _guard = env_guard();
let root = temp_path("project-omc-skills");
let home = root.join("home");
let workspace = root.join("workspace");
@@ -7229,7 +7506,7 @@ mod tests {
#[test]
fn skill_loads_learned_skill_from_claude_config_dir() {
let _guard = env_lock().lock().expect("env lock should acquire");
let _guard = env_guard();
let root = temp_path("claude-config-learned-skill");
let home = root.join("home");
let claude_config_dir = root.join("claude-config");
@@ -7284,7 +7561,7 @@ mod tests {
#[test]
fn skill_loads_direct_skill_and_legacy_command_from_claude_config_dir() {
let _guard = env_lock().lock().expect("env lock should acquire");
let _guard = env_guard();
let root = temp_path("claude-config-direct-skill");
let home = root.join("home");
let claude_config_dir = root.join("claude-config");
@@ -7356,7 +7633,7 @@ mod tests {
#[test]
fn skill_loads_project_local_legacy_command_markdown() {
let _guard = env_lock().lock().expect("env lock should acquire");
let _guard = env_guard();
let root = temp_path("project-legacy-command");
let home = root.join("home");
let workspace = root.join("workspace");
@@ -7678,6 +7955,54 @@ mod tests {
"control_only"
);
let recovery = execute_agent_with_spawn(
AgentInput {
description: "Recover the stalled audit lane".to_string(),
prompt: "Normalize OMX reinjection control prose".to_string(),
subagent_type: Some("Explore".to_string()),
name: Some("recovery-lane".to_string()),
model: None,
},
|job| {
persist_agent_terminal_state(
&job.manifest,
"completed",
Some(
"Team read-only-audit-only-for-roadm: worker panes stalled, no progress 2m30s. Next: omx team status read-only-audit-only-for-roadm; read worker messages; unblock/reassign or shutdown. [OMX_TMUX_INJECT]",
),
None,
)
},
)
.expect("recovery agent should succeed");
let recovery_manifest = std::fs::read_to_string(&recovery.manifest_file)
.expect("recovery manifest should exist");
let recovery_manifest_json: serde_json::Value =
serde_json::from_str(&recovery_manifest).expect("recovery manifest json");
let recovery_detail = recovery_manifest_json["laneEvents"][1]["detail"]
.as_str()
.expect("recovery detail");
assert!(recovery_detail.contains("Recovery handoff observed via tmux reinjection"));
assert!(recovery_detail.contains("read-only-audit-only-for-roadm"));
assert!(!recovery_detail.contains("OMX_TMUX_INJECT"));
assert_eq!(
recovery_manifest_json["laneEvents"][1]["data"]["recoveryOutcome"]["cause"],
"tmux_reinject_after_idle"
);
assert_eq!(
recovery_manifest_json["laneEvents"][1]["data"]["recoveryOutcome"]["targetLane"],
"read-only-audit-only-for-roadm"
);
assert_eq!(
recovery_manifest_json["laneEvents"][1]["data"]["qualityFloorApplied"],
true
);
assert_eq!(
recovery_manifest_json["laneEvents"][1]["data"]["reasons"][0],
"recovery_control_prose"
);
let review = execute_agent_with_spawn(
AgentInput {
description: "Review commit 1234abcd for ROADMAP #67".to_string(),
@@ -7764,6 +8089,117 @@ mod tests {
"#65 is the next repo-local lane-finished metadata task."
);
let artifact = execute_agent_with_spawn(
AgentInput {
description: "Land ROADMAP #64 provenance hardening".to_string(),
prompt: "Ship structured artifact provenance".to_string(),
subagent_type: Some("Explore".to_string()),
name: Some("artifact-lane".to_string()),
model: None,
},
|job| {
persist_agent_terminal_state(
&job.manifest,
"completed",
Some(
"Completed ROADMAP #64. Files: rust/crates/tools/src/lib.rs ROADMAP.md. Diff stat: 2 files, +12/-1. Tested, committed, pushed as commit deadbee.",
),
None,
)
},
)
.expect("artifact agent should succeed");
let artifact_manifest = std::fs::read_to_string(&artifact.manifest_file)
.expect("artifact manifest should exist");
let artifact_manifest_json: serde_json::Value =
serde_json::from_str(&artifact_manifest).expect("artifact manifest json");
assert_eq!(
artifact_manifest_json["laneEvents"][1]["data"]["artifactProvenance"]["sourceLanes"][0],
"artifact-lane"
);
assert_eq!(
artifact_manifest_json["laneEvents"][1]["data"]["artifactProvenance"]["roadmapIds"][0],
"ROADMAP #64"
);
assert_eq!(
artifact_manifest_json["laneEvents"][1]["data"]["artifactProvenance"]["files"][0],
"ROADMAP.md"
);
assert_eq!(
artifact_manifest_json["laneEvents"][1]["data"]["artifactProvenance"]["files"][1],
"rust/crates/tools/src/lib.rs"
);
assert_eq!(
artifact_manifest_json["laneEvents"][1]["data"]["artifactProvenance"]["diffStat"],
"2 files, +12/-1."
);
assert_eq!(
artifact_manifest_json["laneEvents"][1]["data"]["artifactProvenance"]["verification"]
[0],
"tested"
);
assert_eq!(
artifact_manifest_json["laneEvents"][1]["data"]["artifactProvenance"]["verification"]
[1],
"committed"
);
assert_eq!(
artifact_manifest_json["laneEvents"][1]["data"]["artifactProvenance"]["verification"]
[2],
"pushed"
);
assert_eq!(
artifact_manifest_json["laneEvents"][1]["data"]["artifactProvenance"]["commitSha"],
"deadbee"
);
let cron = global_cron_registry().create(
"*/10 * * * *",
"roadmap-nudge-10min for ROADMAP #66",
Some("ROADMAP #66 reminder"),
);
let reminder = execute_agent_with_spawn(
AgentInput {
description: "Close ROADMAP #66 reminder shutdown".to_string(),
prompt: "Finish the cron shutdown fix".to_string(),
subagent_type: Some("Explore".to_string()),
name: Some("cron-closeout".to_string()),
model: None,
},
|job| {
persist_agent_terminal_state(
&job.manifest,
"completed",
Some("Completed ROADMAP #66 after verification."),
None,
)
},
)
.expect("reminder agent should succeed");
let reminder_manifest = std::fs::read_to_string(&reminder.manifest_file)
.expect("reminder manifest should exist");
let reminder_manifest_json: serde_json::Value =
serde_json::from_str(&reminder_manifest).expect("reminder manifest json");
assert_eq!(
reminder_manifest_json["laneEvents"][1]["data"]["disabledCronIds"][0],
cron.cron_id
);
let disabled_entry = global_cron_registry()
.get(&cron.cron_id)
.expect("cron should still exist");
assert!(!disabled_entry.enabled);
let resume_outcome =
extract_recovery_outcome("Continue from current mode state. [OMX_TMUX_INJECT]")
.expect("resume outcome should be detected");
assert_eq!(resume_outcome.cause, "resume_after_stop");
assert_eq!(
resume_outcome.preserved_state.as_deref(),
Some("current mode state")
);
let spawn_error = execute_agent_with_spawn(
AgentInput {
description: "Spawn error task".to_string(),