Skip to content

Implement AgentSessionService gRPC handler (closes #1119) - #1148

Merged
malibio merged 3 commits into
mainfrom
worktree-issue-1119-agent-session-service
May 18, 2026
Merged

malibio merged 3 commits into
mainfrom
worktree-issue-1119-agent-session-service

Conversation

@malibio

@malibio malibio commented May 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Acceptance criteria

  • AgentSessionService handler is registered with the nodespaced gRPC server (both headless + tray loops).
  • LaunchSession creates a PTY session and returns the session ID.
  • StreamOutput opens a server-streaming response that delivers OutputChunk messages in real time.
  • WriteInput forwards bytes to the PTY stdin.
  • ResizeTerminal resizes the PTY.
  • TerminateSession kills the session and triggers cleanup.
  • ListSessions returns metadata for all active sessions.
  • Client disconnecting from StreamOutput does not kill the session.
  • Integration test: launch a session running echo hello, verify StreamOutput delivers the output.
  • cargo build --bin nodespaced succeeds.
  • Code passes bun run quality:fix.

Test plan

  • cargo build --bin nodespaced — success
  • cargo test -p nodespace-daemon — 24 passed (6 unit + 8 agent-session integration + 10 grpc-round-trip)
  • cargo test -p nodespace-agent --lib --features testing — 250 passed
  • bun run quality:fix — clean (eslint + svelte-check + rustfmt + clippy with -D warnings -D clippy::unused-async)
  • bun run test — 3918/3918 passed across 128 files (matches baseline)
  • bun run test:all — exit 0

Notable design decisions

Proto rewrite. The proto from #1111 used raw command/args/cwd/env; the engine from #1118 takes AgentType + Option<String> prompt + &GraphContextAssembler. The rewrite aligns the proto with the engine — see the commit body for the full rationale. Nothing else in the workspace consumed the old proto.

Test helper exposure. PtySession::launch_for_test and session_dir_path moved from pub(crate) + #[cfg(test)] to pub + #[cfg(any(test, feature = "testing"))] so the daemon's integration tests can spawn sh/cat/echo without a real agent binary on PATH. The testing feature already exists in the agent crate for the same reason (ollama_integration tests).

Lint-clean error helpers. parse_session_id and parse_agent_type return Result<T, String> rather than Result<T, Status> to avoid tripping clippy::result_large_err (tonic::Status is ~176 bytes, dwarfs Uuid/AgentType). Call sites wrap the string in Status::invalid_argument in one line.

Drive-by fix from rebase. PR #1147 added id + insert_after_node_id fields to CreateNodeRequest but didn't update packages/daemon/tests/grpc_round_trip.rs or packages/cli/{src/commands/node.rs, tests/cli_integration.rs}. The existing rust:test script only runs nodespace-core --lib, so the breakage didn't surface in CI. Added the two missing fields (String::new()) so the full daemon test binary and cli build compile on top of main.

🤖 Generated with Claude Code

Wire the PTY engine from #1118 into the nodespaced gRPC server. The new
AgentSessionService exposes the six PTY operations — Launch, StreamOutput
(server-streaming), WriteInput, ResizeTerminal, TerminateSession,
ListSessions — so the Tauri UI and CLI can drive agent sessions over
loopback gRPC.

## Layout

- packages/daemon/src/services/agent_session_service.rs (new) — tonic
  trait impl, parses kebab-case agent_type into AgentType, adapts each
  RPC into the matching PtySessionManager / PtySession call, returns
  status codes per failure mode (InvalidArgument for bad UUID / unknown
  agent_type, NotFound for missing sessions, Internal for PTY errors).
- packages/daemon/src/services/mod.rs — re-export AgentSessionHandler.
- packages/daemon/src/main.rs — refactor build_node_service →
  build_services so both headless and tray loops register
  NodeServiceServer *and* AgentSessionServiceServer.
- packages/daemon/Cargo.toml — add nodespace-agent (path dep) plus
  uuid for ID parsing.

## Proto alignment

packages/daemon/proto/agent_session_service.proto: the original message
shape (raw command/args/cwd/env, separate stdout/stderr StreamKind,
optional exit code on SessionInfo) predates the PtySessionManager API
that landed in #1118 and would have required either (a) a parallel
"raw-command" launch path that bypasses the agent catalog +
GraphContextAssembler, or (b) a translation layer that maps strings
back into the AgentType enum. Both are pure technical debt for a
greenfield daemon.

This commit rewrites the proto to match the engine:
  - LaunchSessionRequest is {agent_type, prompt, cols, rows} — the
    fields the issue body specifies and PtySessionManager::launch
    actually accepts.
  - OutputChunk drops StreamKind (PTYs merge stdout/stderr at the
    kernel level, there is no way to split them at this layer).
  - SessionInfo trims to {session_id, agent_type, started_at}
    matching the existing SessionMetadata struct. Status/exit-code
    fields would have required new bookkeeping in the manager for no
    consumer in this PR.
  - ResizeResponse / ListSessionsRequest become empty — the echoed
    cols/rows and status_filter were unused by every caller.

Nothing else in the workspace consumed the old proto (only the daemon's
lib.rs re-exported SessionInfo and no field was read), so the rewrite
is safe.

## StreamOutput

Subscribes to the session's broadcast::Receiver<OutputChunk> and
forwards chunks as proto OutputChunk messages. On RecvError::Lagged
the handler logs and continues — losing a render frame is preferable
to tearing the stream down. On RecvError::Closed the stream ends
naturally. Client disconnect drops only the subscriber, never the
session (acceptance criterion #8); integration test
stream_output_disconnect_does_not_kill_session pins that contract.

## Test helper visibility

PtySession::launch_for_test moved from pub(crate) + #[cfg(test)] to
pub + #[cfg(any(test, feature = "testing"))] so the daemon's
integration tests can spawn shell utilities (echo, cat, sleep) without
a real agent binary on PATH. The "testing" feature already exists in
the agent crate for the same reason (ollama_integration tests). Same
treatment for session_dir_path.

## Tests

packages/daemon/tests/agent_session_grpc.rs (new) — 8 integration
tests against the in-process tonic server:
  - stream_output_delivers_echo_hello (acceptance criterion #9)
  - stream_output_disconnect_does_not_kill_session (criterion #8)
  - write_input_is_echoed_back_through_stream (round-trip through cat)
  - list_sessions_reflects_manager_state
  - resize_terminal_succeeds_for_active_session
  - terminate_unknown_session_reports_was_not_running
  - unknown_session_returns_not_found (both StreamOutput and WriteInput)
  - invalid_session_id_returns_invalid_argument

Plus 4 unit tests in agent_session_service.rs::tests covering the
kebab/snake_case agent_type parsing, round-trip, UUID rejection.

LaunchSession itself is exercised indirectly: the production launch
path resolves the agent binary on PATH (which CI does not have), so
tests seed the manager via PtySession::launch_for_test + manager.insert
and drive every other RPC through the full gRPC stack.

## Lint note

parse_session_id and parse_agent_type return Result<T, String> rather
than Result<T, Status>. tonic::Status is ~176 bytes; Uuid is 16 and
AgentType is 1, so Result<T, Status> trips clippy::result_large_err.
The call sites lift the String into Status::invalid_argument in one
line — keeps the parsers status-agnostic and the lint clean without
any allow attribute.

## Drive-by fix from rebase

PR #1147 added id + insert_after_node_id to CreateNodeRequest but did
not update existing call sites in packages/daemon/tests/grpc_round_trip.rs
or packages/cli/{src/commands/node.rs, tests/cli_integration.rs}. The
existing rust:test script only covers nodespace-core --lib, so the
breakage didn't surface in CI. Added the two missing fields (both
defaulting to String::new()) so the daemon test binary and cli build
compile on top of main.

## Verified

- cargo build --bin nodespaced: success
- cargo test -p nodespace-daemon: 24 passed (6 unit + 8 agent-session
  integration + 10 grpc-round-trip)
- cargo test -p nodespace-agent --lib --features testing: 250 passed
- bun run quality:fix: clean (eslint + svelte-check + rustfmt + clippy)
- bun run test: 3918/3918 passed across 128 files (matches baseline)
- bun run test:all: success (exit 0)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@malibio malibio left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review Summary

Verdict: Approve with minor improvements. This is a high-quality, net-positive PR that lands a clean gRPC adapter over the PTY engine from #1118. All 11 acceptance criteria are met, all 8 integration tests pass against the in-process tonic server, clippy is clean under -D warnings -D clippy::unused-async, and the design rationale in the PR body is well-reasoned.

The proto rewrite is the right call — aligning the wire format with the engine's actual contract rather than maintaining a translation layer matches the project's greenfield "no backwards-compat" directive in CLAUDE.md. The split of build_node_service → build_services returning a DaemonServices bundle is a clean refactor that scales naturally as more services land. Test coverage of the streaming/disconnect contract (the trickiest piece) is genuinely thorough.

A few small but worthwhile improvements are flagged below — none are blockers.


Findings

Critical Issues

None.


Suggested Improvements

1. [Improvement] — LaunchSessionResponse.created_at should reflect the session's actual started_at, not a fresh Utc::now()

packages/daemon/src/services/agent_session_service.rs:65

let created_at = current_unix_secs();
Ok(Response::new(LaunchSessionResponse {
    session_id: id.to_string(),
    created_at,
}))

PtySession.started_at is set inside spawn_in_pty before the reader/watcher tasks are wired (session.rs:201) and is the authoritative timestamp. The handler then takes a second Utc::now() after manager.launch().await and an optional apply_resize round-trip — these are small but non-zero gaps, and the timestamp returned to the client is therefore later than the one ListSessions reports for the same session.

Suggested fix (one extra manager.get(&id) after launch):

let id = self.manager.launch(...).await.map_err(...)?;
if req.cols != 0 && req.rows != 0 {
    apply_resize(&self.manager, &id, req.cols, req.rows).await?;
}
let created_at = self
    .manager
    .get(&id)
    .await
    .map(|s| s.started_at.timestamp())
    .unwrap_or_else(current_unix_secs);

The unwrap_or_else covers the (vanishingly unlikely) case where the session exits and the auto-prune watcher removes it between launch and get. Cleaner alternative: have PtySessionManager::launch return (Uuid, DateTime<Utc>) — but that's a wider change and arguably belongs in a follow-up.

Engineering principle: single source of truth. A client comparing LaunchSessionResponse.created_at to SessionInfo.started_at should see equality, not a few-millisecond drift.


2. [Improvement] — WriteInputResponse.bytes_written: len as i32 can silently truncate or wrap negative

packages/daemon/src/services/agent_session_service.rs:140

let len = req.data.len();
// ...
bytes_written: len as i32,

req.data.len() is usize. For payloads ≥ 2 GiB, as i32 either truncates or produces a negative number (the cast is wraparound, not saturating). The proto comment says this always equals data.len() on success, so the truncation would silently violate the contract.

In practice nobody is going to send a 2 GiB keystroke buffer to a PTY, but the same as i32 lives in ListSessionsResponse.count (line 187) where a misbehaving caller could plausibly leak more than i32::MAX sessions over a long-lived daemon (no, they really couldn't — but the cast is still wrong-shaped).

Two options, in order of preference:

  1. Change the proto — bytes_written: int32 → uint64, count: int32 → uint32. Greenfield, no users, single consumer (this handler) — exactly the kind of change the project's no-backwards-compat directive enables.
  2. Saturate the cast — bytes_written: i32::try_from(len).unwrap_or(i32::MAX). Same silent-clamp problem as today, just bounded.

Option 1 is cleaner and matches the project's stance on schema evolution.

Engineering principle: fail loudly or model correctly. A silent truncating cast is the worst of both worlds.


3. [Improvement] — apply_resize performs a redundant manager.get() after LaunchSession

packages/daemon/src/services/agent_session_service.rs:62, 232–252

launch_session calls apply_resize(..) immediately after manager.launch(..). Inside apply_resize, the first thing it does is manager.get(id) to find the session it just created. That second lookup acquires the manager's Mutex<HashMap> for the second time in two lines.

The cost is real but small (one async lock acquisition). More importantly, between launch and apply_resize, the session could in principle have exited (e.g., the spawned binary crashes immediately) and been pruned, causing the freshly-launched session's resize to return NotFound to the caller — confusing because the launch just succeeded.

Cleaner shape: have PtySessionManager::launch accept an optional initial PtySize, or have launch_session hold an Arc<PtySession> from a single lookup and pass it to a session-direct resize helper. Either keeps the launch-with-dimensions path atomic.

Nit-level — the current behavior is documented and correct for the success case — but worth a follow-up if the daemon grows more launch-time parameters.


4. [Improvement] — StreamOutput lagged-chunk swallowing is invisible to the client

packages/daemon/src/services/agent_session_service.rs:99–110

You explicitly asked about this in the review prompt. My read: a tracing::warn! is the right floor, but consider also emitting a single OutputChunk with data: vec![] and a marker byte sequence (e.g., literal b"\x1b[?25h\r\n[output dropped: N bytes]\r\n") so the rendered terminal visually shows the gap. ANSI-aware UIs already render this kind of control sequence inline.

Alternatively, expose lagged_chunks_total as a session-level metric returned by ListSessions — that's a more conservative interface change.

Lowest-risk option: leave the current behavior, add assert!(skipped > 0) and a TODO note pointing at this discussion so future maintainers don't have to rediscover the trade-off. The current behavior is reasonable; the only failure mode is the user not knowing they lost frames.

Engineering principle: observable degradation > silent degradation. The cost of telling the user "you missed N bytes" is one extra protobuf field; the cost of not telling them is a confused bug report when their terminal scrollback skips a chunk.


5. [Improvement] — apply_resize's 65535 ceiling is fine, but the proto should match

packages/daemon/src/services/agent_session_service.rs:243–246

You also asked about this. The u16 boundary is correct — portable_pty::PtySize uses u16 for rows/cols, and no real terminal goes past that. The right move is to change the proto from uint32 to a comment that documents the [1, 65535] range and let the try_from reject the rest with InvalidArgument (which is what you're doing).

Two strictly-stronger improvements:

  1. Reject zero values too — cols: 0, rows: 0 to ResizeTerminal (as opposed to LaunchSession, where zero means "use default") would currently call portable-pty with PtySize { rows: 0, cols: 0, ... }, which the platform may or may not reject. Better to fail at the API boundary.
  2. The proto comment could note the valid range explicitly.

6. [Improvement] — Integration tests do not exercise the nodespace_agent::pty Cargo.toml feature-unification assumption

packages/daemon/Cargo.toml:42–47

nodespace-agent is a regular dependency and nodespace-agent = { features = ["testing"] } is a dev-dependency. Cargo unifies features across dep/dev-dep within a single package, so during cargo test -p nodespace-daemon, the daemon's production code is compiled against nodespace-agent with the testing feature enabled.

In this PR that's fine — testing only widens pub(crate) to pub and doesn't change behavior. But it's an easy footgun if someone later puts #[cfg(feature = "testing")] on something more invasive. A defensive measure is to add a compile_error! in the agent crate if testing and (some hypothetical) production feature are both enabled, or just to document this on the testing feature in packages/agent/Cargo.toml.

The comment in the daemon's Cargo.toml is good; consider mirroring it in the agent's Cargo.toml [features] section.


7. [Improvement] — Drive-by String::new() for the new id field is correct, but the integration tests should pin the server-side UUID generation contract

packages/daemon/tests/grpc_round_trip.rs:96, 138, 183, 199, 250, 348, 437, 498, 527 and the cli sites.

You explicitly asked. String::new() matches the proto's "empty = server generates" contract, and the test coverage in nodespace-core for the caller-supplied-UUID path is presumably already there (in nodespace-core per #1147). What's missing in the daemon test suite is a single test that confirms the daemon layer correctly passes the caller-supplied UUID through to core when non-empty — that's the contract the gRPC handler is responsible for honoring.

Suggested follow-up (out of scope for this PR): one extra test in grpc_round_trip.rs that does:

let supplied_id = Uuid::new_v4().to_string();
let resp = client.create_node(CreateNodeRequest { id: supplied_id.clone(), ... }).await?;
assert_eq!(resp.into_inner().id, supplied_id);

For this PR specifically, the String::new() drive-by is the right minimal fix.


Nitpicks

  • Nit: packages/daemon/src/services/agent_session_service.rs:221–230 — agent_type_to_string falls back to format!("{agent_type:?}") if serde fails. You acknowledged this is dead code today. A debug_assert!(false, ...) would catch a future serde regression in CI without breaking production. Or use serde::ser::Error more explicitly via serde_plain::to_string which can't fail for unit-variant enums. Optional polish.

  • Nit: packages/daemon/src/services/agent_session_service.rs:212–219 — parse_agent_type accepts both kebab- and snake-case for "historical/UX reasons". For a greenfield project with zero callers, this dual-accept is exactly the kind of "backward-compat code" CLAUDE.md says to delete. Pick one canonical form (the kebab-case serde form is the obvious choice) and reject the other with a clear error pointing at the right spelling. The snake-case acceptance is already breeding ambiguity — the proto comment names claude_code but the canonical form is claude-code.

  • Nit: packages/daemon/proto/agent_session_service.proto:35–37 — the comment "Values of 0 fall back to the engine default (80x24); callers are expected to resize as soon as they know the real terminal geometry" is great. Consider applying the same "0 means default" convention to ResizeTerminal and document it, or explicitly call out that ResizeTerminal rejects zero (consistent with point #5).

  • Nit: packages/daemon/tests/agent_session_grpc.rs:78–86 — the connection-retry loop polls every 25ms for 50 iterations (1.25s ceiling). If the loop exits via panic!, last_err is always Some by construction (first iteration always sets it on failure), but the Option indirection obscures that. Replace with a Result accumulator or just unwrap() (the panic! formatter already prints Some(e) correctly).

  • Nit: packages/daemon/src/main.rs:188–199 — embedding_service = None is wired identically in two spots (the node and the assembler arms). Consider naming this None::<Arc<dyn EmbeddingService>> once and binding to both, or leaving a single-line comment noting both consumers will be wired by the same follow-up.

  • Nit: packages/daemon/src/services/agent_session_service.rs:188 — count: count could be count (field-init-shorthand), but clippy didn't flag it so it's clearly fine.


Acceptance Criteria Verification

All 11 criteria from #1119 are met. Verified locally:

  • cargo build --bin nodespaced — clean (10.80s)
  • cargo test -p nodespace-daemon --lib — 6/6 passed (parse helpers + tray)
  • cargo test -p nodespace-daemon --test agent_session_grpc — 8/8 passed
  • cargo clippy -p nodespace-daemon --all-targets -- -D warnings -D clippy::unused-async — clean (24.22s)

stream_output_disconnect_does_not_kill_session (lines 262–307 of the test file) is the key behavioural test and it exercises the critical-path contract correctly: dropping the stream client-side does not propagate to the session, which is verified by a subsequent list_sessions showing the session still present.

Standards Compliance

  • ✅ No #[allow(...)] introduced in new code. The single #![allow(clippy::all)] in lib.rs is pre-existing and scoped to the auto-generated proto module — standard tonic practice, not a violation.
  • ✅ No #[deprecated], no migration code, no dual-mode support.
  • ✅ Greenfield-correct: proto rewrite without translation layer is exactly what CLAUDE.md prescribes.
  • ✅ Rust-only PR, so the frontend createLogger/Bun rules don't apply.
  • ⚠️ One minor exception: the snake-case agent-type acceptance in parse_agent_type is technically backwards-compat code (see Nit above). Recommend removing.

Security

  • ✅ All session_id inputs are parsed through Uuid::from_str before use — no injection vector via the session_id field.
  • ✅ WriteInput forwards raw bytes to the PTY verbatim, which is the correct behavior for a terminal (Ctrl-C, escape sequences, etc., must pass through). The bytes field is length-bounded by tonic's default message size limits.
  • ✅ LaunchSession only accepts an enumerated agent_type and an Option<String> prompt — there is no path to inject an arbitrary command or arguments. The actual binary is resolved by the agent registry, not the request.
  • ✅ No secrets, no PII in logs. session_id in the lagged warn is a UUID — fine.

Performance

  • The manager.get(&id) lookups acquire the manager's Mutex<HashMap> per RPC. For the expected workload (a handful of agent sessions per user) this is fine. If session counts ever grow into the hundreds and ListSessions becomes hot, swap the inner Mutex<HashMap> for DashMap or RwLock<HashMap>. Out of scope here.
  • StreamOutput clones each OutputChunk.data: Vec<u8> once when copying into the proto message. For typical PTY chunk sizes (≤4 KiB per READ_BUFFER_BYTES) this is negligible; a future optimization could use Bytes end-to-end if profiling shows it matters.
  • No N+1 patterns, no blocking calls on the async runtime.

Excellent work — ship it. The improvements above are worth a follow-up but none are blockers.

Implements 7 of 12 suggestions from the PR #1148 pragmatic review. Each
change is small and self-contained; nothing affects the overall
architecture or behavior beyond what the review identified.

## Addressed

### 1. LaunchSessionResponse.created_at uses authoritative started_at

Previously the handler took a fresh `Utc::now()` after `manager.launch()`
returned. The session's own `started_at` is set inside `spawn_in_pty`
before the reader/watcher tasks are wired, so the wire-level timestamp
drifted a few milliseconds behind it — and ListSessions reported the
earlier (correct) one. Now `launch_session` does one `manager.get()`
after launch and uses `session.started_at.timestamp()`. Single source of
truth between LaunchSession and ListSessions.

### 2. Widen proto numeric fields to avoid silent casts

- `WriteInputResponse.bytes_written`: int32 → int64. `usize as i32` was
  wraparound on 2 GiB+ payloads — unlikely for a PTY but the contract is
  the contract.
- `ListSessionsResponse.count`: int32 → uint32. Saturating cast via
  `u32::try_from(...).unwrap_or(u32::MAX)` keeps the count monotonic for
  clients that diff snapshots.

Greenfield-correct per CLAUDE.md (single consumer, no migration cost).

### 3. Hold session Arc through launch+resize

`launch_session` now does one `manager.get(&id)` after launch and holds
the resulting `Arc<PtySession>` for both `started_at` and the optional
initial resize. Eliminates the redundant lock acquisition AND closes a
small race where the spawned child could exit and the auto-prune watcher
remove the entry between launch() and apply_resize(), returning NotFound
for a session we just created.

The old `apply_resize(manager, id, ...)` helper is replaced by
`resize_session(&Arc<PtySession>, ...)`; `resize_terminal` does its own
manager lookup since it doesn't have an Arc from elsewhere.

### 4. StreamOutput lag visibility (lowest-risk variant)

Kept the current swallow-and-warn behavior — surfacing dropped bytes
inline via a sentinel chunk or a session metric is a wider design
change that should land in its own PR. Added a `debug_assert!(skipped > 0)`
to catch future bugs that produce zero-byte lag events, and a TODO note
pointing at the review discussion so the trade-off is visible to future
maintainers.

### 5. ResizeTerminal rejects zero dimensions

Unlike LaunchSession (0 = use default), ResizeTerminal has no default
semantic — its job is "apply exactly this size". Now returns
`InvalidArgument` if either axis is zero, rather than passing through
to `portable_pty` with `PtySize { rows: 0, cols: 0, .. }` which is
platform-dependent. Added integration test `resize_terminal_rejects_
zero_dimensions` pinning the contract across (0, N), (N, 0), (0, 0).
Proto comment documents the [1, 65535] valid range.

### Nit: Remove snake_case agent_type acceptance

Per CLAUDE.md's no-backwards-compat directive: the previous
`raw.replace('_', "-")` was dead weight for a greenfield API with zero
callers. Kebab-case is the only accepted form now. Added
`parse_agent_type_rejects_snake_case` test pinning the new rejection so
a future refactor cannot silently restore the dual-accept path.

### 6. Document testing-feature footgun in agent crate

Expanded the `[features] testing = []` comment in
`packages/agent/Cargo.toml` to explain Cargo's feature-unification
behavior across dep/dev-dep within a single downstream package, and the
rule that test-only stubs behind this feature must only widen
visibility — never change runtime behavior, swap implementations, or
skip safety checks. Test-only mutating stubs belong in test-only
modules in the downstream crate, not behind this flag.

## Skipped, with reasoning

- **#7 Daemon test for caller-supplied UUID path through CreateNode**:
  Reviewer explicitly marked this "out of scope for this PR" and the
  contract is already exercised in nodespace-core per #1147. Worth a
  follow-up issue if the daemon adapter ever diverges from a simple
  pass-through.

- **Nit: `agent_type_to_string` debug_assert**: The dead-code fallback
  to `format!("{agent_type:?}")` is genuinely defensive — it makes the
  function infallible if `AgentType` ever gains a non-serde variant in
  the future. A `debug_assert!(false)` would catch that in test, but
  would also panic in production for a problem that's recoverable. The
  current shape is the right trade-off.

- **Nit: connect-retry loop `last_err` Option indirection**: Purely
  stylistic and the existing shape already does the right thing
  (`panic!` formats `Some(e)` fine). Not worth a churn commit.

- **Nit: `count: count` field-init shorthand**: Clippy didn't flag and
  the explicit form is consistent with other struct literals in the
  same file. No-op.

- **Nit: `embedding_service = None` duplication in main.rs**: Will
  naturally resolve when the embedding service is wired by the follow-
  up issue (currently both arms become `Some(embedding_service.clone())`).
  Introducing a binding now would just need un-introducing later.

## Verified

- `cargo build --bin nodespaced`: clean
- `cargo test -p nodespace-daemon`: 26 passed (7 unit + 9 agent-session
  integration + 10 grpc-round-trip; added zero-reject + snake-case
  rejection tests)
- `cargo clippy -p nodespace-daemon --all-targets -- -D warnings -D
  clippy::unused-async`: clean
- `bun run quality:fix`: clean (eslint + svelte-check + rustfmt + clippy
  across the full workspace)
- `bun run test`: 3918/3918 across 128 files (matches baseline)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@malibio

malibio commented May 18, 2026

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed — 7 of 12 implemented

Pushed as 10041e3. Summary of the response to each item below.

✅ Addressed

# Item Change
1 LaunchSessionResponse.created_at should equal started_at Now sourced from session.started_at.timestamp() via one extra manager.get() after launch. Single source of truth between LaunchSession and ListSessions.
2 Silent usize as i32 casts Proto widened: bytes_written int32 → int64; count int32 → uint32. Saturating cast on count via u32::try_from(...).unwrap_or(u32::MAX).
3 Redundant manager.get() in launch+resize Single lookup; hold Arc<PtySession> through both started_at and optional initial resize. Closes the small race where the auto-prune watcher could remove the entry between launch and resize. New helper resize_session(&Arc<PtySession>, ...) replaces apply_resize(manager, id, ...).
4 StreamOutput lag invisible to client Lowest-risk variant — kept the swallow-and-warn behavior (surfacing dropped bytes inline is a wider design change that warrants its own issue), added debug_assert!(skipped > 0) and a TODO note pointing at the review discussion.
5 ResizeTerminal accepts zero dimensions Now returns InvalidArgument when either axis is zero. New integration test resize_terminal_rejects_zero_dimensions pins the contract across (0,N), (N,0), (0,0). Proto comment documents the [1, 65535] valid range.
6 Document testing-feature footgun Expanded the [features] testing = [] comment in packages/agent/Cargo.toml to explain Cargo's feature-unification across dep/dev-dep and the rule that gates behind this flag must only widen visibility — never change runtime behavior.
Nit Snake-case agent_type acceptance Removed (per CLAUDE.md no-backwards-compat). Kebab-case is the only accepted form. New test parse_agent_type_rejects_snake_case pins the rejection so a future refactor cannot silently restore the dual-accept path.

⏭️ Skipped, with reasoning

  • Svelte frontend renders with placeholder panels #7 Daemon test for caller-supplied UUID through CreateNode: Reviewer marked "out of scope for this PR"; contract is already exercised in nodespace-core per Migrate Tauri command handlers to gRPC proxy (closes #1113) #1147. Worth a follow-up issue if the daemon adapter ever diverges from a simple pass-through.
  • Nit: agent_type_to_string debug_assert: The dead-code fallback format!("{agent_type:?}") is genuinely defensive — keeps the function infallible if AgentType ever gains a non-serde variant later. A debug_assert!(false) would catch that in test but would also panic in production for a recoverable problem.
  • Nit: connect-retry last_err Option indirection: Stylistic; the existing shape already does the right thing (panic! formats Some(e) fine). Not worth a churn commit.
  • Nit: count: count field-init shorthand: Clippy didn't flag; the explicit form is consistent with other struct literals in the same file.
  • Nit: embedding_service = None duplication in main.rs: Will naturally resolve when the embedding service is wired by the follow-up issue (both arms become Some(embedding_service.clone())). Introducing a binding now would need un-introducing later.

Verified

  • cargo build --bin nodespaced: clean
  • cargo test -p nodespace-daemon: 26 passed (7 unit + 9 agent-session integration + 10 grpc-round-trip — added 2 new tests: zero-reject + snake-case rejection)
  • cargo clippy -p nodespace-daemon --all-targets -- -D warnings -D clippy::unused-async: clean
  • bun run quality:fix: clean (full workspace)
  • bun run test: 3918/3918 across 128 files (matches baseline)

🤖 Generated with Claude Code

@malibio

malibio commented May 18, 2026

Copy link
Copy Markdown
Collaborator Author

Re-Review of commit 10041e3 (LGTM — would approve if not own PR)

Reviewed only the changes since commit 4ce8c73d (the initial review point). Verified locally with cargo build --bin nodespaced, cargo test -p nodespace-daemon (26/26 passing, including the new resize_terminal_rejects_zero_dimensions), and cargo clippy -p nodespace-daemon --all-targets -- -D warnings -D clippy::unused-async (clean).

Summary

This is a tight, focused commit that addresses every reviewer item the author committed to. Each fix is grounded in the right principle (single source of truth, type-correct contracts, defensive boundaries) and is accompanied by a comment explaining the why.

Verification of addressed items

#1 created_at matches started_at (verified)

  • LaunchSessionResponse.created_at now reads session.started_at.timestamp() via one manager.get() after launch. Confirmed SessionMetadata::from_session reads the same PtySession.started_at field — true single source of truth between LaunchSession and ListSessions.
  • The unwrap_or_else(current_unix_secs) fallback is genuinely reachable (auto-prune watcher can fire if the child exits between launch() and get()). The comment correctly describes this as "vanishingly unlikely" rather than dead code. Good call not turning it into expect().

#3 Single Arc<PtySession> lookup (verified)

  • launch_session now holds one Option<Arc<PtySession>> through both started_at extraction and the optional initial resize. The new resize_session(&Arc<PtySession>, ...) helper takes the Arc directly, so the second manager lookup is gone. The Arc also closes the auto-prune race the author called out.
  • Ordering is correct: started_at is captured at PTY spawn time (line 201 in pty/session.rs), so the resize-after-timestamp-read order has no semantic impact.

#5 ResizeTerminal rejects zero (verified)

  • Rejection happens at the API boundary before manager lookup, returning InvalidArgument with a clear error message. Test resize_terminal_rejects_zero_dimensions pins all three permutations (0, N), (N, 0), (0, 0). Proto comment documents the [1, 65535] range and contrasts the semantics against LaunchSession. Clean.

#2 Proto widening (verified)

  • WriteInputResponse.bytes_written: int32 → int64. Cast becomes len as i64 which is lossless on every platform with usize ≤ 64 bits. Correct.
  • ListSessionsResponse.count: int32 → uint32 with saturating cast u32::try_from(sessions.len()).unwrap_or(u32::MAX). Monotonic-for-snapshots reasoning is sound.
  • No external consumer breakage: AgentSessionService is only consumed by nodespace-daemon Rust code today (no agent-tools TS or CLI usage), so the wire format change is safe.

#4 RecvError::Lagged debug_assert + TODO (verified)

  • Per tokio's broadcast channel implementation, Lagged(n) is only emitted when lost > 0, so debug_assert!(skipped > 0) will not produce spurious failures. Safe in test, no-op in release. The TODO with concrete deferred options is exactly the right level of follow-up.

#6 Cargo feature footgun documentation (verified)

  • The expanded [features] testing = [] comment in packages/agent/Cargo.toml clearly states the rule: gates behind this flag must only widen visibility, never change runtime behavior. Captures the cross-package feature-unification gotcha precisely. This will save a future contributor hours.

#7 (nit) Snake-case rejection (verified)

  • parse_agent_type no longer normalizes _ → -. The new parse_agent_type_rejects_snake_case test loops ["claude_code", "gemini_cli", "open_code"] and asserts each errors. No other path silently accepts snake-case — agent_type_to_string round-trips through serde's kebab-case serializer, so a parse → format round-trip stays in the kebab namespace. CLAUDE.md no-backwards-compat is correctly enforced.

Skipped items — was the reasoning sound?

Out-of-scope #7 (daemon-side caller-UUID test): Correctly deferred — that test belongs with the daemon's caller-id behavior, not this PR.

Nit: agent_type_to_string fallback: The author's rationale ("infallible if AgentType gains a non-serde variant") is weaker than the alternatives (unreachable!() or expect()), since AgentType is in the same workspace and adding a non-serde variant would be a deliberate breaking change. That said: this is a Nit either way, and the fallback is a single line that costs nothing. Accepting.

Nit: embedding_service = None duplication: The author's "will resolve naturally when wired" is technically true (both consumers will take the same Arc<NodeEmbeddingService>), but a single shared binding really would be cleaner today. Carrying forward as a minor follow-up isn't worth blocking on — flagging for awareness.

Nit: last_err Option indirection and count: count shorthand: Pure style; defer.

Findings

Suggested Improvements

  • launch_session silent resize skip (packages/daemon/src/services/agent_session_service.rs:75-79): If manager.get() returns None and the caller passed non-zero cols/rows, the resize is silently skipped and we still return a successful LaunchSessionResponse with session_id. The previous code would have returned NotFound from apply_resize. The new behavior is friendlier (don't fail on a phantom race), but the client now holds a session_id they cannot subsequently use (next RPC will return NotFound). Consider returning Status::failed_precondition or not_found here, or at minimum logging at warn so the server-side trace makes the situation discoverable. This is small and not a merge-blocker; flagging because the contract drifted slightly compared to the old apply_resize path.

Nitpicks

  • Nit (agent_session_service.rs:271): The "infallible if a new variant appears" comment on agent_type_to_string could be tightened — since AgentType is in-workspace, this is genuinely unreachable!()-level. Keeping the fallback is fine, but consider noting that adding a new variant should be accompanied by adding it to the parse/format pair, not relying on the debug-format escape hatch.

Standards compliance

  • No new #[allow(...)] attributes.
  • No #[deprecated] markers.
  • No migration code.
  • debug_assert! is the right choice — only runs in debug builds, won't impact production behavior.
  • No backwards-compat aliases (snake-case removal is exactly the CLAUDE.md no-backwards-compat directive in action).

Local verification

  • cargo build --bin nodespaced — passes.
  • cargo test -p nodespace-daemon — 26/26 passing (7 unit + 9 agent_session_grpc + 10 grpc_round_trip).
  • cargo clippy -p nodespace-daemon --all-targets -- -D warnings -D clippy::unused-async — clean.

LGTM. (Would have approved via gh pr review --approve but GitHub blocks self-approval on own PRs.)

@malibio malibio left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-review complete — LGTM. Full report posted as a PR comment: #1148 (comment)

Verified locally: build clean, 26/26 tests passing (including the new resize_terminal_rejects_zero_dimensions), clippy clean with -D warnings -D clippy::unused-async.

Only two non-blocking notes:

  • Improvement: launch_session silently no-ops the initial resize if the session was auto-pruned between launch and get (returns success with an unusable session_id). Worth a warn-log or a failed_precondition status.
  • Nit: agent_type_to_string's debug-format fallback comment could acknowledge that AgentType is in-workspace and a new variant should update parse/format together, not lean on the fallback.

Nothing here should block merge.

Two small follow-ups from the re-review of 10041e3, plus a flake fix
discovered while verifying.

## Addressed

### Re-review suggestion: warn-log silent resize skip in launch_session

If the auto-prune watcher removes the session entry between launch() and
the get() that pins the Arc for the optional initial resize, the resize
silently no-op'd and the client got a successful LaunchSessionResponse
with a session_id that the next RPC would reject as NotFound. Now logs
at `tracing::warn!` with session_id + requested dims so the server-side
trace makes the dropped resize discoverable.

Kept the response semantics unchanged: the launch itself succeeded, so
returning Status::failed_precondition would force every client to
handle a confusing "launch succeeded but failed to resize" mid-failure.
The next RPC's NotFound is a clearer signal for the (vanishingly rare)
race that this branch handles.

### Re-review nit: agent_type_to_string fallback comment

Updated the comment to explicitly state that the debug-format fallback
is defense-in-depth, not a graceful-degradation path. AgentType is
in-workspace and closed, so adding a new variant must extend
parse_agent_type / agent_type_to_string together — the fallback is not
a shortcut.

## Flake fix (discovered during verification)

`stream_output_delivers_echo_hello` was timing-sensitive: on a fast
machine the PTY reader could drain `echo hello` and broadcast the
chunk before the gRPC client's StreamOutput subscriber attached.
`tokio::sync::broadcast` does not replay missed messages to late
subscribers (unlike `watch`), so the test would fail ~10% of runs.

Changed the command from `echo hello` to `sleep 0.2 && echo hello` so
the subscriber is guaranteed to attach before bytes hit the broadcast
channel. Comment in the test body explains why. 5/5 passing after the
change.

This is a pre-existing flake in the original PR; the re-review missed
it because the parallel test run masked it. Catching it now means it
won't show up as intermittent CI failures later.

## Verified

- cargo test -p nodespace-daemon: 26 passed (run 5x to confirm no flake)
- cargo clippy -p nodespace-daemon --all-targets -- -D warnings -D
  clippy::unused-async: clean
- bun run quality:fix: clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@malibio

malibio commented May 18, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review feedback addressed — 2/2 implemented + flake fix

Pushed as 7fe2194.

✅ Addressed

Item Change
Suggestion: silent resize skip launch_session now logs tracing::warn! with session_id + requested dimensions when the auto-prune race causes the optional initial resize to no-op. Response semantics unchanged (launch succeeded, next RPC's NotFound is the cleaner signal for the rare race).
Nit: agent_type_to_string fallback comment Comment now explicitly states the debug-format fallback is defense-in-depth, not graceful degradation — adding a new AgentType variant must update parse_agent_type / agent_type_to_string together.

🐛 Drive-by flake fix

While verifying, stream_output_delivers_echo_hello was failing ~10% of runs: on a fast machine the PTY reader drained echo hello and broadcast the chunk before the gRPC subscriber attached, and tokio::sync::broadcast doesn't replay missed messages to late subscribers.

Changed the command from echo hello to sleep 0.2 && echo hello so the subscriber is guaranteed to attach first. Confirmed 5/5 passing after the change. Comment in the test body explains why.

Verified

  • cargo test -p nodespace-daemon: 26 passed (re-ran 5x to confirm the flake is gone)
  • cargo clippy -p nodespace-daemon --all-targets -- -D warnings -D clippy::unused-async: clean
  • bun run quality:fix: clean across the full workspace

🤖 Generated with Claude Code

@malibio
malibio merged commit 7507779 into main May 18, 2026
@malibio
malibio deleted the worktree-issue-1119-agent-session-service branch May 18, 2026 03:14
malibio added a commit that referenced this pull request Jun 22, 2026
* Implement AgentSessionService gRPC handler (closes #1119)

Wire the PTY engine from #1118 into the nodespaced gRPC server. The new
AgentSessionService exposes the six PTY operations — Launch, StreamOutput
(server-streaming), WriteInput, ResizeTerminal, TerminateSession,
ListSessions — so the Tauri UI and CLI can drive agent sessions over
loopback gRPC.

## Layout

- packages/daemon/src/services/agent_session_service.rs (new) — tonic
  trait impl, parses kebab-case agent_type into AgentType, adapts each
  RPC into the matching PtySessionManager / PtySession call, returns
  status codes per failure mode (InvalidArgument for bad UUID / unknown
  agent_type, NotFound for missing sessions, Internal for PTY errors).
- packages/daemon/src/services/mod.rs — re-export AgentSessionHandler.
- packages/daemon/src/main.rs — refactor build_node_service →
  build_services so both headless and tray loops register
  NodeServiceServer *and* AgentSessionServiceServer.
- packages/daemon/Cargo.toml — add nodespace-agent (path dep) plus
  uuid for ID parsing.

## Proto alignment

packages/daemon/proto/agent_session_service.proto: the original message
shape (raw command/args/cwd/env, separate stdout/stderr StreamKind,
optional exit code on SessionInfo) predates the PtySessionManager API
that landed in #1118 and would have required either (a) a parallel
"raw-command" launch path that bypasses the agent catalog +
GraphContextAssembler, or (b) a translation layer that maps strings
back into the AgentType enum. Both are pure technical debt for a
greenfield daemon.

This commit rewrites the proto to match the engine:
  - LaunchSessionRequest is {agent_type, prompt, cols, rows} — the
    fields the issue body specifies and PtySessionManager::launch
    actually accepts.
  - OutputChunk drops StreamKind (PTYs merge stdout/stderr at the
    kernel level, there is no way to split them at this layer).
  - SessionInfo trims to {session_id, agent_type, started_at}
    matching the existing SessionMetadata struct. Status/exit-code
    fields would have required new bookkeeping in the manager for no
    consumer in this PR.
  - ResizeResponse / ListSessionsRequest become empty — the echoed
    cols/rows and status_filter were unused by every caller.

Nothing else in the workspace consumed the old proto (only the daemon's
lib.rs re-exported SessionInfo and no field was read), so the rewrite
is safe.

## StreamOutput

Subscribes to the session's broadcast::Receiver<OutputChunk> and
forwards chunks as proto OutputChunk messages. On RecvError::Lagged
the handler logs and continues — losing a render frame is preferable
to tearing the stream down. On RecvError::Closed the stream ends
naturally. Client disconnect drops only the subscriber, never the
session (acceptance criterion #8); integration test
stream_output_disconnect_does_not_kill_session pins that contract.

## Test helper visibility

PtySession::launch_for_test moved from pub(crate) + #[cfg(test)] to
pub + #[cfg(any(test, feature = "testing"))] so the daemon's
integration tests can spawn shell utilities (echo, cat, sleep) without
a real agent binary on PATH. The "testing" feature already exists in
the agent crate for the same reason (ollama_integration tests). Same
treatment for session_dir_path.

## Tests

packages/daemon/tests/agent_session_grpc.rs (new) — 8 integration
tests against the in-process tonic server:
  - stream_output_delivers_echo_hello (acceptance criterion #9)
  - stream_output_disconnect_does_not_kill_session (criterion #8)
  - write_input_is_echoed_back_through_stream (round-trip through cat)
  - list_sessions_reflects_manager_state
  - resize_terminal_succeeds_for_active_session
  - terminate_unknown_session_reports_was_not_running
  - unknown_session_returns_not_found (both StreamOutput and WriteInput)
  - invalid_session_id_returns_invalid_argument

Plus 4 unit tests in agent_session_service.rs::tests covering the
kebab/snake_case agent_type parsing, round-trip, UUID rejection.

LaunchSession itself is exercised indirectly: the production launch
path resolves the agent binary on PATH (which CI does not have), so
tests seed the manager via PtySession::launch_for_test + manager.insert
and drive every other RPC through the full gRPC stack.

## Lint note

parse_session_id and parse_agent_type return Result<T, String> rather
than Result<T, Status>. tonic::Status is ~176 bytes; Uuid is 16 and
AgentType is 1, so Result<T, Status> trips clippy::result_large_err.
The call sites lift the String into Status::invalid_argument in one
line — keeps the parsers status-agnostic and the lint clean without
any allow attribute.

## Drive-by fix from rebase

PR #1147 added id + insert_after_node_id to CreateNodeRequest but did
not update existing call sites in packages/daemon/tests/grpc_round_trip.rs
or packages/cli/{src/commands/node.rs, tests/cli_integration.rs}. The
existing rust:test script only covers nodespace-core --lib, so the
breakage didn't surface in CI. Added the two missing fields (both
defaulting to String::new()) so the daemon test binary and cli build
compile on top of main.

## Verified

- cargo build --bin nodespaced: success
- cargo test -p nodespace-daemon: 24 passed (6 unit + 8 agent-session
  integration + 10 grpc-round-trip)
- cargo test -p nodespace-agent --lib --features testing: 250 passed
- bun run quality:fix: clean (eslint + svelte-check + rustfmt + clippy)
- bun run test: 3918/3918 passed across 128 files (matches baseline)
- bun run test:all: success (exit 0)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address review: tighten contracts, drop backwards-compat

Implements 7 of 12 suggestions from the PR #1148 pragmatic review. Each
change is small and self-contained; nothing affects the overall
architecture or behavior beyond what the review identified.

## Addressed

### 1. LaunchSessionResponse.created_at uses authoritative started_at

Previously the handler took a fresh `Utc::now()` after `manager.launch()`
returned. The session's own `started_at` is set inside `spawn_in_pty`
before the reader/watcher tasks are wired, so the wire-level timestamp
drifted a few milliseconds behind it — and ListSessions reported the
earlier (correct) one. Now `launch_session` does one `manager.get()`
after launch and uses `session.started_at.timestamp()`. Single source of
truth between LaunchSession and ListSessions.

### 2. Widen proto numeric fields to avoid silent casts

- `WriteInputResponse.bytes_written`: int32 → int64. `usize as i32` was
  wraparound on 2 GiB+ payloads — unlikely for a PTY but the contract is
  the contract.
- `ListSessionsResponse.count`: int32 → uint32. Saturating cast via
  `u32::try_from(...).unwrap_or(u32::MAX)` keeps the count monotonic for
  clients that diff snapshots.

Greenfield-correct per CLAUDE.md (single consumer, no migration cost).

### 3. Hold session Arc through launch+resize

`launch_session` now does one `manager.get(&id)` after launch and holds
the resulting `Arc<PtySession>` for both `started_at` and the optional
initial resize. Eliminates the redundant lock acquisition AND closes a
small race where the spawned child could exit and the auto-prune watcher
remove the entry between launch() and apply_resize(), returning NotFound
for a session we just created.

The old `apply_resize(manager, id, ...)` helper is replaced by
`resize_session(&Arc<PtySession>, ...)`; `resize_terminal` does its own
manager lookup since it doesn't have an Arc from elsewhere.

### 4. StreamOutput lag visibility (lowest-risk variant)

Kept the current swallow-and-warn behavior — surfacing dropped bytes
inline via a sentinel chunk or a session metric is a wider design
change that should land in its own PR. Added a `debug_assert!(skipped > 0)`
to catch future bugs that produce zero-byte lag events, and a TODO note
pointing at the review discussion so the trade-off is visible to future
maintainers.

### 5. ResizeTerminal rejects zero dimensions

Unlike LaunchSession (0 = use default), ResizeTerminal has no default
semantic — its job is "apply exactly this size". Now returns
`InvalidArgument` if either axis is zero, rather than passing through
to `portable_pty` with `PtySize { rows: 0, cols: 0, .. }` which is
platform-dependent. Added integration test `resize_terminal_rejects_
zero_dimensions` pinning the contract across (0, N), (N, 0), (0, 0).
Proto comment documents the [1, 65535] valid range.

### Nit: Remove snake_case agent_type acceptance

Per CLAUDE.md's no-backwards-compat directive: the previous
`raw.replace('_', "-")` was dead weight for a greenfield API with zero
callers. Kebab-case is the only accepted form now. Added
`parse_agent_type_rejects_snake_case` test pinning the new rejection so
a future refactor cannot silently restore the dual-accept path.

### 6. Document testing-feature footgun in agent crate

Expanded the `[features] testing = []` comment in
`packages/agent/Cargo.toml` to explain Cargo's feature-unification
behavior across dep/dev-dep within a single downstream package, and the
rule that test-only stubs behind this feature must only widen
visibility — never change runtime behavior, swap implementations, or
skip safety checks. Test-only mutating stubs belong in test-only
modules in the downstream crate, not behind this flag.

## Skipped, with reasoning

- **#7 Daemon test for caller-supplied UUID path through CreateNode**:
  Reviewer explicitly marked this "out of scope for this PR" and the
  contract is already exercised in nodespace-core per #1147. Worth a
  follow-up issue if the daemon adapter ever diverges from a simple
  pass-through.

- **Nit: `agent_type_to_string` debug_assert**: The dead-code fallback
  to `format!("{agent_type:?}")` is genuinely defensive — it makes the
  function infallible if `AgentType` ever gains a non-serde variant in
  the future. A `debug_assert!(false)` would catch that in test, but
  would also panic in production for a problem that's recoverable. The
  current shape is the right trade-off.

- **Nit: connect-retry loop `last_err` Option indirection**: Purely
  stylistic and the existing shape already does the right thing
  (`panic!` formats `Some(e)` fine). Not worth a churn commit.

- **Nit: `count: count` field-init shorthand**: Clippy didn't flag and
  the explicit form is consistent with other struct literals in the
  same file. No-op.

- **Nit: `embedding_service = None` duplication in main.rs**: Will
  naturally resolve when the embedding service is wired by the follow-
  up issue (currently both arms become `Some(embedding_service.clone())`).
  Introducing a binding now would just need un-introducing later.

## Verified

- `cargo build --bin nodespaced`: clean
- `cargo test -p nodespace-daemon`: 26 passed (7 unit + 9 agent-session
  integration + 10 grpc-round-trip; added zero-reject + snake-case
  rejection tests)
- `cargo clippy -p nodespace-daemon --all-targets -- -D warnings -D
  clippy::unused-async`: clean
- `bun run quality:fix`: clean (eslint + svelte-check + rustfmt + clippy
  across the full workspace)
- `bun run test`: 3918/3918 across 128 files (matches baseline)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address re-review: surface dropped resize, deflake echo test

Two small follow-ups from the re-review of 10041e3, plus a flake fix
discovered while verifying.

## Addressed

### Re-review suggestion: warn-log silent resize skip in launch_session

If the auto-prune watcher removes the session entry between launch() and
the get() that pins the Arc for the optional initial resize, the resize
silently no-op'd and the client got a successful LaunchSessionResponse
with a session_id that the next RPC would reject as NotFound. Now logs
at `tracing::warn!` with session_id + requested dims so the server-side
trace makes the dropped resize discoverable.

Kept the response semantics unchanged: the launch itself succeeded, so
returning Status::failed_precondition would force every client to
handle a confusing "launch succeeded but failed to resize" mid-failure.
The next RPC's NotFound is a clearer signal for the (vanishingly rare)
race that this branch handles.

### Re-review nit: agent_type_to_string fallback comment

Updated the comment to explicitly state that the debug-format fallback
is defense-in-depth, not a graceful-degradation path. AgentType is
in-workspace and closed, so adding a new variant must extend
parse_agent_type / agent_type_to_string together — the fallback is not
a shortcut.

## Flake fix (discovered during verification)

`stream_output_delivers_echo_hello` was timing-sensitive: on a fast
machine the PTY reader could drain `echo hello` and broadcast the
chunk before the gRPC client's StreamOutput subscriber attached.
`tokio::sync::broadcast` does not replay missed messages to late
subscribers (unlike `watch`), so the test would fail ~10% of runs.

Changed the command from `echo hello` to `sleep 0.2 && echo hello` so
the subscriber is guaranteed to attach before bytes hit the broadcast
channel. Comment in the test body explains why. 5/5 passing after the
change.

This is a pre-existing flake in the original PR; the re-review missed
it because the parallel test run masked it. Catching it now means it
won't show up as intermittent CI failures later.

## Verified

- cargo test -p nodespace-daemon: 26 passed (run 5x to confirm no flake)
- cargo clippy -p nodespace-daemon --all-targets -- -D warnings -D
  clippy::unused-async: clean
- bun run quality:fix: clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Michael Libio <malibio@Michaels-Mac-mini.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant