mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-14 07:24:41 -04:00
Compare commits
5 Commits
2e34949507
...
6a957560bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a957560bd | ||
|
|
42bb6cdba6 | ||
|
|
f91d156f85 | ||
|
|
6b4bb4ac26 | ||
|
|
e75d67dfd3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,4 +8,5 @@ archive/
|
||||
# Claw Code local artifacts
|
||||
.claw/settings.local.json
|
||||
.claw/sessions/
|
||||
.clawhip/
|
||||
status-help.txt
|
||||
|
||||
12
ROADMAP.md
12
ROADMAP.md
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user