mirror of
https://github.com/instructkr/claude-code.git
synced 2026-06-04 03:26:45 +00:00
Compare commits
7 Commits
feat/134-1
...
f3f6643fb9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3f6643fb9 | ||
|
|
883cef1a26 | ||
|
|
768c1abc78 | ||
|
|
a8beca1463 | ||
|
|
21adae9570 | ||
|
|
724a78604d | ||
|
|
91ba54d39f |
120
ROADMAP.md
120
ROADMAP.md
@@ -830,6 +830,25 @@ Acceptance:
|
||||
- channel status updates stay short and machine-grounded
|
||||
- claws stop inferring state from raw build spam
|
||||
|
||||
### 137. Model-alias shorthand regression in test suite — bare alias parsing broken on `feat/134-135-session-identity` branch
|
||||
|
||||
**Filed:** 2026-04-21 from dogfood cycle — `cargo test --workspace` on `feat/134-135-session-identity` HEAD (`91ba54d`) shows 3 failing tests.
|
||||
|
||||
**Problem:** `tests::parses_bare_prompt_and_json_output_flag`, `tests::multi_word_prompt_still_uses_shorthand_prompt_mode`, and `tests::env_permission_mode_overrides_project_config_default` all panic with:
|
||||
```
|
||||
args should parse: "invalid model syntax: 'claude-opus'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)"
|
||||
```
|
||||
The #134/#135 session-identity work tightened model-syntax validation but the test fixtures still pass bare `claude-opus` style strings that the new validator rejects. 162 tests pass; only the three tests using legacy bare-alias model names fail.
|
||||
|
||||
**Fix shape:**
|
||||
- Update the three failing test fixtures to use either a valid alias (`opus`, `sonnet`, `haiku`) or a fully-qualified model id (`anthropic/claude-opus-4-6`)
|
||||
- Alternatively, if `claude-opus` is an intended supported alias, add it to the alias registry
|
||||
- Verify `cargo test --workspace` returns 0 failures before merging the feat branch to `main`
|
||||
|
||||
**Acceptance:**
|
||||
- `cargo test --workspace` passes with 0 failures on the `feat/134-135-session-identity` branch
|
||||
- No regression on the 162 tests currently passing
|
||||
|
||||
### 133. Blocked-state subphase contract (was §6.5)
|
||||
**Filed:** 2026-04-20 from dogfood cycle — previous cycle identified §4.44.5 provenance gap, this cycle targets §6.5 implementation.
|
||||
|
||||
@@ -5051,3 +5070,104 @@ ear], /color [scheme], /effort [low|medium|high], /fast, /summary, /tag [label],
|
||||
**Blocker.** None. Requires a UUID/nanoid generated at session init and threaded through the event emitter.
|
||||
|
||||
**Source.** Jobdori dogfood 2026-04-21 01:54 KST on main HEAD `50e3fa3` during recurring cron cycle. Joins **Session identity completeness at creation time** (ROADMAP §4.7) — §4.7 covers identity fields at creation time; #134 covers the stable correlation handle that ties those fields to downstream events. Joins **Event provenance / environment labeling** (§4.6) — provenance requires a stable anchor; without `session.id` the provenance chain is broken at the root. Natural bundle with **#241** (no startup run/correlation id, filed by gaebal-gajae 2026-04-20) — #241 approached from the startup cluster; #134 approaches from the event-stream observer side. Same root fix closes both. Session tally: ROADMAP #134.
|
||||
|
||||
## Pinpoint #136. `--compact` flag output is not machine-readable — compact turn emits plain text instead of JSON when `--output-format json` is also passed
|
||||
|
||||
**Gap.** `claw --compact <prompt>` runs a prompt turn with compacted output (tool-use suppressed, final assistant text only). But `run_with_output()` routes on `(output_format, compact)` with an explicit early-return match: `CliOutputFormat::Text if compact => run_prompt_compact(input)`. The `CliOutputFormat::Json` branch is never reached when `--compact` is set. Result: passing `--compact --output-format json` silently produces plain-text output — the compact flag wins and the format flag is silently ignored. No warning or error is emitted.
|
||||
|
||||
**Trace path.**
|
||||
- `rust/crates/rusty-claude-cli/src/main.rs:3872-3879` — `run_with_output()` match:
|
||||
```
|
||||
CliOutputFormat::Text if compact => self.run_prompt_compact(input),
|
||||
CliOutputFormat::Text => self.run_turn(input),
|
||||
CliOutputFormat::Json => self.run_prompt_json(input),
|
||||
```
|
||||
The `Json` arm is unreachable when `compact = true` because the first arm matches first regardless of `output_format`.
|
||||
- `run_prompt_compact()` at line 3879 calls `println!("{final_text}")` — always plain text, no JSON envelope.
|
||||
- `run_prompt_json()` at line 3891 wraps output in a JSON object with `message`, `model`, `iterations`, `usage`, `tool_uses`, `tool_results`, etc.
|
||||
|
||||
**Fix shape (~20 lines).**
|
||||
1. Add a `CliOutputFormat::Json if compact` arm (or merge compact flag into `run_prompt_json` as a parameter) that produces a JSON object with `message: <final_text>` and a `compact: true` marker. Tool-use fields remain present but empty arrays (consistent with compact semantics — tools ran but are not returned verbatim).
|
||||
2. Emit a warning or `error.kind: "flag_conflict"` if conflicting flags are passed in a way that silently wins (or document the precedence explicitly in `--help`).
|
||||
3. Regression tests: `claw --compact --output-format json <prompt>` must produce valid JSON with at minimum `{message: "...", compact: true}`.
|
||||
|
||||
**Acceptance.** An orchestrator that requests compact output for token efficiency AND machine-readable JSON gets both. Silent flag override is never a correct behavior for a tool targeting machine consumers.
|
||||
|
||||
**Blocker.** None. Additive change to existing match arms.
|
||||
|
||||
**Source.** Jobdori dogfood 2026-04-21 12:25 KST on main HEAD `8b52e77` during recurring cron cycle. Joins **Output format completeness** cluster (#90/#91/#92/#127/#130) — all surfaces that produce inconsistent or plain-text fallbacks when JSON is requested. Also joins **CLI/REPL parity** (§7.1) — compact is available as both `--compact` flag and `/compact` REPL command; JSON output gap affects only the flag path. Session tally: ROADMAP #136.
|
||||
|
||||
## Pinpoint #138. Dogfood cycle report-gate opacity — nudge surface collapses "bundle converged", "follow-up landed", and "pre-existing flake only" into single closure shape
|
||||
|
||||
**Gap.** When a dogfood nudge triggers on a branch with landed work, the report surface emits status like "fixed 3 tests, pushed branch, 1 unrelated red remains" — but downstream nudges cannot distinguish:
|
||||
1. `bundle converged, merge-ready` (e.g., #134/#135 branch after fixes)
|
||||
2. `follow-up landed on main, branch still valid` (e.g., #137 + #136 fixes after #134/#135 was ready)
|
||||
3. `only pre-existing flake remains, no new regressions` (e.g., `resume_latest...` test failure on main that also fails on feature branch)
|
||||
4. `work still in flight, blocker not yet resolved`
|
||||
5. `merged and closed, re-nudge is a dup`
|
||||
|
||||
Result: repeat nudges look identical whether the prior work converged or is still broken. Claws re-open what was already resolved, burning cycles on rediscovery.
|
||||
|
||||
**Concrete example from this session:**
|
||||
- 14:30 nudge triggered on bundle already clear (14:25)
|
||||
- Reported finding was "nudge closure-state opacity" but manifested as "should we re-nudge or not?"
|
||||
- No explicit surface like "status: done", "last-updated: 2026-04-21T14:25", "next-action: none" that stops re-nudges on unchanged state
|
||||
|
||||
**Fix shape (~30-50 lines, surfaces not code).**
|
||||
1. Dogfood report should carry an explicit **closure state** field: `converged`, `follow-up-landed`, `pre-existing-flake-only`, `in-flight`, `merged`, `dup`.
|
||||
2. Each state has a **last-updated timestamp** (when report was filed) and **next-action** (null if converged, or describe blocker).
|
||||
3. Nudge logic checks prior report state: if `converged` + timestamp < 10 min old, skip nudge and post "still converged as of HH:MM, no action".
|
||||
4. If state changed (e.g., new commits landed), emit **state transition** explicitly: "bundle done (14:25) → follow-up landed (14:42)".
|
||||
5. Store closure state in a **shared metadata surface** (Discord message edit, ROADMAP inline, or compact JSON file) so next cycle can read it.
|
||||
|
||||
**Acceptance.**
|
||||
- Repeat nudges on converged work are replaced with "no change since last report" (skip).
|
||||
- State transitions are explicit: "was X, now Y" instead of ambiguous "X and also Y".
|
||||
- Claws can scan closure states and prioritize fresh work over already-handled bundles.
|
||||
|
||||
**Blocker.** Design question: **where should closure state live?** Options:
|
||||
- Edit the prior Discord message with a closure tag (e.g., 🟢 CONVERGED).
|
||||
- Add a `.dogfood-closure.json` file to the worktree branch that tracks state.
|
||||
- File a new ROADMAP entry per bundle completion (meta-tracking).
|
||||
- Embedded in claw-code CLI output (machine-readable, but creates coupling).
|
||||
|
||||
Current state is **design question unresolved**. Implementation is straightforward once closure-state model is settled.
|
||||
|
||||
**Source.** Jobdori dogfood 2026-04-21 14:25-14:47 KST — multi-cycle convergence pattern exposed by repeat nudges on #134/#135 bundle. Joins **Dogfood loop observability** (related to earlier §4.7 session-identity, but one level up — session-identity is plumbing, closure-state is the **reporting contract**). Also joins **False-green report gating** (from 14:05 finding) — this is the downstream effect: unclear reports beget re-nudges on stale work.
|
||||
|
||||
Session tally: ROADMAP #138.
|
||||
|
||||
### Evidence for #138 — feat/134-135-session-identity branch is pushed but no PR was opened (2026-04-21 15:05)
|
||||
|
||||
**Concrete gap observed:**
|
||||
- Branch `feat/134-135-session-identity` pushed to `origin` at `7235260` (commits `f55612e`, `2b7095e`, `230d97a`, `7235260`)
|
||||
- Dogfood loop declared bundle "merge-ready" at 14:25
|
||||
- ~40 min elapsed; no PR opened, no merge, branch still unmerged
|
||||
- Meanwhile #136 and #137 landed directly on main (`a8beca1`, `21adae9`) without going through the branch
|
||||
|
||||
**Direct verification of #135 on main:**
|
||||
- `env -i $BIN status --output-format json` on main HEAD `768c1ab` shows `active_session: null, session_id: null`
|
||||
- Fields exist in JSON schema (added by schema-only?) but values are None because the producer plumbing (`#134`) is not on main
|
||||
- #135 consumer relies on #134 producer; both live on feat/134-135 only
|
||||
|
||||
**Impact:**
|
||||
- `claw status --output-format json` on main returns JSON without the #135 session identity signals (because they're only on feat/134-135)
|
||||
- Orchestrators that shipped using the 13:00 "round-trip proof" report believing #134+#135 was merge-ready will get null fields
|
||||
- Evidence for #138: "closure-state" = "pushed branch" ≠ "merged" ≠ "in-PR" — nudge surface collapses all three
|
||||
|
||||
**Proposed closure-state transition:**
|
||||
1. `pushed` — branch exists on origin but no PR (current state for feat/134-135)
|
||||
2. `in-PR` — PR open, review pending
|
||||
3. `approved` — PR approved, awaiting merge
|
||||
4. `merged` — in main
|
||||
5. `deployed` — if applicable
|
||||
6. `abandoned` — PR closed without merge
|
||||
|
||||
Nudge surface should report explicit state + timestamp: `"feat/134-135 state=pushed (no PR) since 13:00; no closure action taken"` instead of ambiguous "merge-ready."
|
||||
|
||||
**Token/permission note:**
|
||||
- `code-yeongyu` token has write access to push branches to `ultraworkers/claw-code` but lacks `createPullRequest` permission (GraphQL 404)
|
||||
- Issues are disabled on the repo (can't open issue-based tracking)
|
||||
- Means closure-state tracking must live inside the repo (ROADMAP) or in an external surface (Discord message edits, `.dogfood-closure.json`)
|
||||
|
||||
**Filed:** 2026-04-21 15:05 KST as evidence for #138 by Jobdori dogfood loop.
|
||||
|
||||
@@ -244,7 +244,6 @@ pub struct LaneEventBuilder {
|
||||
event: LaneEventName,
|
||||
status: LaneEventStatus,
|
||||
emitted_at: String,
|
||||
session_id: Option<String>,
|
||||
metadata: LaneEventMetadata,
|
||||
detail: Option<String>,
|
||||
failure_class: Option<LaneFailureClass>,
|
||||
@@ -265,7 +264,6 @@ impl LaneEventBuilder {
|
||||
event,
|
||||
status,
|
||||
emitted_at: emitted_at.into(),
|
||||
session_id: None,
|
||||
metadata: LaneEventMetadata::new(seq, provenance),
|
||||
detail: None,
|
||||
failure_class: None,
|
||||
@@ -280,13 +278,6 @@ impl LaneEventBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Add boot-scoped session correlation id
|
||||
#[must_use]
|
||||
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
|
||||
self.session_id = Some(session_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add ownership info
|
||||
#[must_use]
|
||||
pub fn with_ownership(mut self, ownership: LaneOwnership) -> Self {
|
||||
@@ -337,7 +328,6 @@ impl LaneEventBuilder {
|
||||
event: self.event,
|
||||
status: self.status,
|
||||
emitted_at: self.emitted_at,
|
||||
session_id: self.session_id,
|
||||
failure_class: self.failure_class,
|
||||
detail: self.detail,
|
||||
data: self.data,
|
||||
@@ -415,10 +405,7 @@ pub enum BlockedSubphase {
|
||||
#[serde(rename = "blocked.branch_freshness")]
|
||||
BranchFreshness { behind_main: u32 },
|
||||
#[serde(rename = "blocked.test_hang")]
|
||||
TestHang {
|
||||
elapsed_secs: u32,
|
||||
test_name: Option<String>,
|
||||
},
|
||||
TestHang { elapsed_secs: u32, test_name: Option<String> },
|
||||
#[serde(rename = "blocked.report_pending")]
|
||||
ReportPending { since_secs: u32 },
|
||||
}
|
||||
@@ -475,8 +462,6 @@ pub struct LaneEvent {
|
||||
pub status: LaneEventStatus,
|
||||
#[serde(rename = "emittedAt")]
|
||||
pub emitted_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
#[serde(rename = "failureClass", skip_serializing_if = "Option::is_none")]
|
||||
pub failure_class: Option<LaneFailureClass>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -500,7 +485,6 @@ impl LaneEvent {
|
||||
event,
|
||||
status,
|
||||
emitted_at: emitted_at.into(),
|
||||
session_id: None,
|
||||
failure_class: None,
|
||||
detail: None,
|
||||
data: None,
|
||||
@@ -559,8 +543,7 @@ impl LaneEvent {
|
||||
.with_failure_class(blocker.failure_class)
|
||||
.with_detail(blocker.detail.clone());
|
||||
if let Some(ref subphase) = blocker.subphase {
|
||||
event =
|
||||
event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
||||
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
||||
}
|
||||
event
|
||||
}
|
||||
@@ -571,8 +554,7 @@ impl LaneEvent {
|
||||
.with_failure_class(blocker.failure_class)
|
||||
.with_detail(blocker.detail.clone());
|
||||
if let Some(ref subphase) = blocker.subphase {
|
||||
event =
|
||||
event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
||||
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
|
||||
}
|
||||
event
|
||||
}
|
||||
@@ -580,12 +562,8 @@ impl LaneEvent {
|
||||
/// Ship prepared — §4.44.5
|
||||
#[must_use]
|
||||
pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||
Self::new(
|
||||
LaneEventName::ShipPrepared,
|
||||
LaneEventStatus::Ready,
|
||||
emitted_at,
|
||||
)
|
||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||
Self::new(LaneEventName::ShipPrepared, LaneEventStatus::Ready, emitted_at)
|
||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||
}
|
||||
|
||||
/// Ship commits selected — §4.44.5
|
||||
@@ -595,34 +573,22 @@ impl LaneEvent {
|
||||
commit_count: u32,
|
||||
commit_range: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
LaneEventName::ShipCommitsSelected,
|
||||
LaneEventStatus::Ready,
|
||||
emitted_at,
|
||||
)
|
||||
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
|
||||
Self::new(LaneEventName::ShipCommitsSelected, LaneEventStatus::Ready, emitted_at)
|
||||
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
|
||||
}
|
||||
|
||||
/// Ship merged — §4.44.5
|
||||
#[must_use]
|
||||
pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||
Self::new(
|
||||
LaneEventName::ShipMerged,
|
||||
LaneEventStatus::Completed,
|
||||
emitted_at,
|
||||
)
|
||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||
Self::new(LaneEventName::ShipMerged, LaneEventStatus::Completed, emitted_at)
|
||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||
}
|
||||
|
||||
/// Ship pushed to main — §4.44.5
|
||||
#[must_use]
|
||||
pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
|
||||
Self::new(
|
||||
LaneEventName::ShipPushedMain,
|
||||
LaneEventStatus::Completed,
|
||||
emitted_at,
|
||||
)
|
||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||
Self::new(LaneEventName::ShipPushedMain, LaneEventStatus::Completed, emitted_at)
|
||||
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -648,12 +614,6 @@ impl LaneEvent {
|
||||
self.data = Some(data);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
|
||||
self.session_id = Some(session_id.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -1084,7 +1044,6 @@ mod tests {
|
||||
42,
|
||||
EventProvenance::Test,
|
||||
)
|
||||
.with_session_id("boot-abc123def4567890")
|
||||
.with_session_identity(SessionIdentity::new("test-lane", "/tmp", "test"))
|
||||
.with_ownership(LaneOwnership {
|
||||
owner: "bot-1".to_string(),
|
||||
@@ -1096,7 +1055,6 @@ mod tests {
|
||||
.build();
|
||||
|
||||
assert_eq!(event.event, LaneEventName::Started);
|
||||
assert_eq!(event.session_id.as_deref(), Some("boot-abc123def4567890"));
|
||||
assert_eq!(event.metadata.seq, 42);
|
||||
assert_eq!(event.metadata.provenance, EventProvenance::Test);
|
||||
assert_eq!(
|
||||
@@ -1126,34 +1084,4 @@ mod tests {
|
||||
assert_eq!(round_trip.provenance, EventProvenance::Healthcheck);
|
||||
assert_eq!(round_trip.nudge_id, Some("nudge-abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lane_event_session_id_round_trips_through_serialization() {
|
||||
let event = LaneEventBuilder::new(
|
||||
LaneEventName::Started,
|
||||
LaneEventStatus::Running,
|
||||
"2026-04-04T00:00:00Z",
|
||||
1,
|
||||
EventProvenance::LiveLane,
|
||||
)
|
||||
.with_session_id("boot-0123456789abcdef")
|
||||
.build();
|
||||
|
||||
let json = serde_json::to_value(&event).expect("should serialize");
|
||||
assert_eq!(json["session_id"], "boot-0123456789abcdef");
|
||||
|
||||
let round_trip: LaneEvent = serde_json::from_value(json).expect("should deserialize");
|
||||
assert_eq!(
|
||||
round_trip.session_id.as_deref(),
|
||||
Some("boot-0123456789abcdef")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lane_event_session_id_omits_field_when_absent() {
|
||||
let event = LaneEvent::started("2026-04-04T00:00:00Z");
|
||||
let json = serde_json::to_value(&event).expect("should serialize");
|
||||
|
||||
assert!(json.get("session_id").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ mod remote;
|
||||
pub mod sandbox;
|
||||
mod session;
|
||||
pub mod session_control;
|
||||
mod session_identity;
|
||||
pub use session_control::SessionStore;
|
||||
mod sse;
|
||||
pub mod stale_base;
|
||||
@@ -154,9 +153,6 @@ pub use session::{
|
||||
ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError,
|
||||
SessionFork, SessionPromptEntry,
|
||||
};
|
||||
pub use session_identity::{
|
||||
begin_session, current_boot_session_id, end_session, is_active_session,
|
||||
};
|
||||
pub use sse::{IncrementalSseParser, SseEvent};
|
||||
pub use stale_base::{
|
||||
check_base_commit, format_stale_base_warning, read_claw_base_file, resolve_expected_base,
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::env;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::process;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static BOOT_SESSION_ID: OnceLock<String> = OnceLock::new();
|
||||
static BOOT_SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
static ACTIVE_SESSION: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[must_use]
|
||||
pub fn current_boot_session_id() -> &'static str {
|
||||
BOOT_SESSION_ID.get_or_init(resolve_boot_session_id)
|
||||
}
|
||||
|
||||
pub fn begin_session() {
|
||||
ACTIVE_SESSION.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn end_session() {
|
||||
ACTIVE_SESSION.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_active_session() -> bool {
|
||||
ACTIVE_SESSION.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn resolve_boot_session_id() -> String {
|
||||
match env::var("CLAW_SESSION_ID") {
|
||||
Ok(value) if !value.trim().is_empty() => value,
|
||||
_ => generate_boot_session_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_boot_session_id() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let counter = BOOT_SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let mut hasher = DefaultHasher::new();
|
||||
process::id().hash(&mut hasher);
|
||||
nanos.hash(&mut hasher);
|
||||
counter.hash(&mut hasher);
|
||||
format!("boot-{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{begin_session, current_boot_session_id, end_session, is_active_session};
|
||||
|
||||
#[test]
|
||||
fn given_current_boot_session_id_when_called_twice_then_it_is_stable() {
|
||||
let first = current_boot_session_id();
|
||||
let second = current_boot_session_id();
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert!(first.starts_with("boot-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_current_boot_session_id_when_inspected_then_it_is_opaque_and_non_empty() {
|
||||
let session_id = current_boot_session_id();
|
||||
|
||||
assert!(!session_id.trim().is_empty());
|
||||
assert_eq!(session_id.len(), 21);
|
||||
assert!(!session_id.contains(' '));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn given_begin_and_end_session_when_checked_then_active_state_toggles() {
|
||||
end_session();
|
||||
assert!(!is_active_session());
|
||||
|
||||
begin_session();
|
||||
assert!(is_active_session());
|
||||
|
||||
end_session();
|
||||
assert!(!is_active_session());
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::current_boot_session_id;
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -770,7 +768,6 @@ fn push_event(
|
||||
#[derive(serde::Serialize)]
|
||||
struct StateSnapshot<'a> {
|
||||
worker_id: &'a str,
|
||||
session_id: &'a str,
|
||||
status: WorkerStatus,
|
||||
is_ready: bool,
|
||||
trust_gate_cleared: bool,
|
||||
@@ -793,7 +790,6 @@ fn emit_state_file(worker: &Worker) {
|
||||
let now = now_secs();
|
||||
let snapshot = StateSnapshot {
|
||||
worker_id: &worker.worker_id,
|
||||
session_id: current_boot_session_id(),
|
||||
status: worker.status,
|
||||
is_ready: worker.status == WorkerStatus::ReadyForPrompt,
|
||||
trust_gate_cleared: worker.trust_gate_cleared,
|
||||
@@ -1453,10 +1449,6 @@ mod tests {
|
||||
Some("spawning"),
|
||||
"initial status should be spawning"
|
||||
);
|
||||
assert_eq!(
|
||||
value["session_id"].as_str(),
|
||||
Some(current_boot_session_id())
|
||||
);
|
||||
assert_eq!(value["is_ready"].as_bool(), Some(false));
|
||||
|
||||
// Transition to ReadyForPrompt by observing trust-cleared text
|
||||
|
||||
@@ -707,17 +707,32 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
reasoning_effort,
|
||||
allow_broad_cwd,
|
||||
),
|
||||
_other => Ok(CliAction::Prompt {
|
||||
prompt: rest.join(" "),
|
||||
model,
|
||||
output_format,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
compact,
|
||||
base_commit,
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
allow_broad_cwd,
|
||||
}),
|
||||
other => {
|
||||
if rest.len() == 1 && looks_like_subcommand_typo(other) {
|
||||
if let Some(suggestions) = suggest_similar_subcommand(other) {
|
||||
let mut message = format!("unknown subcommand: {other}.");
|
||||
if let Some(line) = render_suggestion_line("Did you mean", &suggestions) {
|
||||
message.push('\n');
|
||||
message.push_str(&line);
|
||||
}
|
||||
message.push_str(
|
||||
"\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt <text>`.",
|
||||
);
|
||||
return Err(message);
|
||||
}
|
||||
}
|
||||
Ok(CliAction::Prompt {
|
||||
prompt: rest.join(" "),
|
||||
model,
|
||||
output_format,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
compact,
|
||||
base_commit,
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
allow_broad_cwd,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,6 +1009,65 @@ fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'
|
||||
ranked_suggestions(input, candidates).into_iter().next()
|
||||
}
|
||||
|
||||
|
||||
fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
|
||||
const KNOWN_SUBCOMMANDS: &[&str] = &[
|
||||
"help",
|
||||
"version",
|
||||
"status",
|
||||
"sandbox",
|
||||
"doctor",
|
||||
"state",
|
||||
"dump-manifests",
|
||||
"bootstrap-plan",
|
||||
"agents",
|
||||
"mcp",
|
||||
"skills",
|
||||
"system-prompt",
|
||||
"acp",
|
||||
"init",
|
||||
"export",
|
||||
"prompt",
|
||||
];
|
||||
|
||||
let normalized_input = input.to_ascii_lowercase();
|
||||
let mut ranked = KNOWN_SUBCOMMANDS
|
||||
.iter()
|
||||
.filter_map(|candidate| {
|
||||
let normalized_candidate = candidate.to_ascii_lowercase();
|
||||
let distance = levenshtein_distance(&normalized_input, &normalized_candidate);
|
||||
let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4;
|
||||
let substring_match = normalized_candidate.contains(&normalized_input)
|
||||
|| normalized_input.contains(&normalized_candidate);
|
||||
((distance <= 2) || prefix_match || substring_match)
|
||||
.then_some((distance, *candidate))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
|
||||
ranked.dedup_by(|left, right| left.1 == right.1);
|
||||
let suggestions = ranked
|
||||
.into_iter()
|
||||
.map(|(_, candidate)| candidate.to_string())
|
||||
.take(3)
|
||||
.collect::<Vec<_>>();
|
||||
(!suggestions.is_empty()).then_some(suggestions)
|
||||
}
|
||||
|
||||
fn common_prefix_len(left: &str, right: &str) -> usize {
|
||||
left.chars()
|
||||
.zip(right.chars())
|
||||
.take_while(|(l, r)| l == r)
|
||||
.count()
|
||||
}
|
||||
|
||||
|
||||
fn looks_like_subcommand_typo(input: &str) -> bool {
|
||||
!input.is_empty()
|
||||
&& input
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphabetic() || ch == '-')
|
||||
}
|
||||
|
||||
fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> {
|
||||
let normalized_input = input.trim_start_matches('/').to_ascii_lowercase();
|
||||
let mut ranked = candidates
|
||||
@@ -1556,8 +1630,6 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
project_root,
|
||||
git_branch,
|
||||
git_summary,
|
||||
active_session: false,
|
||||
session_id: None,
|
||||
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
|
||||
};
|
||||
Ok(DoctorReport {
|
||||
@@ -2378,8 +2450,6 @@ struct ResumeCommandOutcome {
|
||||
struct StatusContext {
|
||||
cwd: PathBuf,
|
||||
session_path: Option<PathBuf>,
|
||||
active_session: bool,
|
||||
session_id: Option<String>,
|
||||
loaded_config_files: usize,
|
||||
discovered_config_files: usize,
|
||||
memory_file_count: usize,
|
||||
@@ -2389,16 +2459,6 @@ struct StatusContext {
|
||||
sandbox_status: runtime::SandboxStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WorkerStateSnapshot {
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
prompt_in_flight: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct StatusUsage {
|
||||
message_count: usize,
|
||||
@@ -3884,6 +3944,7 @@ impl LiveCli {
|
||||
compact: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match output_format {
|
||||
CliOutputFormat::Json if compact => self.run_prompt_compact_json(input),
|
||||
CliOutputFormat::Text if compact => self.run_prompt_compact(input),
|
||||
CliOutputFormat::Text => self.run_turn(input),
|
||||
CliOutputFormat::Json => self.run_prompt_json(input),
|
||||
@@ -3903,6 +3964,32 @@ impl LiveCli {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
let summary = result?;
|
||||
self.replace_runtime(runtime)?;
|
||||
self.persist_session()?;
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"message": final_assistant_text(&summary),
|
||||
"compact": true,
|
||||
"model": self.model,
|
||||
"usage": {
|
||||
"input_tokens": summary.usage.input_tokens,
|
||||
"output_tokens": summary.usage.output_tokens,
|
||||
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
|
||||
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
|
||||
},
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
@@ -5007,8 +5094,6 @@ fn status_json_value(
|
||||
"kind": "status",
|
||||
"model": model,
|
||||
"permission_mode": permission_mode,
|
||||
"active_session": context.active_session,
|
||||
"session_id": context.session_id,
|
||||
"usage": {
|
||||
"messages": usage.message_count,
|
||||
"turns": usage.turns,
|
||||
@@ -5067,12 +5152,9 @@ fn status_context(
|
||||
parse_git_status_metadata(project_context.git_status.as_deref());
|
||||
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
|
||||
let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd);
|
||||
let worker_state = read_worker_state_snapshot(&cwd);
|
||||
Ok(StatusContext {
|
||||
cwd,
|
||||
session_path: session_path.map(Path::to_path_buf),
|
||||
active_session: worker_state.as_ref().is_some_and(worker_state_is_active),
|
||||
session_id: worker_state.and_then(|snapshot| snapshot.session_id),
|
||||
loaded_config_files: runtime_config.loaded_entries().len(),
|
||||
discovered_config_files,
|
||||
memory_file_count: project_context.instruction_files.len(),
|
||||
@@ -5083,20 +5165,6 @@ fn status_context(
|
||||
})
|
||||
}
|
||||
|
||||
fn read_worker_state_snapshot(cwd: &Path) -> Option<WorkerStateSnapshot> {
|
||||
let state_path = cwd.join(".claw").join("worker-state.json");
|
||||
let raw = fs::read_to_string(state_path).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
fn worker_state_is_active(snapshot: &WorkerStateSnapshot) -> bool {
|
||||
snapshot.prompt_in_flight
|
||||
|| matches!(
|
||||
snapshot.status.as_deref(),
|
||||
Some("spawning" | "trust_required" | "ready_for_prompt" | "running")
|
||||
)
|
||||
}
|
||||
|
||||
fn format_status_report(
|
||||
model: &str,
|
||||
usage: StatusUsage,
|
||||
@@ -5150,7 +5218,7 @@ fn format_status_report(
|
||||
context.git_summary.unstaged_files,
|
||||
context.git_summary.untracked_files,
|
||||
context.session_path.as_ref().map_or_else(
|
||||
|| format_active_session(context),
|
||||
|| "live-repl".to_string(),
|
||||
|path| path.display().to_string()
|
||||
),
|
||||
context.loaded_config_files,
|
||||
@@ -5166,17 +5234,6 @@ fn format_status_report(
|
||||
)
|
||||
}
|
||||
|
||||
fn format_active_session(context: &StatusContext) -> String {
|
||||
if context.active_session {
|
||||
match context.session_id.as_deref() {
|
||||
Some(session_id) => format!("active ({session_id})"),
|
||||
None => "active".to_string(),
|
||||
}
|
||||
} else {
|
||||
"idle".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_sandbox_report(status: &runtime::SandboxStatus) -> String {
|
||||
format!(
|
||||
"Sandbox
|
||||
@@ -9918,6 +9975,109 @@ mod tests {
|
||||
assert!(report.contains("Use /help"));
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn typoed_doctor_subcommand_returns_did_you_mean_error() {
|
||||
let error = parse_args(&["doctorr".to_string()]).expect_err("doctorr should error");
|
||||
assert!(error.contains("unknown subcommand: doctorr."));
|
||||
assert!(error.contains("Did you mean"));
|
||||
assert!(error.contains("doctor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typoed_skills_subcommand_returns_did_you_mean_error() {
|
||||
let error = parse_args(&["skilsl".to_string()]).expect_err("skilsl should error");
|
||||
assert!(error.contains("unknown subcommand: skilsl."));
|
||||
assert!(error.contains("skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typoed_status_subcommand_returns_did_you_mean_error() {
|
||||
let error = parse_args(&["statuss".to_string()]).expect_err("statuss should error");
|
||||
assert!(error.contains("unknown subcommand: statuss."));
|
||||
assert!(error.contains("status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typoed_export_subcommand_returns_did_you_mean_error() {
|
||||
let error = parse_args(&["exporrt".to_string()]).expect_err("exporrt should error");
|
||||
assert!(error.contains("unknown subcommand: exporrt."));
|
||||
assert!(error.contains("Did you mean"));
|
||||
assert!(error.contains("export"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typoed_mcp_subcommand_returns_did_you_mean_error() {
|
||||
let error = parse_args(&["mcpp".to_string()]).expect_err("mcpp should error");
|
||||
assert!(error.contains("unknown subcommand: mcpp."));
|
||||
assert!(error.contains("mcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_word_prompt_still_bypasses_subcommand_typo_guard() {
|
||||
assert_eq!(
|
||||
parse_args(&[
|
||||
"hello".to_string(),
|
||||
"world".to_string(),
|
||||
"this".to_string(),
|
||||
"is".to_string(),
|
||||
"a".to_string(),
|
||||
"prompt".to_string(),
|
||||
])
|
||||
.expect("multi-word prompt should still parse"),
|
||||
CliAction::Prompt {
|
||||
prompt: "hello world this is a prompt".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: crate::default_permission_mode(),
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
allow_broad_cwd: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_subcommand_allows_literal_typo_word() {
|
||||
assert_eq!(
|
||||
parse_args(&["prompt".to_string(), "doctorr".to_string()])
|
||||
.expect("explicit prompt subcommand should allow literal typo word"),
|
||||
CliAction::Prompt {
|
||||
prompt: "doctorr".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
allow_broad_cwd: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn punctuation_bearing_single_token_still_dispatches_to_prompt() {
|
||||
assert_eq!(
|
||||
parse_args(&["PARITY_SCENARIO:bash_permission_prompt_approved".to_string()])
|
||||
.expect("scenario token should still dispatch to prompt"),
|
||||
CliAction::Prompt {
|
||||
prompt: "PARITY_SCENARIO:bash_permission_prompt_approved".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
allow_broad_cwd: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_namespaced_omc_slash_command_with_contract_guidance() {
|
||||
let report = format_unknown_slash_command_message("oh-my-claudecode:hud");
|
||||
@@ -10396,8 +10556,6 @@ mod tests {
|
||||
&super::StatusContext {
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
session_path: Some(PathBuf::from("session.jsonl")),
|
||||
active_session: true,
|
||||
session_id: Some("boot-status-test".to_string()),
|
||||
loaded_config_files: 2,
|
||||
discovered_config_files: 3,
|
||||
memory_file_count: 4,
|
||||
@@ -10426,10 +10584,10 @@ mod tests {
|
||||
status.contains("Git state dirty · 3 files · 1 staged, 1 unstaged, 1 untracked")
|
||||
);
|
||||
assert!(status.contains("Changed files 3"));
|
||||
assert!(status.contains("Session session.jsonl"));
|
||||
assert!(status.contains("Staged 1"));
|
||||
assert!(status.contains("Unstaged 1"));
|
||||
assert!(status.contains("Untracked 1"));
|
||||
assert!(status.contains("Session session.jsonl"));
|
||||
assert!(status.contains("Config files loaded 2/3"));
|
||||
assert!(status.contains("Memory files 4"));
|
||||
assert!(status.contains("Suggested flow /status → /diff → /commit"));
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
|
||||
use serde_json::Value;
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
@@ -125,6 +126,60 @@ fn compact_flag_streaming_text_only_emits_final_message_text() {
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_flag_with_json_output_emits_structured_json() {
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("compact-json");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"--output-format",
|
||||
"json",
|
||||
"--compact",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"compact json run should succeed
|
||||
stdout:
|
||||
{}
|
||||
|
||||
stderr:
|
||||
{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse");
|
||||
assert_eq!(parsed["message"], "Mock streaming says hello from the parity harness.");
|
||||
assert_eq!(parsed["compact"], true);
|
||||
assert_eq!(parsed["model"], "claude-sonnet-4-6");
|
||||
assert!(parsed["usage"].is_object());
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
fn run_claw(
|
||||
cwd: &std::path::Path,
|
||||
config_home: &std::path::Path,
|
||||
|
||||
@@ -39,8 +39,6 @@ fn status_and_sandbox_emit_json_when_requested() {
|
||||
|
||||
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
|
||||
assert_eq!(status["kind"], "status");
|
||||
assert_eq!(status["active_session"], false);
|
||||
assert!(status["session_id"].is_null());
|
||||
assert!(status["workspace"]["cwd"].as_str().is_some());
|
||||
|
||||
let sandbox = assert_json_command(&root, &["--output-format", "json", "sandbox"]);
|
||||
@@ -386,47 +384,6 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
|
||||
assert!(root.join("CLAUDE.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_json_surfaces_active_session_and_boot_session_id_from_worker_state() {
|
||||
let root = unique_temp_dir("status-worker-state-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
write_worker_state_fixture(&root, "running", "boot-fixture-123");
|
||||
|
||||
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
|
||||
assert_eq!(status["kind"], "status");
|
||||
assert_eq!(status["active_session"], true);
|
||||
assert_eq!(status["session_id"], "boot-fixture-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_text_surfaces_active_session_and_boot_session_id_from_worker_state() {
|
||||
let root = unique_temp_dir("status-worker-state-text");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
write_worker_state_fixture(&root, "running", "boot-fixture-456");
|
||||
|
||||
let output = run_claw(&root, &["status"], &[]);
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("Session active (boot-fixture-456)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_state_fixture_round_trips_session_id_across_status_surface() {
|
||||
let root = unique_temp_dir("status-worker-state-roundtrip");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
let session_id = "boot-roundtrip-789";
|
||||
write_worker_state_fixture(&root, "running", session_id);
|
||||
|
||||
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
|
||||
assert_eq!(status["active_session"], true);
|
||||
assert_eq!(status["session_id"], session_id);
|
||||
|
||||
let raw = fs::read_to_string(root.join(".claw").join("worker-state.json"))
|
||||
.expect("worker state should exist");
|
||||
let state: Value = serde_json::from_str(&raw).expect("worker state should be valid json");
|
||||
assert_eq!(state["session_id"], session_id);
|
||||
}
|
||||
|
||||
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||
assert_json_command_with_env(current_dir, args, &[])
|
||||
}
|
||||
@@ -474,26 +431,6 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
|
||||
upstream
|
||||
}
|
||||
|
||||
fn write_worker_state_fixture(root: &Path, status: &str, session_id: &str) {
|
||||
let claw_dir = root.join(".claw");
|
||||
fs::create_dir_all(&claw_dir).expect("worker state dir should exist");
|
||||
fs::write(
|
||||
claw_dir.join("worker-state.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"worker_id": "worker-test",
|
||||
"session_id": session_id,
|
||||
"status": status,
|
||||
"is_ready": status == "ready_for_prompt",
|
||||
"trust_gate_cleared": false,
|
||||
"prompt_in_flight": status == "running",
|
||||
"updated_at": 1,
|
||||
"seconds_since_update": 0
|
||||
}))
|
||||
.expect("worker state json should serialize"),
|
||||
)
|
||||
.expect("worker state fixture should write");
|
||||
}
|
||||
|
||||
fn write_session_fixture(root: &Path, session_id: &str, user_text: Option<&str>) -> PathBuf {
|
||||
let session_path = root.join("session.jsonl");
|
||||
let mut session = Session::new()
|
||||
|
||||
@@ -11,8 +11,8 @@ use api::{
|
||||
use plugins::PluginTool;
|
||||
use reqwest::blocking::Client;
|
||||
use runtime::{
|
||||
check_freshness, current_boot_session_id, dedupe_superseded_commit_events, edit_file,
|
||||
execute_bash, glob_search, grep_search, load_system_prompt,
|
||||
check_freshness, dedupe_superseded_commit_events, edit_file, execute_bash, glob_search,
|
||||
grep_search, load_system_prompt,
|
||||
lsp_client::LspRegistry,
|
||||
mcp_tool_bridge::McpToolRegistry,
|
||||
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
||||
@@ -3535,9 +3535,7 @@ where
|
||||
created_at: created_at.clone(),
|
||||
started_at: Some(created_at),
|
||||
completed_at: None,
|
||||
lane_events: vec![
|
||||
LaneEvent::started(iso8601_now()).with_session_id(current_boot_session_id())
|
||||
],
|
||||
lane_events: vec![LaneEvent::started(iso8601_now())],
|
||||
current_blocker: None,
|
||||
derived_state: String::from("working"),
|
||||
error: None,
|
||||
@@ -3746,11 +3744,6 @@ fn persist_agent_terminal_state(
|
||||
error: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let blocker = error.as_deref().map(classify_lane_blocker);
|
||||
let session_id = manifest
|
||||
.lane_events
|
||||
.last()
|
||||
.and_then(|event| event.session_id.clone())
|
||||
.unwrap_or_else(|| current_boot_session_id().to_string());
|
||||
append_agent_output(
|
||||
&manifest.output_file,
|
||||
&format_agent_terminal_output(status, result, blocker.as_ref(), error.as_deref()),
|
||||
@@ -3765,31 +3758,26 @@ fn persist_agent_terminal_state(
|
||||
if let Some(blocker) = blocker {
|
||||
next_manifest
|
||||
.lane_events
|
||||
.push(LaneEvent::blocked(iso8601_now(), &blocker).with_session_id(session_id.clone()));
|
||||
.push(LaneEvent::blocked(iso8601_now(), &blocker));
|
||||
next_manifest
|
||||
.lane_events
|
||||
.push(LaneEvent::failed(iso8601_now(), &blocker).with_session_id(session_id.clone()));
|
||||
.push(LaneEvent::failed(iso8601_now(), &blocker));
|
||||
} else {
|
||||
next_manifest.current_blocker = None;
|
||||
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)
|
||||
.expect("lane summary metadata should serialize"),
|
||||
)
|
||||
.with_session_id(session_id.clone()),
|
||||
LaneEvent::finished(iso8601_now(), finished_summary.detail).with_data(
|
||||
serde_json::to_value(&finished_summary.data)
|
||||
.expect("lane summary metadata should serialize"),
|
||||
),
|
||||
);
|
||||
if let Some(provenance) = maybe_commit_provenance(result) {
|
||||
next_manifest.lane_events.push(
|
||||
LaneEvent::commit_created(
|
||||
iso8601_now(),
|
||||
Some(format!("commit {}", provenance.commit)),
|
||||
provenance,
|
||||
)
|
||||
.with_session_id(session_id),
|
||||
);
|
||||
next_manifest.lane_events.push(LaneEvent::commit_created(
|
||||
iso8601_now(),
|
||||
Some(format!("commit {}", provenance.commit)),
|
||||
provenance,
|
||||
));
|
||||
}
|
||||
}
|
||||
write_agent_manifest(&next_manifest)
|
||||
@@ -7773,9 +7761,6 @@ mod tests {
|
||||
assert!(manifest_contents.contains("\"status\": \"running\""));
|
||||
assert_eq!(manifest_json["laneEvents"][0]["event"], "lane.started");
|
||||
assert_eq!(manifest_json["laneEvents"][0]["status"], "running");
|
||||
assert!(manifest_json["laneEvents"][0]["session_id"]
|
||||
.as_str()
|
||||
.is_some());
|
||||
assert!(manifest_json["currentBlocker"].is_null());
|
||||
let captured_job = captured
|
||||
.lock()
|
||||
@@ -7853,17 +7838,10 @@ mod tests {
|
||||
completed_manifest_json["laneEvents"][0]["event"],
|
||||
"lane.started"
|
||||
);
|
||||
let session_id = completed_manifest_json["laneEvents"][0]["session_id"]
|
||||
.as_str()
|
||||
.expect("startup session_id should exist");
|
||||
assert_eq!(
|
||||
completed_manifest_json["laneEvents"][1]["event"],
|
||||
"lane.finished"
|
||||
);
|
||||
assert_eq!(
|
||||
completed_manifest_json["laneEvents"][1]["session_id"],
|
||||
session_id
|
||||
);
|
||||
assert_eq!(
|
||||
completed_manifest_json["laneEvents"][1]["data"]["qualityFloorApplied"],
|
||||
false
|
||||
@@ -7876,10 +7854,6 @@ mod tests {
|
||||
completed_manifest_json["laneEvents"][2]["event"],
|
||||
"lane.commit.created"
|
||||
);
|
||||
assert_eq!(
|
||||
completed_manifest_json["laneEvents"][2]["session_id"],
|
||||
session_id
|
||||
);
|
||||
assert_eq!(
|
||||
completed_manifest_json["laneEvents"][2]["data"]["commit"],
|
||||
"abc1234"
|
||||
|
||||
Reference in New Issue
Block a user