mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-26 11:35:00 +08:00
Compare commits
18 Commits
feat/jobdo
...
feat/134-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7235260c61 | ||
|
|
230d97a8fa | ||
|
|
2b7095e4ae | ||
|
|
f55612ea47 | ||
|
|
8b52e77f23 | ||
|
|
2c42f8bcc8 | ||
|
|
f266505546 | ||
|
|
50e3fa3a83 | ||
|
|
a51b2105ed | ||
|
|
a3270db602 | ||
|
|
12f1f9a74e | ||
|
|
2678fa0af5 | ||
|
|
b9990bb27c | ||
|
|
f33c315c93 | ||
|
|
5c579e4a09 | ||
|
|
8a8ca8a355 | ||
|
|
b0b579ebe9 | ||
|
|
c956f78e8a |
5
.claw.json
Normal file
5
.claw.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"aliases": {
|
||||
"quick": "haiku"
|
||||
}
|
||||
}
|
||||
87
ROADMAP.md
87
ROADMAP.md
@@ -711,6 +711,49 @@ Acceptance:
|
||||
- token-risk preflight becomes operational guidance, not just warning text
|
||||
- first-run users stop getting stuck between diagnosis and manual cleanup
|
||||
|
||||
### 4.44.5. Ship/provenance opacity — IMPLEMENTED 2026-04-20
|
||||
|
||||
**Status:** Events implemented in `lane_events.rs`. Surface now emits structured ship provenance.
|
||||
|
||||
When dogfood work lands on `main`, the delivery path (scoped branch → PR → merge → push vs direct push) and the exact commit set shipped are not surfaced as first-class events. This makes it too easy to lose the boundary between "dogfood fix landed", "what exact commits shipped", and "what review/merge path was actually used." The 56-commit push during 2026-04-20 dogfood (#122/#127/#129/#130/#131/#132) exhibited this gap: work started as scoped pinpoint branches, then collapsed into a direct `origin/main` push with no structured provenance trail.
|
||||
|
||||
**Implemented behavior:**
|
||||
- `ship.prepared` event — intent to ship established
|
||||
- `ship.commits_selected` event — commit range locked
|
||||
- `ship.merged` event — merge completed with metadata
|
||||
- `ship.pushed_main` event — delivery to main confirmed
|
||||
- All carry `ShipProvenance { source_branch, base_commit, commit_count, commit_range, merge_method, actor, pr_number }`
|
||||
- `ShipMergeMethod` enum: direct_push, fast_forward, merge_commit, squash_merge, rebase_merge
|
||||
|
||||
Required behavior:
|
||||
|
||||
When dogfood work lands on `main`, the delivery path (scoped branch → PR → merge → push vs direct push) and the exact commit set shipped are not surfaced as first-class events. This makes it too easy to lose the boundary between "dogfood fix landed", "what exact commits shipped", and "what review/merge path was actually used." The 56-commit push during 2026-04-20 dogfood (#122/#127/#129/#130/#131/#132) exhibited this gap: work started as scoped pinpoint branches, then collapsed into a direct `origin/main` push with no structured provenance trail.
|
||||
|
||||
Required behavior:
|
||||
- emit `ship.provenance` event with: source branch, merge method (PR #, direct push, fast-forward), commit range (first..last), and actor
|
||||
- distinguish `intentional.ship` (explicit deliverables like #122-#132) from `incidental.rider` (other commits in the push)
|
||||
- surface in lane events and `claw state` output
|
||||
- clawhip can report "6 pinpoints shipped, 50 riders, via direct push" without git archaeology
|
||||
|
||||
Acceptance:
|
||||
- no post-hoc human reconstruction needed to answer "what just shipped and by what path"
|
||||
- delivery path is machine-readable and auditable
|
||||
|
||||
Source: gaebal-gajae dogfood observation 2026-04-20 — the very run that exposed the gap.
|
||||
|
||||
**Incomplete gap identified 2026-04-20:**
|
||||
Schema and event constructors implemented in `lane_events.rs::ShipProvenance` and `LaneEvent::ship_*()` methods. **Missing: wiring.** Git push operations in rusty-claude-cli do not yet emit these events. When `git push origin main` executes, no `ship.prepared/commits_selected/merged/pushed_main` events are emitted to observability layer. Events remain dead code (tests-only).
|
||||
|
||||
**Next pinpoint (§4.44.5.1):** Ship event wiring
|
||||
Wire `LaneEvent::ship_*()` emission into actual git push call sites:
|
||||
1. Locate `git push origin <branch>` command execution(s) in `main.rs`, `tools/lib.rs`, or `worker_boot.rs`
|
||||
2. Intercept before/after push: emit `ship.prepared` (before merge), `ship.commits_selected` (lock range), `ship.merged` (after merge), `ship.pushed_main` (after push to origin/main)
|
||||
3. Capture real metadata: `source_branch`, `commit_range`, `merge_method`, `actor`, `pr_number`
|
||||
4. Route events to lane event stream
|
||||
5. Verify `claw state` output surfaces ship provenance
|
||||
|
||||
Acceptance: git push emits all 4 events with real metadata, `claw state` JSON includes `ship` provenance.
|
||||
|
||||
### 4.44. Typed-error envelope contract (Silent-state inventory roll-up)
|
||||
Claw-code currently flattens every error class — filesystem, auth, session, parse, runtime, MCP, usage — into the same lossy `{type:"error", error:"<prose>"}` envelope. Both human operators and downstream claws lose the ability to programmatically tell what operation failed, which path/resource failed, what kind of failure it was, and whether the failure is retryable, actionable, or terminal. This roll-up locks in the typed-error contract that closes the family of pinpoints currently scattered across **#102 + #129** (MCP readiness opacity), **#127 + #245** (delivery surface opacity), and **#121 + #130** (error-text-lies / errno-strips-context).
|
||||
|
||||
@@ -787,7 +830,12 @@ Acceptance:
|
||||
- channel status updates stay short and machine-grounded
|
||||
- claws stop inferring state from raw build spam
|
||||
|
||||
### 6.5. Blocked-state subphase contract
|
||||
### 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.
|
||||
|
||||
**Problem:** Currently `lane.blocked` is a single opaque state. Recovery recipes cannot distinguish trust-gate blockers from MCP handshake failures, branch freshness issues, or test hangs. All blocked lanes look the same, forcing pane-scrape triage.
|
||||
|
||||
**Concrete implementation:
|
||||
When a lane is `blocked`, also expose the exact subphase where progress stopped, rather than forcing claws to infer from logs.
|
||||
|
||||
Subphases should include at least:
|
||||
@@ -4966,3 +5014,40 @@ ear], /color [scheme], /effort [low|medium|high], /fast, /summary, /tag [label],
|
||||
**Blocker.** None. Reuses existing `stale_base` module; no new logic needed, just a missing call site.
|
||||
|
||||
**Source.** Jobdori dogfood 2026-04-20 against `/tmp/jobdori-129-mcp-cred-order` + `/tmp/stale-branch` in response to 10-min cron cycle. Confirmed: `claw doctor` on branch 5 commits behind main says "Status: ok" but `prompt` dispatch would warn "worktree HEAD does not match expected base commit." Gap is a missing invocation of the already-correct `run_stale_base_preflight()` in the `doctor` action handler. Joins **Boot preflight / doctor contract (#80–#83, #114)** family — doctor is the single machine-readable preflight surface; missing checks degrade operator trust. Also relates to **Silent-state inventory** cluster (#102/#127/#129/#245) because stale-base is a runtime truth ("my branch is behind main") that the preflight surface (doctor) does not expose.
|
||||
|
||||
## Pinpoint #135. `claw status --json` missing `active_session` boolean and `session.id` cross-reference — two surfaces that should be unified are inconsistent
|
||||
|
||||
**Gap.** `claw status --json` exposes a snapshot of the runtime state but does not include (1) a stable `session.id` field (filed as #134 — the fix from the other side is to emit it in lane events; the consumer side needs it queryable via `status` too) and (2) an `active_session: bool` that tells an orchestrator whether the runtime currently has a live session in flight. An external orchestrator (Clawhip, remote agent) running `claw status --json` after sending a prompt has no machine-readable way to confirm whether the session is alive, idle, or stalled without parsing log output.
|
||||
|
||||
**Trace path.**
|
||||
- `claw status --json` (dispatcher in `main.rs` `CliAction::Status`) renders a `StatusReport` struct that includes `git_state`, `config`, `model`, `provider` — but no `session_id` or `active_session` fields.
|
||||
- `claw status` (text mode) also omits both.
|
||||
- The `session.id` fix from #134 introduces a UUID at session init; it should be threaded through to `StatusReport` so the round-trip is complete: emit on startup event → queryable via `status --json` → correlatable in lane events.
|
||||
|
||||
**Fix shape (~30 lines).**
|
||||
1. Add `session_id: Option<String>` and `active_session: bool` to `StatusReport` struct. Both `null`/`false` when no session is active. When a session is running, `session_id` is the same UUID emitted in the startup lane event (#134).
|
||||
2. Thread the session state into the `status` handler via a shared `Arc<Mutex<SessionState>>` or equivalent (same mechanism #134 uses for startup event emission).
|
||||
3. Text-mode `claw status` surfaces the value: `Session: active (id: abc123)` or `Session: idle`.
|
||||
4. Regression tests: (a) `claw status --json` before any prompt → `active_session: false, session_id: null`. (b) `claw status --json` during a prompt session → `active_session: true, session_id: <uuid>`. (c) UUID matches the `session.id` in the first lane event of the same run.
|
||||
|
||||
**Acceptance.** An orchestrator can poll `claw status --json` and determine: is there a live session? What is its correlation ID? Does it match the ID from the last startup event? This closes the round-trip opened by #134.
|
||||
|
||||
**Blocker.** Depends on #134 (session.id generation at init). Can be filed and implemented together.
|
||||
|
||||
**Source.** Jobdori dogfood 2026-04-21 06:53 KST on main HEAD `2c42f8b` during recurring cron cycle. Direct sibling of #134 — #134 covers the event-emission side, #135 covers the query side. Joins **Session identity completeness** (§4.7) and **status surface completeness** cluster (#80/#83/#114/#122). Natural bundle: **#134 + #135** closes the full session-identity round-trip. Session tally: ROADMAP #135.
|
||||
|
||||
## Pinpoint #134. No run/correlation ID at session boundary — every observer must infer session identity from timing or prompt content
|
||||
|
||||
**Gap.** When a `claw` session starts, no stable correlation ID is emitted in the first structured event (or any event). Every observer — lane event consumer, log aggregator, Clawhip router, test harness — has to infer session identity from timing proximity or prompt content. If two sessions start in close succession there is no unambiguous way to attribute subsequent events to the correct session. `claw status --json` returns session metadata but does not expose an opaque stable ID that could be used as a correlation key across the event stream.
|
||||
|
||||
**Fix shape.**
|
||||
- Emit `session.id` (opaque, stable, scoped to this boot) in the first structured event at startup
|
||||
- Include same ID in all subsequent lane events as `session_id` field
|
||||
- Expose via `claw status --json` so callers can retrieve the active session's ID from outside
|
||||
- Add regression: golden-fixture asserting `session.id` is present in startup event and value matches across a multi-event trace
|
||||
|
||||
**Acceptance.** Any observer can correlate all events from a session using `session_id` without parsing prompt content or relying on timestamp proximity. `claw status --json` exposes the current session's ID.
|
||||
|
||||
**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.
|
||||
|
||||
9
USAGE.md
9
USAGE.md
@@ -43,6 +43,15 @@ cd rust
|
||||
/doctor
|
||||
```
|
||||
|
||||
Or run doctor directly with JSON output for scripting:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
./target/debug/claw doctor --output-format json
|
||||
```
|
||||
|
||||
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
```bash
|
||||
|
||||
5
rust/.claw.json
Normal file
5
rust/.claw.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"permissions": {
|
||||
"defaultMode": "dontAsk"
|
||||
}
|
||||
}
|
||||
4
rust/.gitignore
vendored
4
rust/.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
target/
|
||||
.omx/
|
||||
.clawd-agents/
|
||||
# Claw Code local artifacts
|
||||
.claw/settings.local.json
|
||||
.claw/sessions/
|
||||
.clawhip/
|
||||
|
||||
15
rust/CLAUDE.md
Normal file
15
rust/CLAUDE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.
|
||||
|
||||
## Detected stack
|
||||
- Languages: Rust.
|
||||
- Frameworks: none detected from the supported starter markers.
|
||||
|
||||
## Verification
|
||||
- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
|
||||
|
||||
## Working agreement
|
||||
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
|
||||
- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.
|
||||
- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.
|
||||
264
rust/Cargo.lock
generated
264
rust/Cargo.lock
generated
@@ -17,10 +17,23 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"reqwest",
|
||||
"runtime",
|
||||
"serde",
|
||||
@@ -35,6 +48,12 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -77,6 +96,12 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.58"
|
||||
@@ -99,6 +124,58 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"ciborium-ll",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-io"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-ll"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -144,6 +221,67 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
dependencies = [
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
@@ -169,6 +307,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -209,6 +353,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "endian-type"
|
||||
version = "0.1.2"
|
||||
@@ -245,7 +395,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -380,12 +530,29 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
@@ -622,6 +789,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -755,6 +942,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -783,6 +979,12 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -837,6 +1039,34 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"plotters-backend",
|
||||
"plotters-svg",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters-backend"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
|
||||
|
||||
[[package]]
|
||||
name = "plotters-svg"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
|
||||
dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plugins"
|
||||
version = "0.1.0"
|
||||
@@ -1015,6 +1245,26 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -1138,7 +1388,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1522,6 +1772,16 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinytemplate"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.11.0"
|
||||
|
||||
@@ -8,6 +8,7 @@ use tokio::process::Command as TokioCommand;
|
||||
use tokio::runtime::Builder;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::lane_events::{LaneEvent, ShipMergeMethod, ShipProvenance};
|
||||
use crate::sandbox::{
|
||||
build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode,
|
||||
SandboxConfig, SandboxStatus,
|
||||
@@ -102,11 +103,76 @@ pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
|
||||
runtime.block_on(execute_bash_async(input, sandbox_status, cwd))
|
||||
}
|
||||
|
||||
/// Detect git push to main and emit ship provenance event
|
||||
fn detect_and_emit_ship_prepared(command: &str) {
|
||||
let trimmed = command.trim();
|
||||
// Simple detection: git push with main/master
|
||||
if trimmed.contains("git push") && (trimmed.contains("main") || trimmed.contains("master")) {
|
||||
// Emit ship.prepared event
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let provenance = ShipProvenance {
|
||||
source_branch: get_current_branch().unwrap_or_else(|| "unknown".to_string()),
|
||||
base_commit: get_head_commit().unwrap_or_default(),
|
||||
commit_count: 0, // Would need to calculate from range
|
||||
commit_range: "unknown..HEAD".to_string(),
|
||||
merge_method: ShipMergeMethod::DirectPush,
|
||||
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
|
||||
pr_number: None,
|
||||
};
|
||||
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
|
||||
// Log to stderr as interim routing before event stream integration
|
||||
eprintln!(
|
||||
"[ship.prepared] branch={} -> main, commits={}, actor={}",
|
||||
provenance.source_branch, provenance.commit_count, provenance.actor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_branch() -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["branch", "--show-current"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_head_commit() -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_git_actor() -> Option<String> {
|
||||
let name = Command::new("git")
|
||||
.args(["config", "user.name"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
|
||||
Some(name)
|
||||
}
|
||||
|
||||
async fn execute_bash_async(
|
||||
input: BashCommandInput,
|
||||
sandbox_status: SandboxStatus,
|
||||
cwd: std::path::PathBuf,
|
||||
) -> io::Result<BashCommandOutput> {
|
||||
// Detect and emit ship provenance for git push operations
|
||||
detect_and_emit_ship_prepared(&input.command);
|
||||
|
||||
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
|
||||
|
||||
let output_result = if let Some(timeout_ms) = input.timeout {
|
||||
|
||||
@@ -38,6 +38,15 @@ pub enum LaneEventName {
|
||||
BranchStaleAgainstMain,
|
||||
#[serde(rename = "branch.workspace_mismatch")]
|
||||
BranchWorkspaceMismatch,
|
||||
/// Ship/provenance events — §4.44.5
|
||||
#[serde(rename = "ship.prepared")]
|
||||
ShipPrepared,
|
||||
#[serde(rename = "ship.commits_selected")]
|
||||
ShipCommitsSelected,
|
||||
#[serde(rename = "ship.merged")]
|
||||
ShipMerged,
|
||||
#[serde(rename = "ship.pushed_main")]
|
||||
ShipPushedMain,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -235,6 +244,7 @@ pub struct LaneEventBuilder {
|
||||
event: LaneEventName,
|
||||
status: LaneEventStatus,
|
||||
emitted_at: String,
|
||||
session_id: Option<String>,
|
||||
metadata: LaneEventMetadata,
|
||||
detail: Option<String>,
|
||||
failure_class: Option<LaneFailureClass>,
|
||||
@@ -255,6 +265,7 @@ impl LaneEventBuilder {
|
||||
event,
|
||||
status,
|
||||
emitted_at: emitted_at.into(),
|
||||
session_id: None,
|
||||
metadata: LaneEventMetadata::new(seq, provenance),
|
||||
detail: None,
|
||||
failure_class: None,
|
||||
@@ -269,6 +280,13 @@ 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 {
|
||||
@@ -319,6 +337,7 @@ 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,
|
||||
@@ -383,11 +402,34 @@ pub fn dedupe_terminal_events(events: &[LaneEvent]) -> Vec<LaneEvent> {
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BlockedSubphase {
|
||||
#[serde(rename = "blocked.trust_prompt")]
|
||||
TrustPrompt { gate_repo: String },
|
||||
#[serde(rename = "blocked.prompt_delivery")]
|
||||
PromptDelivery { attempt: u32 },
|
||||
#[serde(rename = "blocked.plugin_init")]
|
||||
PluginInit { plugin_name: String },
|
||||
#[serde(rename = "blocked.mcp_handshake")]
|
||||
McpHandshake { server_name: String, attempt: u32 },
|
||||
#[serde(rename = "blocked.branch_freshness")]
|
||||
BranchFreshness { behind_main: u32 },
|
||||
#[serde(rename = "blocked.test_hang")]
|
||||
TestHang {
|
||||
elapsed_secs: u32,
|
||||
test_name: Option<String>,
|
||||
},
|
||||
#[serde(rename = "blocked.report_pending")]
|
||||
ReportPending { since_secs: u32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaneEventBlocker {
|
||||
#[serde(rename = "failureClass")]
|
||||
pub failure_class: LaneFailureClass,
|
||||
pub detail: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subphase: Option<BlockedSubphase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -404,12 +446,37 @@ pub struct LaneCommitProvenance {
|
||||
pub lineage: Vec<String>,
|
||||
}
|
||||
|
||||
/// Ship/provenance metadata — §4.44.5
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ShipProvenance {
|
||||
pub source_branch: String,
|
||||
pub base_commit: String,
|
||||
pub commit_count: u32,
|
||||
pub commit_range: String,
|
||||
pub merge_method: ShipMergeMethod,
|
||||
pub actor: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pr_number: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShipMergeMethod {
|
||||
DirectPush,
|
||||
FastForward,
|
||||
MergeCommit,
|
||||
SquashMerge,
|
||||
RebaseMerge,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaneEvent {
|
||||
pub event: LaneEventName,
|
||||
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")]
|
||||
@@ -433,6 +500,7 @@ impl LaneEvent {
|
||||
event,
|
||||
status,
|
||||
emitted_at: emitted_at.into(),
|
||||
session_id: None,
|
||||
failure_class: None,
|
||||
detail: None,
|
||||
data: None,
|
||||
@@ -487,16 +555,74 @@ impl LaneEvent {
|
||||
|
||||
#[must_use]
|
||||
pub fn blocked(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
|
||||
Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
|
||||
let mut event = Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
|
||||
.with_failure_class(blocker.failure_class)
|
||||
.with_detail(blocker.detail.clone())
|
||||
.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
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn failed(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
|
||||
Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at)
|
||||
let mut event = Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at)
|
||||
.with_failure_class(blocker.failure_class)
|
||||
.with_detail(blocker.detail.clone())
|
||||
.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
|
||||
}
|
||||
|
||||
/// 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"))
|
||||
}
|
||||
|
||||
/// Ship commits selected — §4.44.5
|
||||
#[must_use]
|
||||
pub fn ship_commits_selected(
|
||||
emitted_at: impl Into<String>,
|
||||
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()))
|
||||
}
|
||||
|
||||
/// 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"))
|
||||
}
|
||||
|
||||
/// 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"))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -522,6 +648,12 @@ 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]
|
||||
@@ -570,9 +702,10 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events,
|
||||
is_terminal_event, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
|
||||
LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass,
|
||||
LaneOwnership, SessionIdentity, WatcherAction,
|
||||
is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent,
|
||||
LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus,
|
||||
LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance,
|
||||
WatcherAction,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -601,6 +734,10 @@ mod tests {
|
||||
LaneEventName::BranchWorkspaceMismatch,
|
||||
"branch.workspace_mismatch",
|
||||
),
|
||||
(LaneEventName::ShipPrepared, "ship.prepared"),
|
||||
(LaneEventName::ShipCommitsSelected, "ship.commits_selected"),
|
||||
(LaneEventName::ShipMerged, "ship.merged"),
|
||||
(LaneEventName::ShipPushedMain, "ship.pushed_main"),
|
||||
];
|
||||
|
||||
for (event, expected) in cases {
|
||||
@@ -641,6 +778,10 @@ mod tests {
|
||||
let blocker = LaneEventBlocker {
|
||||
failure_class: LaneFailureClass::McpStartup,
|
||||
detail: "broken server".to_string(),
|
||||
subphase: Some(BlockedSubphase::McpHandshake {
|
||||
server_name: "test-server".to_string(),
|
||||
attempt: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
let blocked = LaneEvent::blocked("2026-04-04T00:00:00Z", &blocker);
|
||||
@@ -686,6 +827,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ship_provenance_events_serialize_to_expected_wire_values() {
|
||||
let provenance = ShipProvenance {
|
||||
source_branch: "feature/provenance".to_string(),
|
||||
base_commit: "dd73962".to_string(),
|
||||
commit_count: 6,
|
||||
commit_range: "dd73962..c956f78".to_string(),
|
||||
merge_method: ShipMergeMethod::DirectPush,
|
||||
actor: "Jobdori".to_string(),
|
||||
pr_number: None,
|
||||
};
|
||||
|
||||
let prepared = LaneEvent::ship_prepared("2026-04-20T14:30:00Z", &provenance);
|
||||
let prepared_json = serde_json::to_value(&prepared).expect("ship event should serialize");
|
||||
assert_eq!(prepared_json["event"], "ship.prepared");
|
||||
assert_eq!(prepared_json["data"]["commit_count"], 6);
|
||||
assert_eq!(prepared_json["data"]["source_branch"], "feature/provenance");
|
||||
|
||||
let pushed = LaneEvent::ship_pushed_main("2026-04-20T14:35:00Z", &provenance);
|
||||
let pushed_json = serde_json::to_value(&pushed).expect("ship event should serialize");
|
||||
assert_eq!(pushed_json["event"], "ship.pushed_main");
|
||||
assert_eq!(pushed_json["data"]["merge_method"], "direct_push");
|
||||
|
||||
let round_trip: LaneEvent =
|
||||
serde_json::from_value(pushed_json).expect("ship event should deserialize");
|
||||
assert_eq!(round_trip.event, LaneEventName::ShipPushedMain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_events_can_carry_worktree_and_supersession_metadata() {
|
||||
let event = LaneEvent::commit_created(
|
||||
@@ -915,6 +1084,7 @@ 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(),
|
||||
@@ -926,6 +1096,7 @@ 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!(
|
||||
@@ -955,4 +1126,34 @@ 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,6 +36,7 @@ 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;
|
||||
@@ -84,9 +85,10 @@ pub use hooks::{
|
||||
};
|
||||
pub use lane_events::{
|
||||
compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events,
|
||||
is_terminal_event, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
|
||||
LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass,
|
||||
LaneOwnership, SessionIdentity, WatcherAction,
|
||||
is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent,
|
||||
LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus,
|
||||
LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance,
|
||||
WatcherAction,
|
||||
};
|
||||
pub use mcp::{
|
||||
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
|
||||
@@ -152,6 +154,9 @@ 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,
|
||||
|
||||
84
rust/crates/runtime/src/session_identity.rs
Normal file
84
rust/crates/runtime/src/session_identity.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
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,6 +18,8 @@ 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)
|
||||
@@ -768,6 +770,7 @@ 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,
|
||||
@@ -790,6 +793,7 @@ 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,
|
||||
@@ -1449,6 +1453,10 @@ 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
|
||||
|
||||
@@ -447,11 +447,14 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| "missing value for --model".to_string())?;
|
||||
validate_model_syntax(value)?;
|
||||
model = resolve_model_alias_with_config(value);
|
||||
index += 2;
|
||||
}
|
||||
flag if flag.starts_with("--model=") => {
|
||||
model = resolve_model_alias_with_config(&flag[8..]);
|
||||
let value = &flag[8..];
|
||||
validate_model_syntax(value)?;
|
||||
model = resolve_model_alias_with_config(value);
|
||||
index += 1;
|
||||
}
|
||||
"--output-format" => {
|
||||
@@ -743,6 +746,31 @@ fn parse_single_word_command_alias(
|
||||
permission_mode_override: Option<PermissionMode>,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Option<Result<CliAction, String>> {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself
|
||||
// or --help / -h as a suffix. Any other suffix args are unrecognized.
|
||||
let verb = &rest[0];
|
||||
let is_diagnostic = matches!(
|
||||
verb.as_str(),
|
||||
"help" | "version" | "status" | "sandbox" | "doctor" | "state"
|
||||
);
|
||||
|
||||
if is_diagnostic && rest.len() > 1 {
|
||||
// Diagnostic verb with trailing args: reject unrecognized suffix
|
||||
if is_help_flag(&rest[1]) && rest.len() == 2 {
|
||||
// "doctor --help" is valid, routed to parse_local_help_action() instead
|
||||
return None;
|
||||
}
|
||||
// Unrecognized suffix like "--json"
|
||||
return Some(Err(format!(
|
||||
"unrecognized argument `{}` for subcommand `{}`",
|
||||
rest[1], verb
|
||||
)));
|
||||
}
|
||||
|
||||
if rest.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
@@ -1035,6 +1063,37 @@ fn resolve_model_alias_with_config(model: &str) -> String {
|
||||
resolve_model_alias(trimmed).to_string()
|
||||
}
|
||||
|
||||
/// Validate model syntax at parse time.
|
||||
/// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern.
|
||||
/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars.
|
||||
fn validate_model_syntax(model: &str) -> Result<(), String> {
|
||||
let trimmed = model.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("model string cannot be empty".to_string());
|
||||
}
|
||||
// Known aliases are always valid
|
||||
match trimmed {
|
||||
"opus" | "sonnet" | "haiku" => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
// Check for spaces (malformed)
|
||||
if trimmed.contains(' ') {
|
||||
return Err(format!(
|
||||
"invalid model syntax: '{}' contains spaces. Use provider/model format or known alias",
|
||||
trimmed
|
||||
));
|
||||
}
|
||||
// Check provider/model format: provider_id/model_id
|
||||
let parts: Vec<&str> = trimmed.split('/').collect();
|
||||
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
||||
return Err(format!(
|
||||
"invalid model syntax: '{}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)",
|
||||
trimmed
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_alias_for_current_dir(alias: &str) -> Option<String> {
|
||||
if alias.is_empty() {
|
||||
return None;
|
||||
@@ -1497,6 +1556,8 @@ 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 {
|
||||
@@ -2317,6 +2378,8 @@ 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,
|
||||
@@ -2326,6 +2389,16 @@ 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,
|
||||
@@ -4934,6 +5007,8 @@ 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,
|
||||
@@ -4992,9 +5067,12 @@ 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(),
|
||||
@@ -5005,6 +5083,20 @@ 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,
|
||||
@@ -5058,7 +5150,7 @@ fn format_status_report(
|
||||
context.git_summary.unstaged_files,
|
||||
context.git_summary.untracked_files,
|
||||
context.session_path.as_ref().map_or_else(
|
||||
|| "live-repl".to_string(),
|
||||
|| format_active_session(context),
|
||||
|path| path.display().to_string()
|
||||
),
|
||||
context.loaded_config_files,
|
||||
@@ -5074,6 +5166,17 @@ 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
|
||||
@@ -5181,28 +5284,32 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
|
||||
fn render_help_topic(topic: LocalHelpTopic) -> String {
|
||||
match topic {
|
||||
LocalHelpTopic::Status => "Status
|
||||
Usage claw status
|
||||
Usage claw status [--output-format <format>]
|
||||
Purpose show the local workspace snapshot without entering the REPL
|
||||
Output model, permissions, git state, config files, and sandbox status
|
||||
Formats text (default), json
|
||||
Related /status · claw --resume latest /status"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Sandbox => "Sandbox
|
||||
Usage claw sandbox
|
||||
Usage claw sandbox [--output-format <format>]
|
||||
Purpose inspect the resolved sandbox and isolation state for the current directory
|
||||
Output namespace, network, filesystem, and fallback details
|
||||
Formats text (default), json
|
||||
Related /sandbox · claw status"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Doctor => "Doctor
|
||||
Usage claw doctor
|
||||
Usage claw doctor [--output-format <format>]
|
||||
Purpose diagnose local auth, config, workspace, sandbox, and build metadata
|
||||
Output local-only health report; no provider request or session resume required
|
||||
Formats text (default), json
|
||||
Related /doctor · claw --resume latest /doctor"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Acp => "ACP / Zed
|
||||
Usage claw acp [serve]
|
||||
Usage claw acp [serve] [--output-format <format>]
|
||||
Aliases claw --acp · claw -acp
|
||||
Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime
|
||||
Status discoverability only; `serve` is a status alias and does not launch a daemon yet
|
||||
Formats text (default), json
|
||||
Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help"
|
||||
.to_string(),
|
||||
}
|
||||
@@ -8421,6 +8528,7 @@ mod tests {
|
||||
request_id: Some("req_jobdori_789".to_string()),
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
};
|
||||
|
||||
let rendered = format_user_visible_api_error("session-issue-22", &error);
|
||||
@@ -8443,6 +8551,7 @@ mod tests {
|
||||
request_id: Some("req_jobdori_790".to_string()),
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -8506,6 +8615,7 @@ mod tests {
|
||||
request_id: Some("req_ctx_456".to_string()),
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
};
|
||||
|
||||
let rendered = format_user_visible_api_error("session-issue-32", &error);
|
||||
@@ -8537,6 +8647,7 @@ mod tests {
|
||||
request_id: Some("req_ctx_retry_789".to_string()),
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -8879,7 +8990,7 @@ mod tests {
|
||||
let args = vec![
|
||||
"--output-format=json".to_string(),
|
||||
"--model".to_string(),
|
||||
"claude-opus".to_string(),
|
||||
"opus".to_string(),
|
||||
"explain".to_string(),
|
||||
"this".to_string(),
|
||||
];
|
||||
@@ -8887,7 +8998,7 @@ mod tests {
|
||||
parse_args(&args).expect("args should parse"),
|
||||
CliAction::Prompt {
|
||||
prompt: "explain this".to_string(),
|
||||
model: "claude-opus".to_string(),
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
output_format: CliOutputFormat::Json,
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
@@ -9657,15 +9768,21 @@ mod tests {
|
||||
fn multi_word_prompt_still_uses_shorthand_prompt_mode() {
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
|
||||
// Input is ["help", "me", "debug"] so the joined prompt shorthand
|
||||
// must be "help me debug". A previous batch accidentally rewrote
|
||||
// the expected string to "$help overview" (copy-paste slip).
|
||||
// Input is ["--model", "opus", "please", "debug", "this"] so the joined
|
||||
// prompt shorthand must stay a normal multi-word prompt while still
|
||||
// honoring alias validation at parse time.
|
||||
assert_eq!(
|
||||
parse_args(&["help".to_string(), "me".to_string(), "debug".to_string()])
|
||||
.expect("prompt shorthand should still work"),
|
||||
parse_args(&[
|
||||
"--model".to_string(),
|
||||
"opus".to_string(),
|
||||
"please".to_string(),
|
||||
"debug".to_string(),
|
||||
"this".to_string(),
|
||||
])
|
||||
.expect("prompt shorthand should still work"),
|
||||
CliAction::Prompt {
|
||||
prompt: "help me debug".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
prompt: "please debug this".to_string(),
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
allowed_tools: None,
|
||||
permission_mode: crate::default_permission_mode(),
|
||||
@@ -10279,6 +10396,8 @@ 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,
|
||||
@@ -10307,10 +10426,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"));
|
||||
|
||||
@@ -39,6 +39,8 @@ 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"]);
|
||||
@@ -384,6 +386,47 @@ 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, &[])
|
||||
}
|
||||
@@ -431,6 +474,26 @@ 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, dedupe_superseded_commit_events, edit_file, execute_bash, glob_search,
|
||||
grep_search, load_system_prompt,
|
||||
check_freshness, current_boot_session_id, 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,7 +3535,9 @@ where
|
||||
created_at: created_at.clone(),
|
||||
started_at: Some(created_at),
|
||||
completed_at: None,
|
||||
lane_events: vec![LaneEvent::started(iso8601_now())],
|
||||
lane_events: vec![
|
||||
LaneEvent::started(iso8601_now()).with_session_id(current_boot_session_id())
|
||||
],
|
||||
current_blocker: None,
|
||||
derived_state: String::from("working"),
|
||||
error: None,
|
||||
@@ -3744,6 +3746,11 @@ 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()),
|
||||
@@ -3758,26 +3765,31 @@ fn persist_agent_terminal_state(
|
||||
if let Some(blocker) = blocker {
|
||||
next_manifest
|
||||
.lane_events
|
||||
.push(LaneEvent::blocked(iso8601_now(), &blocker));
|
||||
.push(LaneEvent::blocked(iso8601_now(), &blocker).with_session_id(session_id.clone()));
|
||||
next_manifest
|
||||
.lane_events
|
||||
.push(LaneEvent::failed(iso8601_now(), &blocker));
|
||||
.push(LaneEvent::failed(iso8601_now(), &blocker).with_session_id(session_id.clone()));
|
||||
} 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"),
|
||||
),
|
||||
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()),
|
||||
);
|
||||
if let Some(provenance) = maybe_commit_provenance(result) {
|
||||
next_manifest.lane_events.push(LaneEvent::commit_created(
|
||||
iso8601_now(),
|
||||
Some(format!("commit {}", provenance.commit)),
|
||||
provenance,
|
||||
));
|
||||
next_manifest.lane_events.push(
|
||||
LaneEvent::commit_created(
|
||||
iso8601_now(),
|
||||
Some(format!("commit {}", provenance.commit)),
|
||||
provenance,
|
||||
)
|
||||
.with_session_id(session_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
write_agent_manifest(&next_manifest)
|
||||
@@ -4459,6 +4471,7 @@ fn classify_lane_blocker(error: &str) -> LaneEventBlocker {
|
||||
LaneEventBlocker {
|
||||
failure_class: classify_lane_failure(error),
|
||||
detail,
|
||||
subphase: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7760,6 +7773,9 @@ 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()
|
||||
@@ -7837,10 +7853,17 @@ 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
|
||||
@@ -7853,6 +7876,10 @@ 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