Skip to content

refactor: F-ARCH-* audit batch (crate boundaries, atomic budget, approval metadata)#622

Merged
SDS-Mode merged 8 commits into
mainfrom
refactor/f-arch-batch
May 27, 2026
Merged

refactor: F-ARCH-* audit batch (crate boundaries, atomic budget, approval metadata)#622
SDS-Mode merged 8 commits into
mainfrom
refactor/f-arch-batch

Conversation

@SDS-Mode

Copy link
Copy Markdown
Owner

Summary

Bundled cleanup of all 7 open F-ARCH-* findings from the 2026-05-14 codebase audit. Each refactor lands as its own commit so git-cliff routes them cleanly to the CHANGELOG. None of these changes are user-visible; the dispatcher, TUI, web console, and CLI behave identically before and after.

Issue Commit subject Effect
#483 (F-ARCH-4) chore(cli): remove empty test-support feature forwarding Drops the no-op [features] test-support = ["pitboss-core/test-support"] shim — workspace grep confirms no caller passed --features pitboss-cli/test-support.
#479 (F-ARCH-11) refactor(dispatch): restrict install_sublead_cancel_watcher to pub(super) register_sublead is now the only valid entry point for sub-lead cancel-watcher install (the watcher had two prior callers, both in dispatch::*). install_cascade_cancel_watcher stays pub — integration tests under tests/ need it.
#480 (F-ARCH-12) refactor(dispatch): extract ApprovalMetadata shared between Queue and Bridge Pulls the seven duplicated fields out of QueuedApproval / BridgeEntry into a shared ApprovalMetadata. The queue→bridge transfer in control/server.rs is now a single q.metadata move instead of 7 field copies. Also normalizes the two ApprovalPlan paths to the canonical mcp::approval::ApprovalPlan alias.
#478 (F-ARCH-10) refactor(dispatch): switch spend counters to atomic BudgetCounter Replaces std::sync::Mutex<f64> on spent_usd / lead_spent_usd with a new BudgetCounter(AtomicU64) (f64 bit-cast). Removes the std-vs-tokio Mutex foot-gun on the same struct, kills ~110 lines of .lock().unwrap_or_else(PoisonError::into_inner) boilerplate, and lets compute_total_spend_blocking drop the partial-sum fallback for these fields. reserved_usd keeps its tokio Mutex — that lock is the per-spawn TOCTOU guard from #106.
#485 (F-ARCH-6) refactor(cli): rename stream module to live_stream to disambiguate from core ActorPath / EventEnvelope already moved to pitboss-core in #481; this rename eliminates the remaining pitboss-cli::streampitboss-core::stream collision. The cli module is the live + historical composition (depends on typed ControlEvent / ApprovalRule so can't live in core); the core module is the disk side.
#482 (F-ARCH-3) + #484 (F-ARCH-5) refactor: relocate task_events and audit to pitboss-core Moves the TaskEvent / DeniedReasonKind / AuditEntry / AuditFilter types from pitboss-cli::dispatch::events and pitboss-cli::audit into pitboss_core::task_events and pitboss_core::audit. The cli-side modules shrink to re-export shims, matching the pattern pitboss_cli::runs already follows (#484). pitboss-web::api::audit switches to the core paths, dropping two of its pitboss_cli::* reach-ins.
#486 (F-ARCH-7) (no code change) The audit explicitly says "no immediate action needed" — info-severity design observation that shared_store and communication would naturally move with the dispatcher if it ever split into its own crate. No such split is planned in this PR.

Why bundle these into one PR

These findings cluster thematically around crate boundary hygiene: too much in pitboss-cli was exposed to its consumers (TUI, web), and a few internal types had drifted into shapes that invited future bugs (mixed-mutex spend tracking, three-struct approval-field duplication, stream vs stream module-name collision). Doing them as separate PRs would have produced six near-identical PR templates and forced the reviewer to context-switch through the same pitboss-cli surface seven times.

Closes

Closes #478
Closes #479
Closes #480
Closes #482
Closes #483
Closes #484
Closes #485
Closes #486

Test plan

  • cargo check --workspace --all-targets passes
  • cargo lint (clippy with -D warnings) passes — including pitboss-core's stricter clippy::pedantic settings on the newly-relocated task_events and audit modules
  • cargo tidy (fmt --check) clean
  • cargo test -p pitboss-cli --lib budget_watch — all 7 budget-watcher tests pass (atomic counter swap doesn't change semantics)
  • cargo test -p pitboss-cli --test sublead_flows approval_ttl_triggers_auto_reject_fallback — TTL fallback path through new ApprovalMetadata works
  • scripts/smoke-part1.sh — 11/11 pass
  • scripts/smoke-part3-tui.sh — 9/12 pass (1 pre-existing failure on pitboss-tui --version, 2 interactive-only skips; both unrelated to this PR)

Note on e2e_lead_request_approval_round_trip

Under parallel cargo test, this approval round-trip test flakes ~30 % of the time both on this branch and on main (verified by running it three times against b942a65: pass, fail, pass). The race is between the server's responder.send(...) (which unblocks fake-claude and lets it exit) and the subsequent record_approval_outcome counter bump — fake-claude can race ahead and the test asserts before the counter increments. Passes reliably with --test-threads=1 or in isolation. Pre-existing race; not in scope here.

Files touched at a glance

  • New: crates/pitboss-core/src/task_events.rs (moved from cli), crates/pitboss-core/src/audit.rs (moved from cli)
  • Renamed: crates/pitboss-cli/src/stream.rscrates/pitboss-cli/src/live_stream.rs
  • Modified: crates/pitboss-cli/src/dispatch/{layer,budget_watch,state,signals,runner,sublead,events}.rs, crates/pitboss-cli/src/{lib,audit,mcp/approval,mcp/tools/spawn,mcp/tools/tests,control/server,Cargo}.{rs,toml}, crates/pitboss-tui/src/{app,control,state}.rs, crates/pitboss-web/src/api/audit.rs, crates/pitboss-core/src/lib.rs, crates/pitboss-cli/tests/{e2e_sublead_flows,hierarchical_flows,sublead_flows,live_stream_flows}.rs

🤖 Generated with Claude Code

Dan added 7 commits May 26, 2026 20:16
The `test-support` feature on `pitboss-cli` was a no-op forwarding shim
preserved only to keep backward compatibility with callers passing
`--features pitboss-cli/test-support`. A grep of the workspace shows
no such callers: scripts, CI, and dev docs all use the canonical
`--features pitboss-core/test-support` form, and the CLI's own unit
tests pull `pitboss-core/test-support` via `[dev-dependencies]` rather
than this feature. Removing the feature eliminates the fragile
forwarding chain.

Closes #483
…per)

The sub-lead cancel watcher must be installed in lock-step with
`DispatchState::register_sublead` — the watcher only covers actors
registered *before* the signal arrives, and the eager cascade in
`register_worker_cancel` covers everything after, so the two must be
installed by a single sequenced entry point. Exposing the watcher as
`pub fn` invited callers (especially future integration tests) to install
it independently and break that invariant.

`register_sublead` is the only caller in the codebase; the function had
no callers outside the `dispatch::*` module tree. Narrowing visibility
to `pub(super)` enforces the contract at the type system level.

`install_cascade_cancel_watcher` keeps `pub` visibility because the
integration-test suite (cancel_cascade_flows, dogfood_fake_flows, etc.)
calls it directly to drive the root→sub-tree cascade in test harnesses
— the analog of the dispatcher's own startup wiring in
`hierarchical.rs`.

Closes #479
… Bridge

`QueuedApproval` (pre-TUI-attach queue) and `BridgeEntry` (post-attach
bridge map) shared seven fields with identical types and semantics:
`task_id`, `summary`, `plan`, `kind`, `ttl_secs`, `fallback`,
`created_at`. Adding a new approval-metadata field (priority, category,
retry policy, etc.) required updating both structs and every
construction site in lock-step; missing one silently lost the field on
queue→bridge transfer.

Pull the seven shared fields into an `ApprovalMetadata` struct.
`QueuedApproval` becomes `{ request_id, responder, metadata }` and
`BridgeEntry` becomes `{ responder, metadata }`. The queue→bridge
transfer in `control/server.rs` now just moves the `metadata` whole
instead of field-by-field copying, removing one class of "I forgot to
copy field X" bug.

`PendingApproval` (the richer Phase 4+ record-level type with required
`ttl_secs`, typed `ApprovalCategory`, and actor lineage) stays separate
— the Phase 4 unification noted in its doc comment is a deeper design
change and out of scope here.

Also normalizes the two `ApprovalPlan` reference paths: the test in
`control/server.rs` was the only remaining site using
`mcp::tools::ApprovalPlan` directly; switched to
`mcp::approval::ApprovalPlan` (the canonical re-export, used by every
other approval-type field) for consistency.

Closes #480
`LayerState.budget` held `spent_usd` and `lead_spent_usd` as
`std::sync::Mutex<f64>` while `reserved_usd` was a `tokio::sync::Mutex`.
The std-Mutex choice was load-bearing — the session-stream usage
observer is a synchronous callback inside the stream loop, and an
`.await` on a tokio Mutex there would block the executor. But the
proximity of the two lock types on the same struct made future changes
error-prone: a `.lock().await` typo or a refactor that moved a spend
access from sync to async context would silently re-introduce the
guard-across-await foot-gun.

Wrap both counters in a new `BudgetCounter(AtomicU64)` that bit-casts
`f64` through `AtomicU64` for lock-free load/store/add. The session-
stream observer can read and update spend without any lock at all; the
async budget-walking aggregator (`compute_total_spend`) becomes a tight
`.load()` loop. Critical sections that *combine* spend reads with
reservation updates (the per-spawn TOCTOU guard from #106) still go
through `reserved_usd: Mutex<f64>` — that lock is the synchronization,
not the storage, and we leave it unchanged.

Concrete improvements:
- ~110 lines of `*x.lock().unwrap_or_else(PoisonError::into_inner)` /
  `+= delta` boilerplate replaced with `x.load()` / `x.add(delta)` /
  `x.store(value)`.
- `compute_total_spend_blocking` no longer needs the std-Mutex `.lock()`
  fallback for partial-sum recovery on contention — the atomic loads
  don't contend. The tokio RwLock `try_read()` fallback on the
  sub-leads map stays (separate concern from the spend counters).
- The "no `.await` may cross this guard" rule is now an impossibility
  rather than a hand-maintained invariant.

Reservations (per-task `WorkerRegistry.reservations` HashMap) live
deliberately separate from `BudgetState` — they're per-task_id keys,
not layer-aggregate counters. The audit suggestion to centralize them
into `BudgetTracker` would re-cross a clean boundary; this commit keeps
them where the rest of the per-task_id maps live.

Closes #478
…om core

`pitboss-core::stream` exposes the disk-replay foundation; the cli-side
remainder composes that with the per-run control socket to produce a
live + historical unified stream. Until now both modules were named
`stream`, so a reader staring at `use ...::stream::LiveStreamItem`
couldn't tell which crate they were importing from without checking
the prefix. The collision motivated F-ARCH-6 (#485).

The audit also asked for `ActorPath` to move into `pitboss-core` to
unblock a full unification of the consumer API in `pitboss-core::stream`
itself. That work has already landed via #481 — `ActorPath` and the
type-erased `EventEnvelope` shell now live in
`pitboss-core::control_protocol`. The remaining cli-side composition is
still load-bearing because the typed `ControlEvent` payloads carry
`mcp::policy::ApprovalRule` and other MCP/approval concerns that
deliberately don't live in core (per `pitboss-core::control_protocol`'s
preamble). Renaming the cli module to `live_stream` is the right
disambiguator without dragging approval rules into core.

Touches three external consumers: `pitboss-tui::control` and
`pitboss-tui::app` (the TUI's live-session attach path) and the
`live_stream_flows` integration test. The module's interior is
unchanged.

Closes #485
`pitboss-web::api::audit` was reaching into `pitboss-cli::audit` and
`pitboss-cli::dispatch::events` to read the run-wide `audit.jsonl`
artifact — two read-only types whose only legitimate consumer outside
the dispatcher is the web console. Pulling them through `pitboss-cli`
forced the web crate to depend on the full dispatcher (its MCP server,
control bridge, process spawner, …) just to deserialize a JSONL row.

Move both modules into `pitboss-core` where the other consumer-shared
reader-side types already live (`pitboss_core::runs`,
`pitboss_core::store`, `pitboss_core::stream`). The cli-side
`crates/pitboss-cli/src/audit.rs` and
`crates/pitboss-cli/src/dispatch/events.rs` shrink to re-export shims,
matching the pattern `pitboss_cli::runs` already follows since #484. All
existing `crate::dispatch::events::*` / `crate::audit::*` paths inside
`pitboss-cli` continue to resolve through the shim — no internal
caller had to change.

`pitboss-web::api::audit` now imports from `pitboss_core::audit` and
`pitboss_core::task_events` directly, dropping two of its
`pitboss_cli::*` reach-ins. `pitboss-cli` still owns the dispatcher
runtime, manifest pipeline, MCP server, control protocol, and live
stream — those remain too entangled with cli-only types
(`ApprovalRule`, the typed `ControlEvent` payloads, etc.) to relocate
in this PR — but the pure-disk audit types had no such ties.

Notes:
- `pitboss-core` has `#![warn(clippy::pedantic)]` while `pitboss-cli`
  does not. The moved modules carry minimal `#![allow(...)]` attrs for
  `must_use_candidate` and `needless_pass_by_value` so the move is
  mechanical — no behavior change. Two trailing-comma `format!` nits
  got fixed in passing.
- The `pitboss audit` subcommand entry (`run`) was moved alongside the
  data types; the CLI binary's audit subcommand handler still routes
  through `pitboss_cli::audit::run` via the re-export shim.

Closes #482
Closes #484
@SDS-Mode
SDS-Mode force-pushed the refactor/f-arch-batch branch from 6490eba to ede098f Compare May 27, 2026 00:16
@SDS-Mode
SDS-Mode enabled auto-merge (rebase) May 27, 2026 00:16
…ad (#366 twin)

When an approval is raised before any TUI has attached, it queues on
`ApprovalState.queue`. When a TUI later connects, the server drains
the queue into the bridge map and emits `ApprovalRequest` events; the
operator's subsequent `Approve` op flows through `control::server`'s
Approve handler, which is responsible for writing the
`ApprovalResponse` audit row to `events.jsonl`.

That handler hard-coded `&state.root.lead_id` as the actor whose
`tasks/<id>/events.jsonl` received the row, so a worker or sub-lead
approval response landed in the lead's audit file and the originating
actor's events.jsonl was missing its `approval_response` correlation
to the earlier `approval_request`. The bridge-side handler
(`ApprovalBridge::respond`) was already fixed for this in #366; the
queue-drain twin was missed. The just-extracted `caller_id` local
(from `bridge_entry.metadata.task_id`) is the value that should have
been used — F-ARCH-12 made the variable explicit one line above the
buggy call, which is what surfaced the bug.

Add a regression test `approve_op_writes_response_row_to_caller_tasks_dir`
that pre-seeds the bridge with `task_id = "worker-7"`, sends an Approve
op, and asserts:
1. `<run_subdir>/tasks/worker-7/events.jsonl` exists and contains an
   `approval_response` row with the matching `request_id`.
2. The lead's `events.jsonl` does NOT contain the worker's request_id
   (covering the specific pre-fix misdirection).

Also updates `pitboss-tui/tests/connect_timeout_regression.rs` to use
`pitboss_cli::live_stream::*` (instead of the renamed `::stream::*`);
that test landed in this branch from a parallel review pass and was
written against the pre-F-ARCH-6 module name.

Discovered during the F-ARCH-12 review pass on PR #622.
@SDS-Mode
SDS-Mode merged commit 79b3508 into main May 27, 2026
15 checks passed
@SDS-Mode
SDS-Mode deleted the refactor/f-arch-batch branch May 27, 2026 00:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment