diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c77a9024..a5e814d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,12 +140,14 @@ jobs: run: python3 scripts/readiness/qa.py - uses: actions/upload-artifact@v7 if: always() + continue-on-error: true with: name: test-performance path: target/readiness/tests/ retention-days: 30 - uses: actions/upload-artifact@v7 if: always() + continue-on-error: true with: name: local-qa # Diagnostic journals and aggregates remain local to the runner. diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3de101..a79d9c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -### Added -- `/goal` long-horizon persisted goals: create/status/pause/resume/clear, `goal.json` on the session, idle continuation within an 8-turn budget, evidence-based `UpdateGoal`, and a composer chip (`#1F4945`). Lock v2 boards cover chip states (active / paused / done / budget / blocked) and slash palette (`/goal` after `/plan`) at 40×12 and 120×40. Live smoke uses `OPENAI_BASE_URL` / `CORTEX_LLM_BASE_URL`, `OPENAI_API_KEY` / `CORTEX_LLM_API_KEY`, and `CORTEX_LLM_MODEL=cx/gpt-6-astra`; without a key the live test SKIP and unit tests still pass. +## 0.1.11 + +### Changed +- `/goal` production harden: persist is atomic + fsynced; corrupt `goal.json` is quarantined so session resume continues (and the status text says so only when the move succeeded); load never deletes `.goal.json.tmp.*` (a concurrent save's in-flight temp is left alone); `/goal status` is a reserved status token; continuation records a finished turn *then* wrap-up/continues (last remaining turn still runs); `UpdateGoal` complete accepts only `file` / `command` / `test` evidence (globally deduped); status, pause, resume, and resume-on-session print the chip (`Goal · 2/8` / paused / done / budget / blocked). Live smoke still SKIP without a key. - Hidden `cortex mcp-server --verify` stdio JSON-RPC server (`cortex-verify`) so CI and agents can audit TUI chrome, lock scenes, login product copy, and API error paths offline. Remains `hide = true` until Designer sign-off. ### Changed diff --git a/Cargo.lock b/Cargo.lock index da729322..77732791 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -955,7 +955,7 @@ dependencies = [ [[package]] name = "cortex-app-server" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "async-channel", @@ -995,7 +995,7 @@ dependencies = [ [[package]] name = "cortex-apply-patch" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "serde", @@ -1021,7 +1021,7 @@ dependencies = [ [[package]] name = "cortex-cli" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "async-trait", @@ -1079,7 +1079,7 @@ dependencies = [ [[package]] name = "cortex-collab" -version = "0.1.10" +version = "0.1.11" dependencies = [ "serde", "thiserror 2.0.20", @@ -1089,7 +1089,7 @@ dependencies = [ [[package]] name = "cortex-commands" -version = "0.1.10" +version = "0.1.11" dependencies = [ "chrono", "dirs 6.0.0", @@ -1104,7 +1104,7 @@ dependencies = [ [[package]] name = "cortex-common" -version = "0.1.10" +version = "0.1.11" dependencies = [ "clap", "cortex-protocol", @@ -1138,7 +1138,7 @@ dependencies = [ [[package]] name = "cortex-core" -version = "0.1.10" +version = "0.1.11" dependencies = [ "ahash", "anyhow", @@ -1161,7 +1161,7 @@ dependencies = [ [[package]] name = "cortex-engine" -version = "0.1.10" +version = "0.1.11" dependencies = [ "aes-gcm", "anyhow", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "cortex-exec" -version = "0.1.10" +version = "0.1.11" dependencies = [ "cortex-engine", "cortex-protocol", @@ -1246,7 +1246,7 @@ dependencies = [ [[package]] name = "cortex-execpolicy" -version = "0.1.10" +version = "0.1.11" dependencies = [ "serde", "serde_json", @@ -1265,7 +1265,7 @@ dependencies = [ [[package]] name = "cortex-feedback" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "chrono", @@ -1278,7 +1278,7 @@ dependencies = [ [[package]] name = "cortex-file-search" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "ignore", @@ -1320,7 +1320,7 @@ dependencies = [ [[package]] name = "cortex-keyring-store" -version = "0.1.10" +version = "0.1.11" dependencies = [ "keyring", "thiserror 2.0.20", @@ -1344,7 +1344,7 @@ dependencies = [ [[package]] name = "cortex-lmstudio" -version = "0.1.10" +version = "0.1.11" dependencies = [ "reqwest", "serde", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "cortex-login" -version = "0.1.10" +version = "0.1.11" dependencies = [ "aes-gcm", "anyhow", @@ -1406,7 +1406,7 @@ dependencies = [ [[package]] name = "cortex-mcp-client" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "async-trait", @@ -1422,7 +1422,7 @@ dependencies = [ [[package]] name = "cortex-mcp-server" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "async-trait", @@ -1435,7 +1435,7 @@ dependencies = [ [[package]] name = "cortex-mcp-types" -version = "0.1.10" +version = "0.1.11" dependencies = [ "schemars", "serde", @@ -1452,7 +1452,7 @@ dependencies = [ [[package]] name = "cortex-network-proxy" -version = "0.1.10" +version = "0.1.11" dependencies = [ "serde", "serde_json", @@ -1462,7 +1462,7 @@ dependencies = [ [[package]] name = "cortex-otel" -version = "0.1.10" +version = "0.1.11" dependencies = [ "cortex-common", "opentelemetry", @@ -1476,7 +1476,7 @@ dependencies = [ [[package]] name = "cortex-plugins" -version = "0.1.10" +version = "0.1.11" dependencies = [ "async-trait", "chrono", @@ -1502,14 +1502,14 @@ dependencies = [ [[package]] name = "cortex-process-hardening" -version = "0.1.10" +version = "0.1.11" dependencies = [ "libc", ] [[package]] name = "cortex-prompt-harness" -version = "0.1.10" +version = "0.1.11" dependencies = [ "chrono", "indexmap", @@ -1520,7 +1520,7 @@ dependencies = [ [[package]] name = "cortex-protocol" -version = "0.1.10" +version = "0.1.11" dependencies = [ "base64", "chrono", @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "cortex-sandbox" -version = "0.1.10" +version = "0.1.11" dependencies = [ "dirs 6.0.0", "landlock", @@ -1592,7 +1592,7 @@ dependencies = [ [[package]] name = "cortex-shell-snapshot" -version = "0.1.10" +version = "0.1.11" dependencies = [ "chrono", "dirs 6.0.0", @@ -1606,7 +1606,7 @@ dependencies = [ [[package]] name = "cortex-skills" -version = "0.1.10" +version = "0.1.11" dependencies = [ "chrono", "dashmap", @@ -1625,7 +1625,7 @@ dependencies = [ [[package]] name = "cortex-slack" -version = "0.1.10" +version = "0.1.11" dependencies = [ "async-trait", "axum", @@ -1675,7 +1675,7 @@ dependencies = [ [[package]] name = "cortex-tui" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "arboard", @@ -1720,7 +1720,7 @@ dependencies = [ [[package]] name = "cortex-tui-buffer" -version = "0.1.10" +version = "0.1.11" dependencies = [ "cortex-tui-core", "smallvec", @@ -1728,7 +1728,7 @@ dependencies = [ [[package]] name = "cortex-tui-capture" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "chrono", @@ -1748,7 +1748,7 @@ dependencies = [ [[package]] name = "cortex-tui-components" -version = "0.1.10" +version = "0.1.11" dependencies = [ "cortex-core", "crossterm", @@ -1758,7 +1758,7 @@ dependencies = [ [[package]] name = "cortex-tui-core" -version = "0.1.10" +version = "0.1.11" dependencies = [ "bitflags 2.13.1", "smallvec", @@ -1767,7 +1767,7 @@ dependencies = [ [[package]] name = "cortex-tui-framework" -version = "0.1.10" +version = "0.1.11" dependencies = [ "cortex-tui-buffer", "cortex-tui-core", @@ -1781,7 +1781,7 @@ dependencies = [ [[package]] name = "cortex-tui-input" -version = "0.1.10" +version = "0.1.11" dependencies = [ "bitflags 2.13.1", "crossterm", @@ -1790,7 +1790,7 @@ dependencies = [ [[package]] name = "cortex-tui-layout" -version = "0.1.10" +version = "0.1.11" dependencies = [ "cortex-tui-buffer", "slotmap", @@ -1799,7 +1799,7 @@ dependencies = [ [[package]] name = "cortex-tui-syntax" -version = "0.1.10" +version = "0.1.11" dependencies = [ "ahash", "cortex-tui-text", @@ -1812,7 +1812,7 @@ dependencies = [ [[package]] name = "cortex-tui-terminal" -version = "0.1.10" +version = "0.1.11" dependencies = [ "cortex-tui-buffer", "cortex-tui-core", @@ -1822,7 +1822,7 @@ dependencies = [ [[package]] name = "cortex-tui-text" -version = "0.1.10" +version = "0.1.11" dependencies = [ "smallvec", "unicode-segmentation", @@ -1831,7 +1831,7 @@ dependencies = [ [[package]] name = "cortex-tui-widgets" -version = "0.1.10" +version = "0.1.11" dependencies = [ "cortex-tui-buffer", "cortex-tui-core", @@ -1842,7 +1842,7 @@ dependencies = [ [[package]] name = "cortex-update" -version = "0.1.10" +version = "0.1.11" dependencies = [ "chrono", "cortex-common", @@ -1869,11 +1869,11 @@ dependencies = [ [[package]] name = "cortex-utils-cache" -version = "0.1.10" +version = "0.1.11" [[package]] name = "cortex-utils-file-safety" -version = "0.1.10" +version = "0.1.11" dependencies = [ "tempfile", "thiserror 2.0.20", @@ -1881,11 +1881,11 @@ dependencies = [ [[package]] name = "cortex-utils-git" -version = "0.1.10" +version = "0.1.11" [[package]] name = "cortex-utils-image" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "base64", @@ -1893,7 +1893,7 @@ dependencies = [ [[package]] name = "cortex-utils-pty" -version = "0.1.10" +version = "0.1.11" dependencies = [ "anyhow", "portable-pty", @@ -1901,7 +1901,7 @@ dependencies = [ [[package]] name = "cortex-utils-string" -version = "0.1.10" +version = "0.1.11" dependencies = [ "unicode-width", ] diff --git a/Cargo.toml b/Cargo.toml index c9af51a9..25993308 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ members = [ [workspace.package] # CLI version - kept in sync with VERSION_CLI and src/cortex-cli/VERSION # Run `scripts/check-cli-version.sh` to verify consistency -version = "0.1.10" +version = "0.1.11" edition = "2024" rust-version = "1.98" authors = ["Cortex Team "] diff --git a/VERSION_CLI b/VERSION_CLI index 9767cc98..20f49513 100644 --- a/VERSION_CLI +++ b/VERSION_CLI @@ -1 +1 @@ -0.1.10 +0.1.11 diff --git a/docs/guides/goal.md b/docs/guides/goal.md index d7749004..a630f860 100644 --- a/docs/guides/goal.md +++ b/docs/guides/goal.md @@ -16,24 +16,41 @@ Type these in the TUI composer: | Command | Effect | |---------|--------| | `/goal ` | Create or replace the active goal and start a kickoff turn | -| `/goal` | Show objective, state, progress, and budget | +| `/goal` or `/goal status` | Show chip, objective, state, progress, next step, and budget | | `/goal pause` | Stop auto-continuation. The model cannot pause. | | `/goal resume` | Resume a paused (or blocked) goal if budget remains | | `/goal clear` | Delete the persisted goal | A reserved token is only special when it is the entire argument. `/goal pause -the deploy` sets an objective; it does not pause. +the deploy` and `/goal status the rollout` set an objective; they do not pause +or show status. + +Resume of a complete or budget-limited goal is refused. Start a new +`/goal ` or `/goal clear`. ## States `active` · `paused` · `complete` · `budget_limited` · `blocked` +Composer chip copy is text-only (no radios): + +| State | Chip | +|-------|------| +| `active` | `Goal · 2/8` (turns used / budget) | +| `paused` | `Goal · paused` | +| `complete` | `Goal · done` | +| `budget_limited` | `Goal · budget` | +| `blocked` | `Goal · blocked` | + Completion is evidence-based. The model calls `UpdateGoal` with a reason and -at least one of: a file path, a command, or a test. Vibes are not enough. +at least one of: a **file** path, a **command**, or a **test**. Unknown kinds +and vibes are rejected. Resume reloads `goal.json` and paints the chip. A +corrupt file is moved to `goal.json.corrupt` so the session still opens. -Default budget is **8 turns**. Near the limit (one turn left, or 85% of a token -cap), the next continuation asks the agent to wrap up instead of opening new -scope. +Default budget is **8 turns**. After each finished turn the harness records +usage, then continues if budget remains. Near the limit (one turn left, or 85% +of a token cap), the next continuation asks the agent to wrap up instead of +opening new scope. That last remaining turn still runs. ## Where it is stored @@ -43,10 +60,8 @@ scope. ~/.cortex/sessions/{session-id}/goal.json ``` -Resume reloads it. The composer chip (`Goal · 2/8`, or paused / done / budget) -uses designer accent `#1F4945`. - -See [Sessions](sessions.md) and [Data locations](../configuration/data-locations.md). +Writes are atomic and fsynced. Resume reloads the file. See [Sessions](sessions.md) +and [Data locations](../configuration/data-locations.md). ## Live smoke (operators) diff --git a/docs/reference/app-server.openapi.json b/docs/reference/app-server.openapi.json index f2f670b6..adc7e1d9 100644 --- a/docs/reference/app-server.openapi.json +++ b/docs/reference/app-server.openapi.json @@ -270,7 +270,7 @@ "info": { "description": "Supported local session/health contract. Message POST stores a message; it does not generate a model response. Other development endpoints are not part of this stable contract.", "title": "Cortex local session API", - "version": "0.1.10" + "version": "0.1.11" }, "openapi": "3.1.0", "paths": { diff --git a/docs/reference/slash-commands.md b/docs/reference/slash-commands.md index bf27f4d5..d7131e7c 100644 --- a/docs/reference/slash-commands.md +++ b/docs/reference/slash-commands.md @@ -43,7 +43,7 @@ ordinary message. | `/ide` | | Manage IDE integration | | `/install-github-app` | | Install the Cortex GitHub App | | `/bug` | | `/bug [description]` | -| `/goal` | | `/goal [objective\|pause\|resume\|clear]` — persisted long-horizon objective | +| `/goal` | | `/goal [objective\|status\|pause\|resume\|clear]` — persisted long-horizon objective | ## Authentication and billing diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 55ea8c72..d8dc40e8 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -65,7 +65,7 @@ Web search is enabled with `--search`. | Tool | Parameters | Does | |------|-----------|------| | `Plan` | `title`, `description`, `tasks`, `agent_analyses`, and optional `architecture`, `tech_stack`, `use_cases`, `risks`, `success_criteria`, `timeline`, `estimated_changes` | Submit a structured plan for approval | -| `UpdateGoal` | `status` (`active`, `blocked`, `complete`), optional `progress`, `reason`, `evidence` (`kind` + `detail`) | Record evidence-based progress on the user's `/goal`. `complete` requires evidence and a reason. The model cannot pause. | +| `UpdateGoal` | `status` (`active`, `blocked`, `complete`), optional `progress`, `reason`, `evidence` (`kind` = `file`/`command`/`test` + `detail`) | Record evidence-based progress on the user's `/goal`. `complete` requires a reason and usable evidence. The model cannot pause. | | `ExitSpecMode` | `reason` | Leave specification mode and unlock the mutating tools | | `Task` | `mode` (`explore`, `plan`, `worker`), `prompt`, `description`, `context`, `await_result` | Delegate to a subagent | | `ListSubagents` | `include_custom` | List the available subagent types | diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 2ba5a4a1..abdfca74 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@cortexlm/sdk", - "version": "0.1.10", + "version": "0.1.11", "description": "Typed, local Cortex CLI subprocess client", "license": "Apache-2.0", "private": true, diff --git a/src/cortex-cli/VERSION b/src/cortex-cli/VERSION index 9767cc98..20f49513 100755 --- a/src/cortex-cli/VERSION +++ b/src/cortex-cli/VERSION @@ -1 +1 @@ -0.1.10 +0.1.11 diff --git a/src/cortex-engine/src/goal/continuation.rs b/src/cortex-engine/src/goal/continuation.rs index c1ea0a50..c7967819 100644 --- a/src/cortex-engine/src/goal/continuation.rs +++ b/src/cortex-engine/src/goal/continuation.rs @@ -87,4 +87,15 @@ mod tests { assert!(text.contains("create hello.txt")); assert!(text.contains("UpdateGoal")); } + + #[test] + fn wrap_up_prompt_is_used_on_last_remaining_turn() { + let mut goal = Goal::new("ship tests"); + goal.turn_budget = 2; + crate::goal::machine::finish_turn(&mut goal, 0); + assert!(continuation_gate(Some(&goal))); + let text = continuation_prompt(&goal); + assert!(text.contains("Wrap up"), "{text}"); + assert!(text.contains("ship tests"), "{text}"); + } } diff --git a/src/cortex-engine/src/goal/live_smoke.rs b/src/cortex-engine/src/goal/live_smoke.rs index 51711df2..9220ad3c 100644 --- a/src/cortex-engine/src/goal/live_smoke.rs +++ b/src/cortex-engine/src/goal/live_smoke.rs @@ -41,7 +41,7 @@ mod tests { .json(&serde_json::json!({ "model": model, "messages": [ - {"role": "user", "content": "Reply with the single word pong."} + {"role": "user", "content": "Durable goal smoke. Reply with the single word pong."} ], "max_tokens": 16 })) diff --git a/src/cortex-engine/src/goal/machine.rs b/src/cortex-engine/src/goal/machine.rs index c01f32e9..b2410e1c 100644 --- a/src/cortex-engine/src/goal/machine.rs +++ b/src/cortex-engine/src/goal/machine.rs @@ -19,7 +19,13 @@ pub fn apply_command(current: Option, command: GoalCommand) -> Result Ok(Some(goal)), - other => Err(format!("Cannot pause a goal that is {other}.")), + GoalState::Complete => Err( + "Goal is already complete. /goal clear or set a new /goal ." + .to_string(), + ), + GoalState::BudgetLimited => Err( + "Budget exhausted. Start a new /goal or /goal clear.".to_string(), + ), } } GoalCommand::Resume => { @@ -28,6 +34,7 @@ pub fn apply_command(current: Option, command: GoalCommand) -> Result { if goal.turns_remaining() == 0 { goal.state = GoalState::BudgetLimited; + goal.last_reason = Some("Turn or token budget exhausted.".to_string()); goal.touch(); return Ok(Some(goal)); } @@ -36,7 +43,13 @@ pub fn apply_command(current: Option, command: GoalCommand) -> Result Ok(Some(goal)), - other => Err(format!("Cannot resume a goal that is {other}.")), + GoalState::Complete => Err( + "Goal is already complete. /goal clear or set a new /goal ." + .to_string(), + ), + GoalState::BudgetLimited => Err( + "Budget exhausted. Start a new /goal or /goal clear.".to_string(), + ), } } } @@ -63,7 +76,14 @@ pub fn apply_model_update( } } for item in evidence { - if item.is_usable() { + let Some(item) = item.normalized() else { + continue; + }; + if !next + .evidence + .iter() + .any(|existing| existing.kind == item.kind && existing.detail == item.detail) + { next.evidence.push(item); } } @@ -132,6 +152,18 @@ pub fn record_turn(goal: &mut Goal, tokens: u64) { goal.touch(); } +/// Count a finished *active* agent turn. Returns whether idle continuation should start. +/// +/// Call this *after* the turn completes. Recording first and then deciding +/// whether to continue lets the last remaining turn run as wrap-up. +pub fn finish_turn(goal: &mut Goal, tokens: u64) -> bool { + if goal.state != GoalState::Active { + return false; + } + record_turn(goal, tokens); + should_continue(goal) +} + pub fn within_budget(goal: &Goal) -> bool { if goal.turns_used >= goal.turn_budget { return false; @@ -200,6 +232,19 @@ mod tests { assert!(apply_command(None, GoalCommand::Pause).is_err()); } + #[test] + fn resume_complete_and_budget_explain_next_step() { + let mut done = Goal::new("x"); + done.state = GoalState::Complete; + let err = apply_command(Some(done), GoalCommand::Resume).unwrap_err(); + assert!(err.contains("already complete"), "{err}"); + + let mut limited = Goal::new("x"); + limited.state = GoalState::BudgetLimited; + let err = apply_command(Some(limited), GoalCommand::Resume).unwrap_err(); + assert!(err.contains("Budget exhausted"), "{err}"); + } + #[test] fn complete_requires_evidence_and_reason() { let mut goal = Goal::new("write a file"); @@ -228,6 +273,42 @@ mod tests { assert!(!should_continue(&goal)); } + #[test] + fn complete_rejects_unknown_evidence_kind() { + let mut goal = Goal::new("write a file"); + let err = apply_model_update( + &mut goal, + "complete", + None, + Some("feels done".into()), + vec![GoalEvidence::new("vibe", "shipped")], + ) + .unwrap_err(); + assert!(err.contains("evidence"), "{err}"); + assert_eq!(goal.state, GoalState::Active); + assert!(goal.evidence.is_empty()); + } + + #[test] + fn evidence_is_normalized_and_deduped() { + let mut goal = Goal::new("write a file"); + apply_model_update( + &mut goal, + "active", + Some("wrote it".into()), + None, + vec![ + GoalEvidence::new("FILE", "out.txt"), + GoalEvidence::new("file", "out.txt"), + GoalEvidence::new("shell", "ls out.txt"), + ], + ) + .unwrap(); + assert_eq!(goal.evidence.len(), 2); + assert_eq!(goal.evidence[0].kind, "file"); + assert_eq!(goal.evidence[1].kind, "command"); + } + #[test] fn model_cannot_pause() { let mut goal = Goal::new("x"); @@ -256,6 +337,31 @@ mod tests { assert!(should_continue(&goal)); } + #[test] + fn finish_turn_lets_last_remaining_turn_wrap_up() { + let mut goal = Goal::new("x"); + goal.turn_budget = 2; + assert!(should_continue(&goal)); + assert!(!needs_wrap_up(&goal)); + + assert!(finish_turn(&mut goal, 4)); + assert_eq!(goal.turns_used, 1); + assert!(needs_wrap_up(&goal)); + assert!(should_continue(&goal)); + + assert!(!finish_turn(&mut goal, 4)); + assert_eq!(goal.state, GoalState::BudgetLimited); + assert!(!should_continue(&goal)); + } + + #[test] + fn finish_turn_ignores_paused() { + let mut goal = Goal::new("x"); + goal.state = GoalState::Paused; + assert!(!finish_turn(&mut goal, 10)); + assert_eq!(goal.turns_used, 0); + } + #[test] fn resume_paused_when_budget_gone_is_limited() { let mut goal = Goal::new("x"); diff --git a/src/cortex-engine/src/goal/mod.rs b/src/cortex-engine/src/goal/mod.rs index daa607a5..74b453af 100644 --- a/src/cortex-engine/src/goal/mod.rs +++ b/src/cortex-engine/src/goal/mod.rs @@ -10,11 +10,14 @@ mod types; pub use continuation::{continuation_gate, continuation_prompt, kickoff_prompt}; pub use machine::{ - apply_command, apply_model_update, needs_wrap_up, record_turn, should_continue, within_budget, + apply_command, apply_model_update, finish_turn, needs_wrap_up, record_turn, should_continue, + within_budget, }; pub use parse::{parse_goal_args, parse_goal_input}; -pub use persist::{GOAL_FILE, clear_goal, goal_path, load_goal, save_goal}; +pub use persist::{ + GOAL_FILE, GoalLoad, clear_goal, goal_path, load_goal, load_goal_report, save_goal, +}; pub use types::{ - DEFAULT_TURN_BUDGET, Goal, GoalCommand, GoalEvidence, GoalState, WRAP_UP_TOKEN_RATIO, - WRAP_UP_TURNS_REMAINING, + DEFAULT_TURN_BUDGET, GOAL_SCHEMA_VERSION, Goal, GoalCommand, GoalEvidence, GoalState, + WRAP_UP_TOKEN_RATIO, WRAP_UP_TURNS_REMAINING, normalize_evidence_kind, }; diff --git a/src/cortex-engine/src/goal/parse.rs b/src/cortex-engine/src/goal/parse.rs index 70890262..ab5fdf7f 100644 --- a/src/cortex-engine/src/goal/parse.rs +++ b/src/cortex-engine/src/goal/parse.rs @@ -4,7 +4,7 @@ use super::types::GoalCommand; /// Parse `/goal` arguments (already split, command name stripped). /// -/// Reserved single tokens: `pause`, `resume`, `clear`. Anything else +/// Reserved single tokens: `pause`, `resume`, `clear`, `status`. Anything else /// (including the empty list) is status or a new objective. pub fn parse_goal_args(args: &[String]) -> Result { if args.is_empty() { @@ -16,6 +16,7 @@ pub fn parse_goal_args(args: &[String]) -> Result { "pause" => return Ok(GoalCommand::Pause), "resume" => return Ok(GoalCommand::Resume), "clear" => return Ok(GoalCommand::Clear), + "status" => return Ok(GoalCommand::Status), other if other.trim().is_empty() => { return Err("Goal objective cannot be empty.".to_string()); } @@ -36,13 +37,14 @@ pub fn parse_goal_args(args: &[String]) -> Result { /// Parse a raw composer line such as `/goal pause the deploy`. pub fn parse_goal_input(input: &str) -> Result { let input = input.trim(); - let rest = if let Some(rest) = input.strip_prefix("/goal") { - rest - } else if let Some(rest) = input.strip_prefix("/GOAL") { - rest - } else { + let lower = input.to_ascii_lowercase(); + if !lower.starts_with("/goal") { return Err("Not a /goal command.".to_string()); - }; + } + let rest = &input["/goal".len()..]; + if !rest.is_empty() && !rest.starts_with(char::is_whitespace) { + return Err("Not a /goal command.".to_string()); + } let rest = rest.trim(); if rest.is_empty() { return Ok(GoalCommand::Status); @@ -82,6 +84,14 @@ mod tests { assert_eq!(parse_goal_args(&[]).unwrap(), GoalCommand::Status); assert_eq!(parse_goal_input("/goal").unwrap(), GoalCommand::Status); assert_eq!(parse_goal_input(" /goal ").unwrap(), GoalCommand::Status); + assert_eq!( + parse_goal_input("/goal status").unwrap(), + GoalCommand::Status + ); + assert_eq!( + parse_goal_input("/GOAL STATUS").unwrap(), + GoalCommand::Status + ); } #[test] @@ -93,6 +103,7 @@ mod tests { ); assert_eq!(parse_goal_input("/goal clear").unwrap(), GoalCommand::Clear); assert_eq!(parse_goal_input("/GOAL PAUSE").unwrap(), GoalCommand::Pause); + assert_eq!(parse_goal_input("/Goal Clear").unwrap(), GoalCommand::Clear); } #[test] @@ -109,6 +120,12 @@ mod tests { objective: "pause the deploy".into() } ); + assert_eq!( + parse_goal_input("/goal status the rollout").unwrap(), + GoalCommand::Set { + objective: "status the rollout".into() + } + ); } #[test] @@ -125,5 +142,6 @@ mod tests { fn rejects_non_goal() { assert!(parse_goal_input("/plan").is_err()); assert!(parse_goal_input("goal").is_err()); + assert!(parse_goal_input("/goalstatus").is_err()); } } diff --git a/src/cortex-engine/src/goal/persist.rs b/src/cortex-engine/src/goal/persist.rs index 9a0365c2..953b70ff 100644 --- a/src/cortex-engine/src/goal/persist.rs +++ b/src/cortex-engine/src/goal/persist.rs @@ -1,5 +1,7 @@ //! `goal.json` next to session metadata. +use std::fs::{File, OpenOptions}; +use std::io::Write; use std::path::{Path, PathBuf}; use super::types::Goal; @@ -8,30 +10,78 @@ use crate::error::{CortexError, Result}; /// File name stored in a session directory. pub const GOAL_FILE: &str = "goal.json"; +/// Suffix for a quarantined unreadable `goal.json`. +pub const GOAL_CORRUPT_SUFFIX: &str = "json.corrupt"; + +/// Outcome of reading the session goal file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GoalLoad { + /// No `goal.json` on disk. + Missing, + /// A persistable goal. + Loaded(Goal), + /// File existed but was unreadable. The reason says whether it was moved aside. + Quarantined { reason: String }, +} + pub fn goal_path(session_dir: impl AsRef) -> PathBuf { session_dir.as_ref().join(GOAL_FILE) } -/// Load a goal if `goal.json` exists. Missing file is `Ok(None)`. +/// Load a goal if `goal.json` exists. Missing or quarantined files are `Ok(None)`. pub fn load_goal(session_dir: impl AsRef) -> Result> { - let path = goal_path(session_dir); + match load_goal_report(session_dir)? { + GoalLoad::Loaded(goal) => Ok(Some(goal)), + GoalLoad::Missing | GoalLoad::Quarantined { .. } => Ok(None), + } +} + +/// Load with an explicit missing / loaded / quarantined result for resume UX. +/// +/// Load never deletes `.goal.json.tmp.*`. A concurrent `save_goal` may have +/// that file open; removing it makes the writer's rename fail with ENOENT. +pub fn load_goal_report(session_dir: impl AsRef) -> Result { + let dir = session_dir.as_ref(); + let path = goal_path(dir); if !path.exists() { - return Ok(None); + return Ok(GoalLoad::Missing); } - let content = std::fs::read_to_string(&path)?; - let goal: Goal = serde_json::from_str(&content).map_err(|e| { - CortexError::InvalidInput(format!("Failed to parse {}: {e}", path.display())) - })?; - Ok(Some(goal)) + let content = match std::fs::read_to_string(&path) { + Ok(content) => content, + Err(error) => { + return Ok(quarantine( + &path, + &format!("could not read goal.json ({error})."), + )); + } + }; + let mut goal: Goal = match serde_json::from_str(&content) { + Ok(goal) => goal, + Err(_) => { + return Ok(quarantine(&path, "goal.json was unreadable.")); + } + }; + goal.sanitize(); + if !goal.is_persistable() { + return Ok(quarantine(&path, "goal.json was missing an objective.")); + } + Ok(GoalLoad::Loaded(goal)) } pub fn save_goal(session_dir: impl AsRef, goal: &Goal) -> Result<()> { + let mut goal = goal.clone(); + goal.sanitize(); + if !goal.is_persistable() { + return Err(CortexError::InvalidInput( + "Goal objective cannot be empty.".to_string(), + )); + } let dir = session_dir.as_ref(); std::fs::create_dir_all(dir)?; let path = goal_path(dir); - let content = serde_json::to_string_pretty(goal) - .map_err(|e| CortexError::InvalidInput(format!("Failed to serialize goal: {e}")))?; - atomic_write(&path, content.as_bytes()) + let content = serde_json::to_string_pretty(&goal) + .map_err(|e| CortexError::InvalidInput(format!("Failed to persist goal: {e}")))?; + durable_atomic_write(&path, content.as_bytes()) } pub fn clear_goal(session_dir: impl AsRef) -> Result<()> { @@ -42,20 +92,51 @@ pub fn clear_goal(session_dir: impl AsRef) -> Result<()> { Ok(()) } -fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { +fn quarantine(path: &Path, why: &str) -> GoalLoad { + let dest = path.with_extension(GOAL_CORRUPT_SUFFIX); + let reason = match std::fs::rename(path, &dest) { + Ok(()) => format!("{why} Moved aside so the session can resume."), + Err(error) => { + format!("{why} Could not move the file aside ({error}); it remains in place.") + } + }; + GoalLoad::Quarantined { reason } +} + +fn durable_atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { let parent = path.parent().ok_or_else(|| { CortexError::InvalidInput(format!("Cannot write goal to {}", path.display())) })?; + std::fs::create_dir_all(parent)?; let tmp = parent.join(format!( ".{}.tmp.{}", path.file_name().and_then(|n| n.to_str()).unwrap_or("goal"), std::process::id() )); - std::fs::write(&tmp, bytes)?; - std::fs::rename(&tmp, path).map_err(|e| { + let write_tmp = || -> std::io::Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&tmp)?; + file.write_all(bytes)?; + file.sync_all()?; + Ok(()) + }; + if let Err(error) = write_tmp() { let _ = std::fs::remove_file(&tmp); - CortexError::from(e) - })?; + return Err(CortexError::from(error)); + } + if let Err(error) = std::fs::rename(&tmp, path) { + let _ = std::fs::remove_file(&tmp); + return Err(CortexError::from(error)); + } + #[cfg(unix)] + { + if let Ok(dir) = File::open(parent) { + let _ = dir.sync_all(); + } + } Ok(()) } @@ -67,7 +148,10 @@ mod tests { #[test] fn roundtrip_and_clear() { let dir = tempfile::tempdir().unwrap(); - assert!(load_goal(dir.path()).unwrap().is_none()); + assert!(matches!( + load_goal_report(dir.path()).unwrap(), + GoalLoad::Missing + )); let goal = Goal::new("persist me"); save_goal(dir.path(), &goal).unwrap(); @@ -75,8 +159,155 @@ mod tests { assert_eq!(loaded.objective, "persist me"); assert_eq!(loaded.state, GoalState::Active); assert_eq!(loaded.id, goal.id); + assert_eq!( + loaded.schema_version, + crate::goal::types::GOAL_SCHEMA_VERSION + ); clear_goal(dir.path()).unwrap(); assert!(load_goal(dir.path()).unwrap().is_none()); } + + #[test] + fn corrupt_json_is_quarantined_not_fatal() { + let dir = tempfile::tempdir().unwrap(); + let path = goal_path(dir.path()); + std::fs::write(&path, "{not-json").unwrap(); + match load_goal_report(dir.path()).unwrap() { + GoalLoad::Quarantined { reason } => { + assert!(reason.contains("unreadable"), "{reason}"); + } + other => panic!("expected quarantine, got {other:?}"), + } + assert!(!path.exists()); + assert!(dir.path().join("goal.json.corrupt").exists()); + assert!(load_goal(dir.path()).unwrap().is_none()); + } + + #[test] + fn empty_objective_is_quarantined() { + let dir = tempfile::tempdir().unwrap(); + let mut goal = Goal::new("x"); + goal.objective = String::new(); + let path = goal_path(dir.path()); + std::fs::write(&path, serde_json::to_string(&goal).unwrap()).unwrap(); + assert!(matches!( + load_goal_report(dir.path()).unwrap(), + GoalLoad::Quarantined { .. } + )); + assert!(load_goal(dir.path()).unwrap().is_none()); + } + + #[test] + fn zero_budget_is_repaired_on_load() { + let dir = tempfile::tempdir().unwrap(); + let mut goal = Goal::new("keep going"); + goal.turn_budget = 0; + let path = goal_path(dir.path()); + std::fs::write(&path, serde_json::to_string(&goal).unwrap()).unwrap(); + let loaded = load_goal(dir.path()).unwrap().unwrap(); + assert_eq!(loaded.turn_budget, crate::goal::types::DEFAULT_TURN_BUDGET); + } + + #[test] + fn save_rejects_empty_objective() { + let dir = tempfile::tempdir().unwrap(); + let mut goal = Goal::new("x"); + goal.objective.clear(); + assert!(save_goal(dir.path(), &goal).is_err()); + } + + #[test] + fn load_never_deletes_goal_temps() { + let dir = tempfile::tempdir().unwrap(); + let live = dir + .path() + .join(format!(".goal.json.tmp.{}", std::process::id())); + let other = dir.path().join(".goal.json.tmp.1"); + std::fs::write(&live, b"in-flight").unwrap(); + std::fs::write(&other, b"other-writer").unwrap(); + assert!(matches!( + load_goal_report(dir.path()).unwrap(), + GoalLoad::Missing + )); + assert!(live.exists(), "load must not steal this process's temp"); + assert!(other.exists(), "load must not steal another writer's temp"); + } + + #[test] + fn load_does_not_break_in_flight_writer_rename() { + let dir = tempfile::tempdir().unwrap(); + let goal = Goal::new("from writer"); + let tmp = dir + .path() + .join(format!(".goal.json.tmp.{}", std::process::id())); + std::fs::write(&tmp, serde_json::to_string_pretty(&goal).unwrap()).unwrap(); + let _ = load_goal_report(dir.path()).unwrap(); + std::fs::rename(&tmp, goal_path(dir.path())).expect("writer rename must not ENOENT"); + let loaded = load_goal(dir.path()).unwrap().unwrap(); + assert_eq!(loaded.objective, "from writer"); + } + + #[cfg(unix)] + #[test] + fn parent_load_does_not_steal_child_writer_temp() { + let dir = tempfile::tempdir().unwrap(); + let session = dir.path(); + let script = r#" +set -e +tmp="$1/.goal.json.tmp.$$" +printf '%s\n' '{"schema_version":1,"id":"child","objective":"child-writer","state":"active","progress":null,"turns_used":0,"turn_budget":8,"tokens_used":0,"token_budget":null,"evidence":[],"last_reason":null,"created_at":0,"updated_at":0}' > "$tmp" +echo ready > "$1/ready" +while [ ! -f "$1/go" ]; do sleep 0.05; done +mv "$tmp" "$1/goal.json" +"#; + let mut child = std::process::Command::new("sh") + .arg("-c") + .arg(script) + .arg("goal-tmp-writer") + .arg(session) + .spawn() + .expect("spawn child writer"); + let ready = session.join("ready"); + for _ in 0..200 { + if ready.exists() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(ready.exists(), "child should publish its in-flight temp"); + let _ = load_goal_report(session).unwrap(); + std::fs::write(session.join("go"), b"go").unwrap(); + let status = child.wait().expect("wait child writer"); + assert!( + status.success(), + "child rename must succeed after parent load" + ); + let loaded = load_goal(session).unwrap().unwrap(); + assert_eq!(loaded.objective, "child-writer"); + } + + #[test] + fn quarantine_reports_when_rename_fails() { + let dir = tempfile::tempdir().unwrap(); + let path = goal_path(dir.path()); + std::fs::write(&path, "{not-json").unwrap(); + // Occupying the destination makes rename fail without depending on uid. + std::fs::create_dir(dir.path().join("goal.json.corrupt")).unwrap(); + match load_goal_report(dir.path()).unwrap() { + GoalLoad::Quarantined { reason } => { + assert!(reason.contains("unreadable"), "{reason}"); + assert!( + reason.contains("remains in place"), + "must not claim the file was moved: {reason}" + ); + assert!( + !reason.contains("Moved aside"), + "must not claim a successful quarantine: {reason}" + ); + } + other => panic!("expected quarantine, got {other:?}"), + } + assert!(path.exists(), "corrupt goal.json must stay when move fails"); + } } diff --git a/src/cortex-engine/src/goal/types.rs b/src/cortex-engine/src/goal/types.rs index 886b46b6..0f79d6f6 100644 --- a/src/cortex-engine/src/goal/types.rs +++ b/src/cortex-engine/src/goal/types.rs @@ -11,6 +11,13 @@ pub const WRAP_UP_TURNS_REMAINING: u32 = 1; /// Token-budget ratio that triggers wrap-up steering. pub const WRAP_UP_TOKEN_RATIO: f64 = 0.85; +/// On-disk schema for `goal.json`. Bump when the file shape changes. +pub const GOAL_SCHEMA_VERSION: u32 = 1; + +fn default_schema_version() -> u32 { + GOAL_SCHEMA_VERSION +} + /// Lifecycle of a persisted session goal. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -33,6 +40,17 @@ impl GoalState { Self::Blocked => "blocked", } } + + /// Composer chip suffix after `Goal · `. + pub fn chip_label(self) -> &'static str { + match self { + Self::Active => "", + Self::Paused => "paused", + Self::Complete => "done", + Self::BudgetLimited => "budget", + Self::Blocked => "blocked", + } + } } impl std::fmt::Display for GoalState { @@ -60,7 +78,30 @@ impl GoalEvidence { /// True when both fields are non-empty after trim. pub fn is_usable(&self) -> bool { - !self.kind.trim().is_empty() && !self.detail.trim().is_empty() + normalize_evidence_kind(&self.kind).is_some() && !self.detail.trim().is_empty() + } + + /// Canonical `file` / `command` / `test` plus trimmed detail, if usable. + pub fn normalized(self) -> Option { + let kind = normalize_evidence_kind(&self.kind)?; + let detail = self.detail.trim(); + if detail.is_empty() { + return None; + } + Some(Self { + kind: kind.to_string(), + detail: detail.to_string(), + }) + } +} + +/// Map a model-supplied evidence kind onto the allowed set. +pub fn normalize_evidence_kind(kind: &str) -> Option<&'static str> { + match kind.trim().to_ascii_lowercase().as_str() { + "file" | "path" | "filepath" => Some("file"), + "command" | "cmd" | "shell" => Some("command"), + "test" | "tests" => Some("test"), + _ => None, } } @@ -77,6 +118,8 @@ pub enum GoalCommand { /// Persisted durable objective attached to a session. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Goal { + #[serde(default = "default_schema_version")] + pub schema_version: u32, pub id: String, pub objective: String, pub state: GoalState, @@ -99,7 +142,8 @@ impl Goal { /// Create a replacement active goal for `objective`. pub fn new(objective: impl Into) -> Self { let now = chrono::Utc::now().timestamp(); - Self { + let mut goal = Self { + schema_version: GOAL_SCHEMA_VERSION, id: uuid::Uuid::new_v4().to_string(), objective: objective.into(), state: GoalState::Active, @@ -112,13 +156,38 @@ impl Goal { last_reason: None, created_at: now, updated_at: now, - } + }; + goal.sanitize(); + goal } pub fn touch(&mut self) { self.updated_at = chrono::Utc::now().timestamp(); } + /// Drop empty evidence, canonicalize kinds, and repair a zero budget. + pub fn sanitize(&mut self) { + self.objective = self.objective.trim().to_string(); + if self.schema_version == 0 { + self.schema_version = GOAL_SCHEMA_VERSION; + } + if self.turn_budget == 0 { + self.turn_budget = DEFAULT_TURN_BUDGET; + } + let mut seen = std::collections::HashSet::new(); + self.evidence = self + .evidence + .drain(..) + .filter_map(GoalEvidence::normalized) + .filter(|item| seen.insert((item.kind.clone(), item.detail.clone()))) + .collect(); + } + + /// True when the record is safe to keep on disk and show in the chip. + pub fn is_persistable(&self) -> bool { + !self.id.trim().is_empty() && !self.objective.trim().is_empty() + } + /// Composer / footer chip, e.g. `Goal · 2/8`. pub fn chip(&self) -> String { match self.state { @@ -130,9 +199,34 @@ impl Goal { } } + /// Short next-step line for `/goal` status. + pub fn next_step(&self) -> &'static str { + match self.state { + GoalState::Active if self.needs_wrap_up_hint() => { + "wrap-up — verify, record evidence, do not open new scope" + } + GoalState::Active => "continue when idle", + GoalState::Paused => "paused — /goal resume to continue", + GoalState::Complete => "done — /goal clear to remove", + GoalState::BudgetLimited => { + "budget exhausted — /goal to replace, or /goal clear" + } + GoalState::Blocked => "blocked — /goal resume after you unblock", + } + } + + fn needs_wrap_up_hint(&self) -> bool { + if self.turns_remaining() <= WRAP_UP_TURNS_REMAINING { + return true; + } + self.token_ratio() + .is_some_and(|ratio| ratio >= WRAP_UP_TOKEN_RATIO) + } + /// Multi-line status for `/goal` with no arguments. pub fn status_text(&self) -> String { let progress = self.progress.as_deref().unwrap_or("(none yet)"); + let last = self.last_reason.as_deref().unwrap_or("(none)"); let tokens = match self.token_budget { Some(budget) => format!("{} / {budget}", self.tokens_used), None => format!("{} (no cap)", self.tokens_used), @@ -147,11 +241,24 @@ impl Goal { .join("\n") }; format!( - "Goal\n objective: {}\n state: {}\n progress: {}\n turns: {} / {}\n tokens: {}\n evidence:\n{evidence}", - self.objective, self.state, progress, self.turns_used, self.turn_budget, tokens + "{}\n objective: {}\n state: {}\n progress: {}\n last: {}\n turns: {} / {}\n tokens: {}\n next: {}\n evidence:\n{evidence}", + self.chip(), + self.objective, + self.state, + progress, + last, + self.turns_used, + self.turn_budget, + tokens, + self.next_step() ) } + /// Pause / resume / set acknowledgement that repeats the chip. + pub fn action_text(&self, action: &str) -> String { + format!("{action} {}", self.chip()) + } + pub fn turns_remaining(&self) -> u32 { self.turn_budget.saturating_sub(self.turns_used) } @@ -190,3 +297,78 @@ impl Goal { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chip_copy_is_stable_per_state() { + let mut goal = Goal::new("ship it"); + goal.turns_used = 2; + assert_eq!(goal.chip(), "Goal · 2/8"); + goal.state = GoalState::Paused; + assert_eq!(goal.chip(), "Goal · paused"); + goal.state = GoalState::Complete; + assert_eq!(goal.chip(), "Goal · done"); + goal.state = GoalState::BudgetLimited; + assert_eq!(goal.chip(), "Goal · budget"); + goal.state = GoalState::Blocked; + assert_eq!(goal.chip(), "Goal · blocked"); + } + + #[test] + fn status_names_chip_and_next_step() { + let mut goal = Goal::new("write out.txt"); + goal.turns_used = 7; + let text = goal.status_text(); + assert!(text.starts_with("Goal · 7/8"), "{text}"); + assert!(text.contains("wrap-up"), "{text}"); + assert!(text.contains("write out.txt"), "{text}"); + } + + #[test] + fn sanitize_repairs_budget_and_kinds() { + let mut goal = Goal::new(" keep me "); + goal.turn_budget = 0; + goal.evidence = vec![ + GoalEvidence::new("FILE", "src/lib.rs"), + GoalEvidence::new("vibe", "feels done"), + GoalEvidence::new("file", "src/lib.rs"), + ]; + goal.sanitize(); + assert_eq!(goal.objective, "keep me"); + assert_eq!(goal.turn_budget, DEFAULT_TURN_BUDGET); + assert_eq!(goal.evidence.len(), 1); + assert_eq!(goal.evidence[0].kind, "file"); + } + + #[test] + fn evidence_kinds_are_canonical() { + assert_eq!(normalize_evidence_kind("Path"), Some("file")); + assert_eq!(normalize_evidence_kind("shell"), Some("command")); + assert_eq!(normalize_evidence_kind("tests"), Some("test")); + assert!(normalize_evidence_kind("vibe").is_none()); + } + + #[test] + fn sanitize_dedups_nonadjacent_evidence() { + let mut goal = Goal::new("ship it"); + goal.evidence = vec![ + GoalEvidence::new("file", "a.rs"), + GoalEvidence::new("command", "ls a.rs"), + GoalEvidence::new("FILE", "a.rs"), + GoalEvidence::new("test", "goal_ok"), + GoalEvidence::new("file", "a.rs"), + ]; + goal.sanitize(); + assert_eq!( + goal.evidence, + vec![ + GoalEvidence::new("file", "a.rs"), + GoalEvidence::new("command", "ls a.rs"), + GoalEvidence::new("test", "goal_ok"), + ] + ); + } +} diff --git a/src/cortex-engine/src/session/handlers.rs b/src/cortex-engine/src/session/handlers.rs index 1717e162..175ca73f 100644 --- a/src/cortex-engine/src/session/handlers.rs +++ b/src/cortex-engine/src/session/handlers.rs @@ -712,7 +712,7 @@ impl Session { async fn continue_goal_if_needed(&mut self) -> Result<()> { use crate::goal::{ - continuation_prompt, load_goal, record_turn, save_goal, should_continue, + continuation_prompt, finish_turn, load_goal, save_goal, should_continue, }; let dir = self.goal_session_dir(); @@ -720,13 +720,13 @@ impl Session { let Some(mut goal) = load_goal(&dir)? else { break; }; - if !should_continue(&goal) { + if goal.state != crate::goal::GoalState::Active { break; } - record_turn(&mut goal, 0); + let cont = finish_turn(&mut goal, 0); save_goal(&dir, &goal)?; self.emit(EventMsg::GoalUpdated(goal.to_event())).await; - if !should_continue(&goal) { + if !cont || !should_continue(&goal) { break; } let prompt = continuation_prompt(&goal); diff --git a/src/cortex-engine/src/tools/handlers/update_goal.rs b/src/cortex-engine/src/tools/handlers/update_goal.rs index f5e49e2c..af79d903 100644 --- a/src/cortex-engine/src/tools/handlers/update_goal.rs +++ b/src/cortex-engine/src/tools/handlers/update_goal.rs @@ -84,7 +84,20 @@ impl ToolHandler for UpdateGoalHandler { .evidence .into_iter() .map(|e| GoalEvidence::new(e.kind, e.detail)) - .collect(); + .collect::>(); + if args.status == "complete" + && !evidence.iter().any(GoalEvidence::is_usable) + && evidence + .iter() + .any(|e| !e.kind.trim().is_empty() || !e.detail.trim().is_empty()) + { + return Err(CortexError::ToolExecution { + tool: "UpdateGoal".to_string(), + message: + "Completion requires evidence kind file, command, or test (plus a non-empty detail)." + .to_string(), + }); + } apply_model_update( &mut goal, @@ -188,6 +201,28 @@ mod tests { assert!(err.to_string().contains("cannot pause")); } + #[tokio::test] + async fn complete_rejects_unknown_evidence_kind() { + let dir = tempfile::tempdir().unwrap(); + save_goal(dir.path(), &Goal::new("write out.txt")).unwrap(); + let ctx = + ToolContext::new(dir.path().to_path_buf()).with_session_dir(dir.path().to_path_buf()); + let err = UpdateGoalHandler::new() + .execute( + json!({ + "status": "complete", + "reason": "looks good", + "evidence": [{"kind": "vibe", "detail": "shipped"}] + }), + &ctx, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("file, command, or test")); + let loaded = load_goal(dir.path()).unwrap().unwrap(); + assert_eq!(loaded.state, crate::goal::GoalState::Active); + } + #[tokio::test] async fn missing_goal_errors() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/cortex-tui/src/bridge/event_adapter/mod.rs b/src/cortex-tui/src/bridge/event_adapter/mod.rs index a754c06f..ad5bfd19 100644 --- a/src/cortex-tui/src/bridge/event_adapter/mod.rs +++ b/src/cortex-tui/src/bridge/event_adapter/mod.rs @@ -283,11 +283,10 @@ pub fn adapt_event(event: Event) -> Option { if e.cleared { Some(AppEvent::Info("Goal cleared.".to_string())) } else { - Some(AppEvent::Info(format!( - "Goal · {} ({})", - e.state.as_deref().unwrap_or("active"), - e.chip.as_deref().unwrap_or("in progress") - ))) + let chip = e.chip.clone().unwrap_or_else(|| { + format!("Goal · {}", e.state.as_deref().unwrap_or("active")) + }); + Some(AppEvent::Info(chip)) } } @@ -483,6 +482,34 @@ mod tests { assert!(matches!(result, Some(AppEvent::SessionCreated(_)))); } + #[test] + fn test_adapt_goal_updated_uses_chip() { + use cortex_protocol::GoalUpdatedEvent; + let event = make_event(EventMsg::GoalUpdated(GoalUpdatedEvent { + cleared: false, + goal_id: Some("g1".to_string()), + objective: Some("create foo.txt".to_string()), + state: Some("active".to_string()), + progress: None, + turns_used: 2, + turn_budget: 8, + chip: Some("Goal · 2/8".to_string()), + })); + assert!(matches!(adapt_event(event), Some(AppEvent::Info(s)) if s == "Goal · 2/8")); + + let cleared = make_event(EventMsg::GoalUpdated(GoalUpdatedEvent { + cleared: true, + goal_id: None, + objective: None, + state: Some("cleared".to_string()), + progress: None, + turns_used: 0, + turn_budget: 0, + chip: None, + })); + assert!(matches!(adapt_event(cleared), Some(AppEvent::Info(s)) if s == "Goal cleared.")); + } + #[test] fn test_adapt_events_batch() { let events = vec![ diff --git a/src/cortex-tui/src/commands/executor/tests.rs b/src/cortex-tui/src/commands/executor/tests.rs index 5d80e334..0dd64e11 100644 --- a/src/cortex-tui/src/commands/executor/tests.rs +++ b/src/cortex-tui/src/commands/executor/tests.rs @@ -284,6 +284,10 @@ fn test_goal_commands() { executor.execute_str("/goal"), CommandResult::Async(ref s) if s == "goal:status" )); + assert!(matches!( + executor.execute_str("/goal status"), + CommandResult::Async(ref s) if s == "goal:status" + )); assert!(matches!( executor.execute_str("/goal pause"), CommandResult::Async(ref s) if s == "goal:pause" diff --git a/src/cortex-tui/src/commands/registry/builtin.rs b/src/cortex-tui/src/commands/registry/builtin.rs index 67638dbe..e07d6831 100644 --- a/src/cortex-tui/src/commands/registry/builtin.rs +++ b/src/cortex-tui/src/commands/registry/builtin.rs @@ -88,7 +88,7 @@ pub fn register_builtin_commands(registry: &mut CommandRegistry) { "goal", &[], "Persisted long-horizon objective", - "/goal [objective|pause|resume|clear]", + "/goal [objective|status|pause|resume|clear]", CommandCategory::General, true, )); diff --git a/src/cortex-tui/src/lock_v2_goal.rs b/src/cortex-tui/src/lock_v2_goal.rs index f140dde8..7b0a488c 100644 --- a/src/cortex-tui/src/lock_v2_goal.rs +++ b/src/cortex-tui/src/lock_v2_goal.rs @@ -134,4 +134,14 @@ mod tests { fn goal_chip_ids_are_registered() { assert_eq!(GOAL_CHIP_IDS.len(), 5); } + + #[test] + fn status_text_repeats_chip_and_next_step() { + let mut goal = Goal::new("ship the rate limiter and prove it with tests"); + goal.state = GoalState::Paused; + let text = goal.status_text(); + assert!(text.contains("Goal · paused"), "{text}"); + assert!(text.contains("/goal resume"), "{text}"); + assert!(!text.contains("Yes, run once"), "{text}"); + } } diff --git a/src/cortex-tui/src/runner/event_loop/commands.rs b/src/cortex-tui/src/runner/event_loop/commands.rs index d16834c1..8f953a09 100644 --- a/src/cortex-tui/src/runner/event_loop/commands.rs +++ b/src/cortex-tui/src/runner/event_loop/commands.rs @@ -773,11 +773,17 @@ impl EventLoop { return Ok(()); }; + self.reload_goal_from_session(); match apply_command(self.app_state.goal.clone(), command.clone()) { Ok(next) => { + let previous = self.app_state.goal.clone(); self.app_state.goal = next; - if let Err(error) = self.persist_app_goal() { - self.add_system_message(&format!("Could not persist goal: {error}")); + if !matches!(command, GoalCommand::Status) { + if let Err(error) = self.persist_app_goal() { + self.app_state.goal = previous; + self.add_system_message(&format!("Could not persist goal: {error}")); + return Ok(()); + } } match command { GoalCommand::Status => { @@ -788,18 +794,30 @@ impl EventLoop { } } GoalCommand::Pause => { - self.add_system_message("Goal paused."); + if let Some(goal) = &self.app_state.goal { + self.add_system_message(&goal.action_text("Goal paused.")); + } else { + self.add_system_message("Goal paused."); + } } GoalCommand::Resume => { if let Some(goal) = &self.app_state.goal { - self.add_system_message(&format!("Goal is {}.", goal.state)); + self.add_system_message( + &goal.action_text(&format!("Goal is {}.", goal.state)), + ); } } GoalCommand::Clear => { self.add_system_message("Goal cleared."); } GoalCommand::Set { objective } => { - self.add_system_message(&format!("Goal set: {objective}")); + if let Some(goal) = &self.app_state.goal { + self.add_system_message( + &goal.action_text(&format!("Goal set: {objective}.")), + ); + } else { + self.add_system_message(&format!("Goal set: {objective}")); + } self.send_text_message(cortex_engine::goal::kickoff_prompt(&objective)) .await?; } @@ -811,34 +829,50 @@ impl EventLoop { } fn persist_app_goal(&mut self) -> Result<()> { - if let Some(session) = &self.cortex_session { - session.persist_goal(self.app_state.goal.as_ref())?; - } + let session = self + .cortex_session + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No session to persist the goal."))?; + session.persist_goal(self.app_state.goal.as_ref())?; Ok(()) } pub(super) fn reload_goal_from_session(&mut self) { - if let Some(session) = &self.cortex_session - && let Ok(goal) = session.load_goal() - { - self.app_state.goal = goal; + let Some(session) = &self.cortex_session else { + return; + }; + match session.load_goal_report() { + Ok(cortex_engine::goal::GoalLoad::Loaded(goal)) => { + self.app_state.goal = Some(goal); + } + Ok(cortex_engine::goal::GoalLoad::Missing) => { + self.app_state.goal = None; + } + Ok(cortex_engine::goal::GoalLoad::Quarantined { reason }) => { + self.app_state.goal = None; + self.add_system_message(&reason); + } + Err(error) => { + self.add_system_message(&format!("Could not reload goal: {error}")); + } } } pub(super) async fn maybe_continue_goal(&mut self, tokens: u64) -> bool { self.reload_goal_from_session(); - if !cortex_engine::goal::continuation_gate(self.app_state.goal.as_ref()) { + let Some(goal) = self.app_state.goal.as_mut() else { + return false; + }; + if goal.state != cortex_engine::goal::GoalState::Active { return false; } - { - let goal = self.app_state.goal.as_mut().expect("continuation gate"); - cortex_engine::goal::record_turn(goal, tokens); - } + let cont = cortex_engine::goal::finish_turn(goal, tokens); if let Err(error) = self.persist_app_goal() { self.add_system_message(&format!("Could not persist goal: {error}")); + self.reload_goal_from_session(); return false; } - if !cortex_engine::goal::continuation_gate(self.app_state.goal.as_ref()) { + if !cont { return false; } let prompt = cortex_engine::goal::continuation_prompt( diff --git a/src/cortex-tui/src/runner/event_loop/sessions.rs b/src/cortex-tui/src/runner/event_loop/sessions.rs index f92f8372..749b1a3b 100644 --- a/src/cortex-tui/src/runner/event_loop/sessions.rs +++ b/src/cortex-tui/src/runner/event_loop/sessions.rs @@ -149,11 +149,31 @@ impl EventLoop { worked_secs: None, }); } - self.app_state.goal = session - .load_goal() - .map_err(|e| anyhow::anyhow!("Failed to load session goal: {e}"))?; + let mut resume_note = None; + match session.load_goal_report() { + Ok(cortex_engine::goal::GoalLoad::Loaded(goal)) => { + resume_note = Some(format!("Resumed {}", goal.chip())); + self.app_state.goal = Some(goal); + } + Ok(cortex_engine::goal::GoalLoad::Missing) => { + self.app_state.goal = None; + } + Ok(cortex_engine::goal::GoalLoad::Quarantined { reason }) => { + self.app_state.goal = None; + resume_note = Some(reason); + } + Err(error) => { + self.app_state.goal = None; + resume_note = Some(format!( + "Could not load session goal. Session resumed without it ({error})." + )); + } + } self.cortex_session = Some(session); self.app_state.set_view(AppView::Session); + if let Some(note) = resume_note { + self.add_system_message(¬e); + } Ok(()) } diff --git a/src/cortex-tui/src/session/manager.rs b/src/cortex-tui/src/session/manager.rs index 23a54d05..6c73a74b 100644 --- a/src/cortex-tui/src/session/manager.rs +++ b/src/cortex-tui/src/session/manager.rs @@ -95,6 +95,12 @@ impl CortexSession { .map_err(|e| anyhow::anyhow!("{e}")) } + /// Load with missing / loaded / quarantined so resume can continue after a bad file. + pub fn load_goal_report(&self) -> Result { + cortex_engine::goal::load_goal_report(self.storage.session_dir(self.id())) + .map_err(|e| anyhow::anyhow!("{e}")) + } + /// Persist or clear the session goal. pub fn persist_goal(&self, goal: Option<&cortex_engine::goal::Goal>) -> Result<()> { let dir = self.storage.session_dir(self.id()); @@ -675,4 +681,22 @@ mod tests { session.persist_goal(None).unwrap(); assert!(session.load_goal().unwrap().is_none()); } + + #[test] + fn test_goal_corrupt_is_quarantined() { + let (session, _temp) = create_test_session(); + let path = session + .storage() + .session_dir(session.id()) + .join("goal.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "{not-json").unwrap(); + match session.load_goal_report().unwrap() { + cortex_engine::goal::GoalLoad::Quarantined { reason } => { + assert!(reason.contains("unreadable"), "{reason}"); + } + other => panic!("expected quarantine, got {other:?}"), + } + assert!(session.load_goal().unwrap().is_none()); + } }