feat(server): POST /roster/assistant/propose endpoint + in-memory session store #1219
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/agent-hooks!1219
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "code-lead/1212"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Adds the HTTP surface and session lifecycle for the Agents Roster AI Assistant (spec:
specs/agents-roster-ai-assistant.md). The prompt assembly + Claude call live behind the_rosterAssistantProposer.proposeinjection seam so the prompt ticket can land in parallel — production wires the real builder on top of this; tests inject a stub.domain/roster-assistant/session-store.ts): per-session conversation history,refinements_usedcounter, lastcatalog_hash; idle GC at >1h and a FIFO capacity cap so a misbehaving caller can't grow the resident set without bound.message(≤4000 chars), creates / resumes the session, calls the injected proposer, validates the returned envelope shape, records the proposal, and tacks on asoft_cap_exceeded: trueflag pastDEFAULT_REFINEMENTS_SOFT_CAP.parent_proposal_idwiring, unknown-session 404, oversized-message 400, proposer-throw 502, default-unconfigured 502, malformed-envelope 502, soft-cap flag wiring, idle-GC, capacity eviction.Closes #1212.
Test plan
bun test apps/server/src/domain/roster-assistant apps/server/src/http/handlers/roster-assistant-propose.test.ts→ 35 passjust qa(typecheck + lint + format + 3397 tests + sql-layer + paraglide + i18n + flow-schema) — all greenguardMutatingtest path insession-gate.test.ts)🤖 Generated with Claude Code
Review — PR #1219: POST /roster/assistant/propose + in-memory session store
APPROVED with a couple of notes worth acting on before the prompt-builder ticket lands.
Overview
Solid implementation of the HTTP surface + session lifecycle layer for the Roster AI Assistant (#1212). The injection-seam pattern mirrors the established
_architectForgeFactoryconvention cleanly, the rollback logic on failure is correct and well-tested, and the session store has the right safety properties (defensive snapshot copies, FIFO cap with TTL-first eviction, inline sweep to avoid background timer management). AC coverage against the spec is good for the scope of this ticket — the latency/catalog-accuracy ACs are correctly deferred to the prompt-builder ticket.Issues
1. Silent
recordProposal ?? sessionfallback — orphaned session on 200 (minor, worth fixing before GA)In
roster-assistant-propose.ts:recordProposalreturnsnullwhen the session id isn't in the store — which can happen in this (unlikely, but real) sequence:createSession()→ session X is the FIFO-oldest resident.await _rosterAssistantProposer.propose(...)yields the event loop.createSession()at capacity=256 →evictIfOverCapacity()walks from the FIFO front → evicts session X.recordProposal(X.id, ...)returnsnull,?? sessionsilently falls back to the stale snapshot.session_idno longer exists in the store.session_id→ 404.The
?? sessionwas clearly intended as a defensive fallback for "shouldn't happen", but at capacity=256 with concurrent traffic it can. The fix is straightforward — log a warning and return a 502 rather than silently lying to the caller:No test covers this path at the moment.
2.
lastAssistantProposalId— unnecessarily widened signature + internal cast (nit)The function only ever receives
session.historywhich isReadonlyArray<RosterAssistantHistoryEntry>. Taking the narrower type directly would eliminate theascast and let TypeScript verify theproposalaccess without needing a structural assertion:This is a nit since the cast is safe in practice, but removing it makes the narrowing explicit to the compiler.
Observations (no action needed)
isWellFormedEnvelopeis intentionally shallow — the comment explains this correctly. Full AJV validation is the proposer's responsibility; the handler's check is a defence-in-depth discriminator guard. ✓peekSessiontouch-semantics test ("getSession touches last_touched_at; peekSession does not") doesn't strongly falsify the no-touch guarantee without a time mock — two consecutive calls in the same tick give alast_touched_atdifference of 0 ms, which satisfies≥ 0whether or notpeekSessiontouches. This is fine for now since the behaviour is simple to reason about by inspection, but worth noting if the GC logic ever gets more sophisticated.Error messages forwarded to the client in 502 (
body.errorcontainserr.message): acceptable for an operator-only loopback-gated endpoint, and the tests assert on the message content which locks down the behaviour. Just be mindful when the prompt-builder ticket wires real Claude errors in — surface-level model error text leaking to the FE could be surprising.current_roster: unknownis correctly scoped to v1 per the spec ("not used in v1 since scope is new-fleet only"). The design note inRosterAssistantProposerInputmakes the intent clear for the prompt-builder implementer./roster/in the not-found wildcard — the catch-all exclusion inserver.tsis correct; unregistered/roster/xxxpaths will 404 cleanly rather than falling through to whatever the default handler gives.AC check (this ticket's scope)
POST /roster/assistant/proposeexists, guarded byguardMutatingsession_idthreaded across refinementsparent_proposal_idwired from prior proposalmessagevalidated (non-empty, ≤ 4000 chars)session_id→ 404soft_cap_exceeded: truepast soft capThe two ACs that touch the real model path (latency < 15s, catalog-only skills/MCPs) are correctly left to the prompt-builder ticket.
Good work — the rollback logic in particular is exactly right and the test for the first-call failure / session-drop path is the kind of subtle case that often gets missed. The
recordProposal ?? sessionsilent fallback is the main thing worth tidying up before the full feature goes live.