Skip to content

feat(sessions): SubmitVerdict handler resumes pending sessions - #618

Merged
evanspearman merged 2 commits into
mainfrom
evan/createsession-wire3
Jul 3, 2026
Merged

feat(sessions): SubmitVerdict handler resumes pending sessions#618
evanspearman merged 2 commits into
mainfrom
evan/createsession-wire3

Conversation

@evanspearman

@evanspearman evanspearman commented Jul 2, 2026

Copy link
Copy Markdown
Member

Depends on #602

Wires up the SubmitVerdict RPC end-to-end so a Pending session can be resumed after the client returns its per-item verdict. Manager pops the stashed PendingComposeState, runs resume_from_verdict, promotes the record Pending → Active, replies with SessionStep::Active { id }.

  • sessions: resume_from_verdict (walks verdict → reattaches provenance → assembles Composition); WireError::WrongState, SessionStep::Active wire additions; Draft → Pending status rename (serde alias keeps old records loading).
  • minimald: Manager.pending in-memory stash (cap MAX_PENDING_SESSIONS, ResourceBusy on overflow); SubmitVerdict handler; CreateSession Pending branch actually returns Pending now; GetSession refuses non-Active; startup reap of orphan Pending records; failed resume destroys the on-disk record.
  • minimal: cmd_activate drives Phase 3 (handle_response) → SubmitVerdict → unwrap SessionStep::Active instead of erroring on Pending. Deny-all PolicyHooks stub until interactive prompts land.
  • minimald-rpc: SubmitVerdict RPC dispatch.

Stash is in-memory only — restart-survives-pending is out of scope; the reap pass is the correctness floor. Ready-path composition is still dropped (apply-layer plumbing separate work); guard on non-empty Composition prevents silent data loss.

Summary by CodeRabbit

  • New Features

    • Session creation can now return a pending session that later becomes active after verdict submission.
    • Added a new “session active” response in the session protocol and support for submitting verdicts to advance sessions.
    • Pending session behavior now includes resume/merge of approved items.
  • Bug Fixes

    • Pending/orphaned sessions are cleaned up on startup, and non-active sessions are refused for attachment.
    • Legacy serialized state names continue to load correctly.
  • Documentation

    • Updated composition documentation to describe the new pending-to-active lifecycle.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f9eb67b4-4cc8-419c-b8b5-fa5293e91439

📥 Commits

Reviewing files that changed from the base of the PR and between 3342964 and c0be94c.

📒 Files selected for processing (2)
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/src/core/compose.rs
✅ Files skipped from review due to trivial changes (1)
  • crates/sessions/docs/COMPOSITION.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/sessions/src/core/compose.rs

📝 Walkthrough

Walkthrough

This PR adds a pending-session flow: daemon-composed items can be returned as Pending, later resolved through SubmitVerdict, and then promoted to Active. It updates wire contracts, daemon/session manager logic, CLI activation, and documentation.

Changes

Pending session composition and verdict submission

