Repository navigation
Implement AgentSessionService gRPC handler (closes #1119) - #1148
Conversation
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
left a comment
There was a problem hiding this comment.
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:
- 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. - 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:
- Reject zero values too —
cols: 0, rows: 0toResizeTerminal(as opposed toLaunchSession, where zero means "use default") would currently callportable-ptywithPtySize { rows: 0, cols: 0, ... }, which the platform may or may not reject. Better to fail at the API boundary. - 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_stringfalls back toformat!("{agent_type:?}")if serde fails. You acknowledged this is dead code today. Adebug_assert!(false, ...)would catch a future serde regression in CI without breaking production. Or useserde::ser::Errormore explicitly viaserde_plain::to_stringwhich can't fail for unit-variant enums. Optional polish. -
Nit:
packages/daemon/src/services/agent_session_service.rs:212–219—parse_agent_typeaccepts 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 namesclaude_codebut the canonical form isclaude-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 toResizeTerminaland document it, or explicitly call out thatResizeTerminalrejects 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 viapanic!,last_erris alwaysSomeby construction (first iteration always sets it on failure), but theOptionindirection obscures that. Replace with aResultaccumulator or justunwrap()(thepanic!formatter already printsSome(e)correctly). -
Nit:
packages/daemon/src/main.rs:188–199—embedding_service = Noneis wired identically in two spots (thenodeand theassemblerarms). Consider naming thisNone::<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: countcould becount(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 passedcargo 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)]inlib.rsis 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 inparse_agent_typeis technically backwards-compat code (see Nit above). Recommend removing.
Security
- ✅ All session_id inputs are parsed through
Uuid::from_strbefore use — no injection vector via the session_id field. - ✅
WriteInputforwards raw bytes to the PTY verbatim, which is the correct behavior for a terminal (Ctrl-C, escape sequences, etc., must pass through). Thebytesfield is length-bounded by tonic's default message size limits. - ✅
LaunchSessiononly accepts an enumeratedagent_typeand anOption<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_idin thelaggedwarn is a UUID — fine.
Performance
- The
manager.get(&id)lookups acquire the manager'sMutex<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 andListSessionsbecomes hot, swap the innerMutex<HashMap>forDashMaporRwLock<HashMap>. Out of scope here. StreamOutputclones eachOutputChunk.data: Vec<u8>once when copying into the proto message. For typical PTY chunk sizes (≤4 KiB perREAD_BUFFER_BYTES) this is negligible; a future optimization could useBytesend-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>
Review feedback addressed — 7 of 12 implementedPushed as 10041e3. Summary of the response to each item below. ✅ Addressed
⏭️ Skipped, with reasoning
Verified
🤖 Generated with Claude Code |
Re-Review of commit 10041e3 (LGTM — would approve if not own PR)Reviewed only the changes since commit SummaryThis 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
#3 Single
#5 ResizeTerminal rejects zero (verified)
#2 Proto widening (verified)
#4
#6 Cargo feature footgun documentation (verified)
#7 (nit) Snake-case rejection (verified)
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: Nit: Nit: FindingsSuggested Improvements
Nitpicks
Standards compliance
Local verification
LGTM. (Would have approved via |
malibio
left a comment
There was a problem hiding this comment.
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>
Re-review feedback addressed — 2/2 implemented + flake fixPushed as 7fe2194. ✅ Addressed
🐛 Drive-by flake fixWhile verifying, Changed the command from Verified
🤖 Generated with Claude Code |
* 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>
Summary
nodespacedas theAgentSessionServicegRPC handler — six RPCs (Launch, StreamOutput, WriteInput, ResizeTerminal, TerminateSession, ListSessions) backed byArc<PtySessionManager>+Arc<GraphContextAssembler>.agent_session_service.protowith the actualPtySessionManagerAPI (the original shape from Define protobuf schema and set up tonic/prost build pipeline for nodespaced #1111 predated the engine and would have required either a parallel raw-command launch path or a translation layer — both pure tech debt for a greenfield daemon).Acceptance criteria
AgentSessionServicehandler is registered with thenodespacedgRPC server (both headless + tray loops).LaunchSessioncreates a PTY session and returns the session ID.StreamOutputopens a server-streaming response that deliversOutputChunkmessages in real time.WriteInputforwards bytes to the PTY stdin.ResizeTerminalresizes the PTY.TerminateSessionkills the session and triggers cleanup.ListSessionsreturns metadata for all active sessions.StreamOutputdoes not kill the session.echo hello, verifyStreamOutputdelivers the output.cargo build --bin nodespacedsucceeds.bun run quality:fix.Test plan
cargo build --bin nodespaced— successcargo 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 passedbun 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 0Notable design decisions
Proto rewrite. The proto from #1111 used raw
command/args/cwd/env; the engine from #1118 takesAgentType+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_testandsession_dir_pathmoved frompub(crate) + #[cfg(test)]topub + #[cfg(any(test, feature = "testing"))]so the daemon's integration tests can spawnsh/cat/echowithout a real agent binary on PATH. Thetestingfeature already exists in the agent crate for the same reason (ollama_integration tests).Lint-clean error helpers.
parse_session_idandparse_agent_typereturnResult<T, String>rather thanResult<T, Status>to avoid trippingclippy::result_large_err(tonic::Status is ~176 bytes, dwarfsUuid/AgentType). Call sites wrap the string inStatus::invalid_argumentin one line.Drive-by fix from rebase. PR #1147 added
id+insert_after_node_idfields toCreateNodeRequestbut didn't updatepackages/daemon/tests/grpc_round_trip.rsorpackages/cli/{src/commands/node.rs, tests/cli_integration.rs}. The existingrust:testscript only runsnodespace-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