Layer / File(s) Summary
Wire contract updates: SessionStatus, WireError, SessionStep
crates/sessions/src/lib.rs, crates/sessions/src/wire/errors.rs, crates/sessions/src/wire/request.rs, crates/sessions/src/store.rs
SessionStatus::Draft becomes Pending with legacy aliasing, WireError::WrongState is added, SessionStep::Active { id } is added, and related docs/tests/store examples are updated.
Core compose helpers for pending wire transformation
crates/sessions/src/core/compose.rs
Adds pending transform storage, shared conflict checking, SessionPatch::new, Composition::extend_with, and rewrites contribution_to_pending to return wire plus daemon-side stashes.
Daemon composer: ComposeOutcome and verdict resumption
crates/sessions/src/daemon/composer.rs
SessionComposer::compose now returns Ready or Pending, and resume_from_verdict reconstructs a final composition from stashed pending items with verdict validation.
Session manager: pending stash, promotion, and SubmitVerdict
crates/minimald/src/sessions.rs
Adds pending-session bookkeeping, orphan reaping, Active-only lookup, CreateSession branching, verdict submission handling, and tests for pending cleanup and unknown-session faults.
RPC dispatch: SubmitVerdict handler and ResourceBusy mapping
crates/minimald/src/rpc.rs
Wires the SubmitVerdict subsystem into RPC dispatch and returns a typed busy error for full pending-stash conditions.
CLI client: driving pending sessions to Active
crates/minimal/src/main.rs
Adds client-side verdict generation and uses SubmitVerdict to finish Pending session activation.
Documentation updates
crates/sessions/docs/COMPOSITION.md
Describes the Ready/Pending split, resume flow, restart behavior, and attach rules.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI as minimal CLI
  participant Daemon as minimald RPC
  participant Manager as SessionsManager
  participant Composer as SessionComposer

  CLI->>Daemon: CreateSession(contribution)
  Daemon->>Manager: CreateSession
  Manager->>Composer: compose(session_id, options)
  alt daemon vars/patches empty
    Composer-->>Manager: ComposeOutcome::Ready(Composition)
    Manager-->>Daemon: CreateSessionResponse::Ready { id }
    Daemon-->>CLI: Ready { id }
  else needs approval
    Composer-->>Manager: ComposeOutcome::Pending(response, state)
    Manager->>Manager: stash PendingComposeState, persist Pending record
    Manager-->>Daemon: CreateSessionResponse::Pending { id, response }
    Daemon-->>CLI: Pending { id, response }
    CLI->>CLI: handle_response + UserPolicy + AbortOnUnapproved
    CLI->>Daemon: SubmitVerdict(verdict)
    Daemon->>Manager: SubmitVerdict
    Manager->>Composer: resume_from_verdict(state, verdict)
    Composer-->>Manager: Composition
    Manager->>Manager: promote Pending -> Active
    Manager-->>Daemon: SessionStep::Active { id }
    Daemon-->>CLI: Active { id }
  end
Loading

Possibly related PRs

  • gominimal/minimal#348: Adds the policy/provenance foundation that this PR’s pending verdict flow builds on.
  • gominimal/minimal#568: Introduces the client verdict and correlation data consumed by resume_from_verdict.
  • gominimal/minimal#600: Introduces the Ready/Pending session creation shape that this PR extends with promotion logic.

Suggested labels: needs-spec

Suggested reviewers: norrietaylor, twitchyliquid64

Poem

A rabbit hopped through pending light,
Then sent a verdict late at night.
From Pending state to Active gleam,
The burrow now can finish the dream.
🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding SubmitVerdict support to resume pending sessions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@evanspearman
evanspearman force-pushed the evan/createsession-wire3 branch from 00f444d to 7bf3d2c Compare July 2, 2026 15:56

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minimal/src/main.rs`:
- Around line 480-489: The local gating path in main around handle_response
currently returns before SubmitVerdict, which can leave a
CreateSessionResponse::Pending record and pending-stash slot behind when
AbortOnUnapproved aborts. Update the verdict handling so this branch explicitly
cleans up the daemon pending session—either by invoking an abort/cleanup path or
by submitting an explicit denial that causes the manager to remove the pending
state—using the existing handle_response, AbortOnUnapproved, and SubmitVerdict
flow as the place to wire it in.

In `@crates/minimald-rpc/src/lib.rs`:
- Around line 232-236: The status note in the documentation comment is outdated
and still implies `Pending` is unavailable and `SubmitVerdict` has not landed.
Update the comment near the composer flow to describe the actual `Pending ->
SubmitVerdict -> Active` path, keeping the explanation aligned with the current
behavior of the manager/composer logic and removing any language that suggests
the transition is still missing.

In `@crates/minimald/src/sessions.rs`:
- Around line 168-175: The destroy path in `Manager`/`sessions.rs` removes the
session record but leaves the matching `pending` stash entry behind, so
destroyed pending sessions still count against `MAX_PENDING_SESSIONS`. Update
the session destroy handler to also remove the in-memory entry from `pending`
using the session id (for example, alongside the existing delete logic in the
destroy method that handles `SessionId`), so `PendingComposeState` is cleaned up
whenever a session is destroyed.
- Around line 529-579: The resume/ready flow in `resume_from_verdict` handling
should not mark a session `Active` while the resumed composition is still being
dropped. Add the same non-empty composition guard used by the Ready path before
calling `store.save` and returning `SessionStep::Active`, and if `_composition`
contains approved vars/patches/packages/hooks, return a fault instead of
promoting the record. This keeps `SessionStep::Active` and the `Pending ->
Active` transition from silently losing the resumed composition.
- Around line 513-579: The pending session stash is being removed too early in
the SubmitVerdict flow, before promotion to Active is durably saved. In the
sessions::SessionStore resume path around `self.pending.remove`,
`resume_from_verdict`, and `store.save`, keep the pending entry until after
`store.save` succeeds so a failed promotion can still be retried. Adjust the
logic to borrow or clone the pending state for `resume_from_verdict`, delete
`self.pending` only after the on-disk status update is committed, and retain the
immediate removal only for terminal resume failures that already return a Fault.

In `@crates/sessions/docs/COMPOSITION.md`:
- Around line 47-55: The status note in the COMPOSITION docs is stale and still
says `ComposeOutcome::Pending` is rejected and `SubmitVerdict` is missing;
update the paragraph around `SessionComposer::compose` and
`CreateSessionResponse::Ready` so it reflects the current `Pending ->
SubmitVerdict -> Active` lifecycle and removes the outdated rejection wording.

In `@crates/sessions/src/daemon/composer.rs`:
- Around line 351-358: The approval handling in Composer::pending_vars /
WireVarVerdict::Approved currently trusts the client-returned value and can
install a different variable name than the one originally routed by PendingId.
When processing the Approved branch, compare the name in value against the
stashed pending var from take_pending and reject or ignore any mismatch so only
the original pending var name is accepted; use the existing pending-var lookup
path and SessionVar::new construction to keep the routed name authoritative.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 65fc5ca1-b8a9-4ba9-b482-7d1314c4f030

📥 Commits

Reviewing files that changed from the base of the PR and between 3e9637a and 7bf3d2c.

📒 Files selected for processing (12)
  • crates/minimal/src/main.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/sessions.rs
  • crates/minvmd/tests/minimald_session_e2e.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/src/core/compose.rs
  • crates/sessions/src/daemon/composer.rs
  • crates/sessions/src/lib.rs
  • crates/sessions/src/store.rs
  • crates/sessions/src/wire/errors.rs
  • crates/sessions/src/wire/request.rs

Comment thread crates/minimal/src/main.rs
Comment thread crates/minimald-rpc/src/lib.rs
Comment on lines +168 to +175
/// Per-session stash for in-flight `CreateSession` flows that
/// reached [`ComposeOutcome::Pending`]. Keyed by the daemon's
/// allocated [`SessionId`]; the matching `SubmitVerdict` pops
/// the entry and finalizes. The stash is in-memory only — if
/// the daemon restarts mid-flow the on-disk
/// `SessionStatus::Pending` record loses its match and is
/// reaped by [`Manager::reap_orphan_pending`] at startup.
pending: BTreeMap<SessionId, PendingComposeState>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clear pending stash entries when sessions are destroyed.

The new pending map is keyed by session id, but the existing destroy path deletes the record without removing the matching stash entry. Destroyed Pending sessions can keep consuming MAX_PENDING_SESSIONS capacity until daemon restart.

Add self.pending.remove(&id) in the destroy handler when deleting a session id.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/sessions.rs` around lines 168 - 175, The destroy path in
`Manager`/`sessions.rs` removes the session record but leaves the matching
`pending` stash entry behind, so destroyed pending sessions still count against
`MAX_PENDING_SESSIONS`. Update the session destroy handler to also remove the
in-memory entry from `pending` using the session id (for example, alongside the
existing delete logic in the destroy method that handles `SessionId`), so
`PendingComposeState` is cleaned up whenever a session is destroyed.

Comment thread crates/minimald/src/sessions.rs Outdated
Comment on lines +513 to +579
let stash_hit = self.pending.remove(&session_id);
let store = &mut self.store;
responder
.handle(async move {
let state = match stash_hit {
Some(s) => s,
None => {
return Ok(SessionStep::Fault {
error: sessions::wire::errors::WireError::UnknownSessionId,
});
}
};
// Run Phase 4 resume. A verdict that the
// composer can't apply (mismatched
// PendingIds, denied items, etc.) maps to a
// `Fault` via `WireError::from(ComposeError)`.
// The resumed composition is dropped today:
// session activation doesn't yet consume it.
let _composition = match resume_from_verdict(state, verdict) {
Ok(c) => c,
Err(e) => {
// Verdict couldn't be applied — the
// session is dead. Destroy the
// matching on-disk Pending record so
// the name is freed, `list()` doesn't
// show a phantom entry, and the next
// reap pass has nothing to clean up.
// A delete failure here is logged but
// doesn't override the verdict-level
// error returned to the client.
if let Some(k) = store.find_by_id(&session_id)?
&& let Err(del_err) = store.delete(&k)
{
tracing::warn!(
session_id = %session_id,
error = %del_err,
"failed to delete Pending record after \
resume failure; record will be reaped on \
next daemon restart",
);
}
return Ok(SessionStep::Fault { error: e.into() });
}
};
// R2.1: reject a policy incompatible with the network
// mode (e.g. egress on a non-`OwnIp` PTask) at
// declaration time, so an invalid session is never
// written to the store rather than only failing when
// a client later attaches.
record.validate_policy().map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, e)
})?;
let k = self.store.create(record)?;
Ok(minimald_rpc::CreateSessionResponse::Ready { id: *k.id() })
// Promote the on-disk record `Pending →
// Active`. A mid-flight delete or a status
// already past `Pending` is degenerate but
// surfaces as a structured `WrongState`.
let k = match store.find_by_id(&session_id)? {
Some(k) => k,
None => {
return Ok(SessionStep::Fault {
error: sessions::wire::errors::WireError::UnknownSessionId,
});
}
};
let mut record = store.get(&k)?.record().clone();
if record.status != sessions::SessionStatus::Pending {
return Ok(SessionStep::Fault {
error: sessions::wire::errors::WireError::WrongState {
what: format!("expected Pending, found {:?}", record.status,),
},
});
}
record.status = sessions::SessionStatus::Active;
store.save(&k, &record)?;
Ok(SessionStep::Active { id: session_id })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep the pending stash until promotion is durable.

Line 513 removes the stash before store.save succeeds. If promotion fails, the on-disk record remains Pending but the in-memory state needed to retry SubmitVerdict is gone.

Prefer cloning/borrowing the state for resume_from_verdict, then remove self.pending only after store.save succeeds; remove it immediately only for terminal resume failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/sessions.rs` around lines 513 - 579, The pending session
stash is being removed too early in the SubmitVerdict flow, before promotion to
Active is durably saved. In the sessions::SessionStore resume path around
`self.pending.remove`, `resume_from_verdict`, and `store.save`, keep the pending
entry until after `store.save` succeeds so a failed promotion can still be
retried. Adjust the logic to borrow or clone the pending state for
`resume_from_verdict`, delete `self.pending` only after the on-disk status
update is committed, and retain the immediate removal only for terminal resume
failures that already return a Fault.

Comment on lines +529 to +579
// The resumed composition is dropped today:
// session activation doesn't yet consume it.
let _composition = match resume_from_verdict(state, verdict) {
Ok(c) => c,
Err(e) => {
// Verdict couldn't be applied — the
// session is dead. Destroy the
// matching on-disk Pending record so
// the name is freed, `list()` doesn't
// show a phantom entry, and the next
// reap pass has nothing to clean up.
// A delete failure here is logged but
// doesn't override the verdict-level
// error returned to the client.
if let Some(k) = store.find_by_id(&session_id)?
&& let Err(del_err) = store.delete(&k)
{
tracing::warn!(
session_id = %session_id,
error = %del_err,
"failed to delete Pending record after \
resume failure; record will be reaped on \
next daemon restart",
);
}
return Ok(SessionStep::Fault { error: e.into() });
}
};
// R2.1: reject a policy incompatible with the network
// mode (e.g. egress on a non-`OwnIp` PTask) at
// declaration time, so an invalid session is never
// written to the store rather than only failing when
// a client later attaches.
record.validate_policy().map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, e)
})?;
let k = self.store.create(record)?;
Ok(minimald_rpc::CreateSessionResponse::Ready { id: *k.id() })
// Promote the on-disk record `Pending →
// Active`. A mid-flight delete or a status
// already past `Pending` is degenerate but
// surfaces as a structured `WrongState`.
let k = match store.find_by_id(&session_id)? {
Some(k) => k,
None => {
return Ok(SessionStep::Fault {
error: sessions::wire::errors::WireError::UnknownSessionId,
});
}
};
let mut record = store.get(&k)?.record().clone();
if record.status != sessions::SessionStatus::Pending {
return Ok(SessionStep::Fault {
error: sessions::wire::errors::WireError::WrongState {
what: format!("expected Pending, found {:?}", record.status,),
},
});
}
record.status = sessions::SessionStatus::Active;
store.save(&k, &record)?;
Ok(SessionStep::Active { id: session_id })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not promote while dropping the resumed composition.

The Ready path rejects non-empty compositions, but SubmitVerdict drops _composition and still marks the record Active. Any approved pending vars/patches/packages/hooks are silently lost.

Add the same non-empty guard here until the apply layer can persist/consume the resumed composition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/sessions.rs` around lines 529 - 579, The resume/ready
flow in `resume_from_verdict` handling should not mark a session `Active` while
the resumed composition is still being dropped. Add the same non-empty
composition guard used by the Ready path before calling `store.save` and
returning `SessionStep::Active`, and if `_composition` contains approved
vars/patches/packages/hooks, return a fault instead of promoting the record.
This keeps `SessionStep::Active` and the `Pending -> Active` transition from
silently losing the resumed composition.

Comment thread crates/sessions/docs/COMPOSITION.md Outdated
Comment on lines +351 to +358
WireVarVerdict::Approved { id, value } => {
let pv = take_pending(&mut pending_vars, id, "var")?;
// The verdict's `value` is the client-resolved
// (possibly user-edited at prompt) value; the
// stashed `pv` supplies the source the wire schema
// doesn't echo.
let (_, source) = pv.into_parts();
accepted.push(SessionVar::new(value.into(), source));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate approved var names against the stash.

A verdict with a valid PendingId can currently change the variable name in value, causing Phase 4 to install a different var than the daemon originally routed to Pending.

Proposed fix
             WireVarVerdict::Approved { id, value } => {
                 let pv = take_pending(&mut pending_vars, id, "var")?;
+                let expected_name = pv.var().name();
+                if value.name != expected_name {
+                    return Err(ComposeError::InvalidWireItem {
+                        what: "approved var name does not match pending var",
+                        context: format!(
+                            "pending id {id:?}: expected `{expected_name}`, got `{}`",
+                            value.name,
+                        ),
+                    });
+                }
                 // The verdict's `value` is the client-resolved
                 // (possibly user-edited at prompt) value; the
                 // stashed `pv` supplies the source the wire schema
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
WireVarVerdict::Approved { id, value } => {
let pv = take_pending(&mut pending_vars, id, "var")?;
// The verdict's `value` is the client-resolved
// (possibly user-edited at prompt) value; the
// stashed `pv` supplies the source the wire schema
// doesn't echo.
let (_, source) = pv.into_parts();
accepted.push(SessionVar::new(value.into(), source));
WireVarVerdict::Approved { id, value } => {
let pv = take_pending(&mut pending_vars, id, "var")?;
let expected_name = pv.var().name();
if value.name != expected_name {
return Err(ComposeError::InvalidWireItem {
what: "approved var name does not match pending var",
context: format!(
"pending id {id:?}: expected `{expected_name}`, got `{}`",
value.name,
),
});
}
// The verdict's `value` is the client-resolved
// (possibly user-edited at prompt) value; the
// stashed `pv` supplies the source the wire schema
// doesn't echo.
let (_, source) = pv.into_parts();
accepted.push(SessionVar::new(value.into(), source));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/daemon/composer.rs` around lines 351 - 358, The approval
handling in Composer::pending_vars / WireVarVerdict::Approved currently trusts
the client-returned value and can install a different variable name than the one
originally routed by PendingId. When processing the Approved branch, compare the
name in value against the stashed pending var from take_pending and reject or
ignore any mismatch so only the original pending var name is accepted; use the
existing pending-var lookup path and SessionVar::new construction to keep the
routed name authoritative.

@evanspearman
evanspearman force-pushed the evan/createsession-wire3 branch from 7bf3d2c to 3342964 Compare July 3, 2026 18:39

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
crates/sessions/docs/COMPOSITION.md (1)

44-56: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale "Pending outcomes are still rejected" status note persists.

This paragraph still states Pending outcomes are rejected with InvalidInput and that the SubmitVerdict handler "hasn't landed yet." This was already flagged in a prior review round, and it remains unresolved: this very diff adds resume_from_verdict, the SubmitVerdict RPC dispatch, and the full Pending→Active flow described just a few lines below (Phase 4, step 5: "Replies with SessionStep::Active { id }"). The status note now directly contradicts the rest of the document it introduces.

📝 Proposed fix
-> `client::handler::handle_response`. Phase 2 (daemon-side routing)
-> lives in `SessionComposer::compose` and produces a
-> `ComposeOutcome::Ready` or `ComposeOutcome::Pending`. Phase 4 is
-> partially wired — the `Ready` outcome is consumed (the manager
-> persists the session and returns `CreateSessionResponse::Ready`),
-> but `Pending` outcomes are still rejected with `InvalidInput`
-> because the `SubmitVerdict` handler hasn't landed yet. No daemon-
-> side project/package contributors are wired in either, so every
-> caller still hits the all-decided path today.
+> `client::handler::handle_response`. Phase 2 (daemon-side routing)
+> lives in `SessionComposer::compose` and produces a
+> `ComposeOutcome::Ready` or `ComposeOutcome::Pending`. Both
+> outcomes are now wired end-to-end: `Ready` persists the session
+> as `Active` directly, and `Pending` is resumed via the
+> `SubmitVerdict` RPC and `resume_from_verdict` (see Phase 4). No
+> daemon-side project/package contributors are wired in either, so
+> every caller still hits the all-decided path today.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/docs/COMPOSITION.md` around lines 44 - 56, Update the status
note in COMPOSITION.md so it no longer says `Pending` outcomes are rejected with
`InvalidInput` or that `SubmitVerdict` “hasn't landed yet”; this text is now
stale. In the section describing `core::compose`, `SessionComposer::compose`,
and `ComposeOutcome::Pending`, revise the note to reflect the implemented
Pending→Active flow and the new `resume_from_verdict` / `SubmitVerdict` path,
keeping it consistent with the Phase 4 description below.
🧹 Nitpick comments (1)
crates/sessions/src/core/compose.rs (1)

982-1039: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate conflict-check logic between extend_from_wire and extend_with.

Both methods run the identical check_var_mismatches/check_patch_mismatches pair against self.{vars,patches}.iter().chain(incoming.iter()). Extracting a shared private helper avoids the two call sites drifting apart if the conflict rules ever change.

♻️ Proposed refactor
+    fn check_conflicts(
+        &self,
+        incoming_vars: &[SessionVar],
+        incoming_patches: &[SessionPatch],
+    ) -> Result<(), ComposeError> {
+        check_var_mismatches(
+            self.vars.iter().chain(incoming_vars.iter()),
+            |v| v.var().name(),
+            |v| v.var().value(),
+        )?;
+        check_patch_mismatches(
+            self.patches.iter().chain(incoming_patches.iter()),
+            |p| p.patch().destination(),
+            |p| p.patch().host_path().as_str(),
+        )?;
+        Ok(())
+    }
+
     pub(crate) fn extend_from_wire(...) -> Result<(), ComposeError> {
         ...
-        check_var_mismatches(
-            self.vars.iter().chain(incoming_vars.iter()),
-            |v| v.var().name(),
-            |v| v.var().value(),
-        )?;
-        check_patch_mismatches(
-            self.patches.iter().chain(incoming_patches.iter()),
-            |p| p.patch().destination(),
-            |p| p.patch().host_path().as_str(),
-        )?;
+        self.check_conflicts(&incoming_vars, &incoming_patches)?;
         ...
     }

     pub(crate) fn extend_with(
         &mut self,
         vars: Vec<SessionVar>,
         patches: Vec<SessionPatch>,
     ) -> Result<(), ComposeError> {
-        check_var_mismatches(
-            self.vars.iter().chain(vars.iter()),
-            |v| v.var().name(),
-            |v| v.var().value(),
-        )?;
-        check_patch_mismatches(
-            self.patches.iter().chain(patches.iter()),
-            |p| p.patch().destination(),
-            |p| p.patch().host_path().as_str(),
-        )?;
+        self.check_conflicts(&vars, &patches)?;
         self.vars.extend(vars);
         self.patches.extend(patches);
         Ok(())
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/core/compose.rs` around lines 982 - 1039, `extend_with`
duplicates the same conflict-check sequence used by `extend_from_wire`, so
factor the shared `check_var_mismatches` and `check_patch_mismatches` logic into
a private helper in `compose.rs`. Have both `extend_with` and `extend_from_wire`
call that helper with their existing iterators/accessors, keeping the atomic
precheck behavior and existing `ComposeError::Conflict` semantics unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@crates/sessions/docs/COMPOSITION.md`:
- Around line 44-56: Update the status note in COMPOSITION.md so it no longer
says `Pending` outcomes are rejected with `InvalidInput` or that `SubmitVerdict`
“hasn't landed yet”; this text is now stale. In the section describing
`core::compose`, `SessionComposer::compose`, and `ComposeOutcome::Pending`,
revise the note to reflect the implemented Pending→Active flow and the new
`resume_from_verdict` / `SubmitVerdict` path, keeping it consistent with the
Phase 4 description below.

---

Nitpick comments:
In `@crates/sessions/src/core/compose.rs`:
- Around line 982-1039: `extend_with` duplicates the same conflict-check
sequence used by `extend_from_wire`, so factor the shared `check_var_mismatches`
and `check_patch_mismatches` logic into a private helper in `compose.rs`. Have
both `extend_with` and `extend_from_wire` call that helper with their existing
iterators/accessors, keeping the atomic precheck behavior and existing
`ComposeError::Conflict` semantics unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ddd368e9-e76c-4f39-bdcb-7f4ab8b2bb10

📥 Commits

Reviewing files that changed from the base of the PR and between 7bf3d2c and 3342964.

📒 Files selected for processing (10)
  • crates/minimal/src/main.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/sessions.rs
  • crates/sessions/docs/COMPOSITION.md
  • crates/sessions/src/core/compose.rs
  • crates/sessions/src/daemon/composer.rs
  • crates/sessions/src/lib.rs
  • crates/sessions/src/store.rs
  • crates/sessions/src/wire/errors.rs
  • crates/sessions/src/wire/request.rs
🚧 Files skipped from review as they are similar to previous changes (8)
  • crates/sessions/src/wire/errors.rs
  • crates/minimald/src/rpc.rs
  • crates/sessions/src/lib.rs
  • crates/minimal/src/main.rs
  • crates/sessions/src/store.rs
  • crates/sessions/src/wire/request.rs
  • crates/sessions/src/daemon/composer.rs
  • crates/minimald/src/sessions.rs

@evanspearman
evanspearman merged commit 479ea7b into main Jul 3, 2026
39 checks passed
@evanspearman
evanspearman deleted the evan/createsession-wire3 branch July 3, 2026 20:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants