Skip to content

Overnight: v0.1 completion + v0.2 durable-run pipeline (umbrella) - #101

Closed
leon0399 wants to merge 24 commits into
masterfrom
experiment/overnight-loop
Closed

Overnight: v0.1 completion + v0.2 durable-run pipeline (umbrella)#101
leon0399 wants to merge 24 commits into
masterfrom
experiment/overnight-loop

Conversation

@leon0399

@leon0399 leon0399 commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Umbrella PR — opened for review visibility over the whole overnight branch. The plan is to split it into stacked, independently-green PRs (split guide prepared separately); this PR itself is not meant to merge as-is unless the split is skipped.

Overnight experiment: v0.1 completion + v0.2 durable-run pipeline

One night of autonomous 30-minute iterations on experiment/overnight-loop. 29 commits, ten issues addressed with closing keywords, every commit individually verified (unit + RLS integration + HTTP e2e + browser e2e where applicable).

v0.1 — completed and eval-verified

v0.2 — durable runs, end to end

Hardening

Decisions left deliberately open

  1. Flip RUN_EXECUTION_MODE default to worker (refactor(api): move the single-model loop into the worker #50) — browser suite already soaks it.
  2. Make CI required on master once the first run is green.
  3. apps/web has two pre-existing unused-import warnings (untouched files) — flagged, not fixed.

Review pass

The full branch diff was adversarially reviewed by two independent reviewers before this PR:

  • Security: no high-severity findings. Fail-closed guard, atomic sessions, FORCE RLS on all new tables, no existence leaks, clean CI posture — all verified.
  • Concurrency: 4 HIGH findings, all resolved (5ba547a, 86994b1): redelivery double-execution (heartbeat pickup gate), lost cancellation in the pickup window (post-registration re-check), execution without a claim (mandatory markStarted claim; failed claim = no spend, no events), and the permanent chat wedge (stale-zombie supersede on single-flight conflict — a chat can no longer be wedged in any mode). One MEDIUM (deadman re-enqueue resilience) accepted with rationale: queue-level retries plus the unwedge backstop cover its failure mode.

Summary by cubic

Completes v0.1 and introduces the v0.2 durable-run pipeline: chats now execute as durable runs that survive refresh/disconnects, with context compaction and server-side titles. Adds CI and auth hardening.

  • New Features

    • Configurable OpenAI-compatible provider via OPENAI_BASE_URL/OPENAI_MODEL (defaults updated); targets /chat/completions.
    • Lineage-based context compaction (append-only summaries) and server-side chat titles; minimal live evals are green.
    • Durable runs: runs/run_events, Queue interface on pg-boss (@wavezync/nestjs-pgboss), worker mode (RUN_EXECUTION_MODE=worker) with SSE replay (/runs/:id, /runs/:id/events, /chats/:id/stream); cancellation, heartbeat/timeout, per-chat single-flight.
    • Resume-on-refresh in web (transport reconnect); Playwright browser suite with a deterministic model server; queue integration tests.
  • Hardening

    • Auth: global fail-closed session guard with @Public(), atomic validate+touch (debounced), login/register rate limits via @nestjs/throttler, TRUST_PROXY, hourly expired-session cleanup via pg-boss.
    • Data and reliability: reply-integrity trigger; FORCE RLS on all new tables; concurrency fixes (cancel pickup window, mandatory run claim, stale-run unwedge, redelivery gate); CI workflow gates lint/build/tests and RLS; provider path fixed to /chat/completions.

Written for commit 86994b1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added durable chat runs with stream resumption, run status tracking, and event replay.
    • Introduced chat title generation and context compaction for longer conversations.
    • Added worker-based execution support with queue-backed processing and live session recovery.
  • Bug Fixes

    • Improved session handling, cross-tenant isolation, and reply integrity checks.
    • Added rate limiting for sign-up and login, plus better cancellation and timeout handling.
  • Documentation

    • Updated roadmap, vision, changelog, and agent guidance.
  • Chores

    • Added CI workflow and expanded end-to-end test coverage.

leon0399 and others added 24 commits July 2, 2026 01:20
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Short OpenClaw-style vision doc distinct from SPEC.md's detailed
architecture: the why behind the core bets, current milestone focus,
emerging directions not yet spec'd (personas, machine connector, Brain
memory surface, calendar/email, n8n-style workflows), and guardrails on
what we won't build yet. Linked from AGENTS.md's doc index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
)

OPENAI_BASE_URL and OPENAI_MODEL env vars let dev and the eval suite
(#58) run the chat loop against any OpenAI-compatible endpoint
(OpenRouter free tier, groq, local) instead of hardcoded api.openai.com.
ModelsService resolves both from config; an explicit model argument
still wins over the env default. Documented the OpenRouter free-tier
setup in .env.example.

v0.1 dev/eval stopgap by design — native OpenRouter provider, credential
vault, and BYOK stay in v0.4 (#37/#82).

Closes #88

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
When a chat's estimated live context exceeds COMPACTION_TOKEN_THRESHOLD
(default 100k tokens), a post-turn model call summarizes the older turns
into a first-class `compactions` row: `upto_seq` records exactly which
messages it supersedes and `parent_id` chains to the compaction it
absorbed — auditable, rewindable lineage (Hermes-style, SPEC §2.1);
messages are never deleted or mutated. The next turn's context is
summary + recent turns via ContextBuilder.

Mechanics: the trigger fires after a completed turn (fire-and-forget —
before the context limit, never delaying the user's response); the
summarization call runs outside any DB transaction, with a staleness
re-check so a concurrent compaction of the same chat wins and the stale
one is discarded. Re-compaction feeds the prior summary into the new
one, so lineage loses nothing.

Security: compactions condense private conversation content, so the
table ships with RLS ENABLE + hand-maintained FORCE (0004 precedent)
and the same owner policy as messages; cross-tenant read and write
denial proven in the RLS integration suite (rls-test.sh green).

Unblocks the v0.1 eval set (#58) overflow case.

Closes #57

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
Three eval cases run the real loop over HTTP with a real provider:
happy path, prompt-injection canary, and context-overflow/compaction
(#57) coherence. Double-gated (RUN_MODEL_EVALS=1 + POSTGRES_URL) so
rls-test.sh and CI never spend provider tokens; run via
`pnpm --filter api test:evals`.

Verified live against OpenAI: happy path and injection PASS. The
overflow case initially stalled under the threshold because the model
answered fillers too tersely — fillers now carry a fixed ~800-char
inert payload so the window size is deterministic; that fix has NOT
yet been re-verified live (session interruption), so this does not
close #58.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
Restores server-side chat titling lost in the #63 thin-client cutover
(every chat stayed "New chat"). After the first completed turn, a cheap
post-turn model call names the chat from the user's message — same
fire-and-forget shape as compaction (#57), off the response path, and
it rides into the worker with the loop (#50).

The prompt and output sanitation follow the Vercel ai-chatbot reference
(2–5 words, no prefixes/markdown/quotes, clamped to 80 chars). The
model call is skipped unless the title is still the default, and the
persist goes through an atomic WHERE title = 'New chat' guard, so a
user rename mid-generation always wins. The e2e fake client tracks
title calls separately from chat turns to keep turn-count assertions
deterministic; the first-message e2e now proves the async title lands.

Closes #78

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
All three eval cases now pass against a real model over the real loop:
happy path, prompt-injection canary, and overflow — the last verified
live after making the context-window sizing deterministic (fixed inert
padding per filler turn instead of relying on model verbosity). The
overflow case doubles as an end-to-end integration proof of the
configurable provider (#88) + lineage compaction (#57): the chat
compacts mid-conversation and a fact from the absorbed turns survives
via the summary.

This was the last v0.1 line item: ROADMAP drops the completed v0.1
section (forward-only) and moves budget enforcement (#91) under v0.2
where its GitHub milestone actually lives; CHANGELOG records the
completion.

Closes #58

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
…erface (#47)

Stands up pg-boss v12 on the existing Postgres (own `pgboss` schema, own
`pg` pool alongside Drizzle's postgres.js — two drivers to one database,
SPEC §24.0.1) via @wavezync/nestjs-pgboss. No Redis, no separate
scheduler service; scheduling is pg-boss cron, not the pg_cron extension
(which runs SQL, not app code).

Callers depend on a new Queue interface (QUEUE token: ensureQueue /
enqueue / consume / stopConsumer / schedule / unschedule / cancel), so
the engine can later swap to BullMQ or Temporal without touching call
sites (SPEC §23.4). ensureQueue defaults every queue to retries with
exponential backoff plus a `<queue>.dead` dead-letter queue — failed
work stays inspectable and replayable, never silently dropped.

QueueModule is deliberately NOT imported into AppModule: its consumers
are the durable-run pipeline (#48) and the worker (#50); booting a live
queue in the API request path today would deepen exactly the coupling
the v0.2 worker split unwinds.

Verified against real Postgres by a gated integration suite (same
TEST_DATABASE_URL gate as the RLS spec): enqueue/consume roundtrip,
retry-until-success, dead-letter routing with payload intact, cron
schedule persistence/removal, and deferred (startAfter) delivery.
Jest transforms the ESM-only pg-boss chain (pg-boss, serialize-error,
non-error) via transformIgnorePatterns + allowJs; the production CJS
build uses Node 20.19+'s require(esm). No dedup/singleton option is
exposed yet — pg-boss v12 ties dedup to queue policy, and the verified
semantics land with #48's idempotent enqueueing.

Closes #47

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
Lands the durability substrate of the run pipeline (SPEC §9.3–§9.4):

- `runs` — one row per execution attempt (a retried message gets a
  fresh run), with the full SPEC §9.3 status enum so later milestones
  add no enum migrations. Created in the SAME transaction as the user
  message, so a message can never exist without its execution record.
- `run_events` — append-only, replayable event log; `sequence` is a
  table-global identity (monotonic per run, not dense), indexed for the
  cursor reads the SSE replay (#49) will do. No update/delete methods
  exist on the repository — append-only by construction.
- The streaming loop dual-writes the lifecycle (run.created →
  run.started → model.requested → model.completed → run.completed /
  run.failed / run.cancelled) as best-effort bookkeeping that can never
  break the live stream; when the loop moves into the worker (#50) this
  log becomes the authoritative execution record.

Security: both tables ship RLS ENABLE + hand-maintained FORCE (0004
precedent); runs scope directly on user_id, run_events via their run.
Cross-tenant read AND write denial proven in the RLS integration suite,
and the e2e now asserts a completed turn leaves a completed run with a
strictly ordered event log.

Deliberately deferred within #48 (issue stays open): worker consumption
from pg-boss, model.delta token events (lands with #49, its consumer),
cancellation/heartbeat/timeout, and the per-chat single-flight unique
index — unsafe before heartbeat exists, since a crashed in-flight run
would deadlock its chat forever.

Refs #48

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
/#49)

Makes the durable run log complete and consumable — the API side of the
refresh-safe replay (#49) and another #48 slice:

- ModelStreamInput gains a narrow onTextDelta seam; the OpenAI client
  maps AI SDK onChunk text-deltas onto it. The loop coalesces deltas
  through a pure size-based delta-buffer (400 chars — one row per token
  would multiply writes) and appends model.delta events via a
  sequential promise chain, drained before the terminal events so the
  log always reads in stream order.
- RunsController: GET /api/v1/runs/:id (egress-allowlisted RunResponse)
  and GET /api/v1/runs/:id/events?after_sequence=N — SSE per SPEC §9.4.
  Each frame's SSE id: is its event sequence, so Last-Event-ID-style
  resume falls out naturally; an in-flight run is polled (500ms) until
  terminal, a finished run streams its tail and closes with [DONE], a
  deleted-mid-stream run closes instead of spinning, and connections
  are capped at 5 minutes (clients reconnect with their cursor).

Security: session-guarded; a cross-tenant run id 404s on both endpoints
before any SSE headers are sent (no existence leak) — proven in e2e
alongside full-replay ordering, mid-stream cursor resume, and the
coalesced delta payload round-trip.

Also fixes the e2e SSE parser, which dropped frames carrying an id:
line before data: — it made a cursor assertion pass vacuously and hid
the frames entirely.

The apps/web resume-on-refresh client remains open in #49; worker
consumption, cancellation, and heartbeat/timeout remain open in #48.

Refs #48, #49

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
GitHub Actions workflow with two parallel jobs on every PR and pushes
to master:

- checks: pnpm install --frozen-lockfile → turbo run lint → turbo run
  build → api unit suite (DB-backed specs self-skip without a database;
  model evals stay opt-in behind RUN_MODEL_EVALS, so CI never spends
  provider tokens).
- rls: apps/api/scripts/rls-test.sh — the FORCE-RLS cross-tenant proof
  plus the auth/chat HTTP e2e against a throwaway Postgres, exactly the
  script run locally (GitHub-hosted runners have Docker).

Hardening: actions pinned to commit SHAs (tag in comment), top-level
permissions: contents: read, persist-credentials: false, no untrusted
interpolation. actionlint and zizmor both clean.

Wiring root lint into CI surfaced that packages/ui's lint script had
been silently broken forever — eslint was never in its devDependencies,
so `turbo run lint` failed before reaching any code. Fixed the
dependency and the three warnings it had been hiding (an unused shiki
import and two declaration-merging interfaces the empty-object-type
rule flags by design).

Making CI required on master is a repo-settings step once the first
run is green.

Closes #70

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
Splits the chat loop along the SPEC §9.5 seam:

- RunExecutionService (new, transport-agnostic): context assembly at
  execution time (compaction summary + live window up to the triggering
  message), the model call, and every durable side effect — assistant
  turn persistence, run lifecycle + coalesced model.delta events, and
  post-turn compaction/titling. No HTTP types; the caller supplies the
  ModelClient, so the API keeps its 402-before-persistence contract and
  the worker will resolve credentials itself.
- ChatLoopService keeps only the API-side steps: validate, store the
  user message, create the run (same transaction), hand off execution.

Today the hand-off returns the live stream to the controller; the
worker move (#50) replaces that one call with an enqueue while the
worker drives the identical executeRun. The only observable change is
that context assembly now reads in its own transaction after the
message commit (read-committed makes this equivalent).

Behavior-preserving by proof: the full e2e suite (event ordering,
titling, abort/retry, replay, cross-tenant denial) and RLS integration
pass unchanged, plus lint/build/unit suites.

Refs #48, #50

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
/#50)

With RUN_EXECUTION_MODE=worker the API side of a turn becomes exactly
SPEC §9.5: validate → store message → create run → enqueue on pg-boss →
respond. A co-located RunsWorkerService consumes the `runs` queue and
drives the identical RunExecutionService extracted last commit; the
HTTP response streams from the durable run-event log through a new
bridge that translates run events into the AI SDK UI-message protocol
(start/text-start/text-delta/text-end/finish/error + [DONE]), so the
existing apps/web chat transport works unchanged.

The durability win, proven by e2e: a client disconnect mid-run stops
only the bridge — the worker finishes the model call, the assistant
turn persists, and the run reaches `completed` with a fully ordered
event log. The turn no longer lives or dies with the socket.

Failure contract: run-level failures (model errors) are recorded
durably by executeRun and the queue job succeeds — retrying would
re-run a turn whose failure is already the source of truth; queue
retries + dead-lettering apply to infrastructure failures (credential
resolution, DB unavailability). The early 402 credential check in the
API is preserved in both modes.

Default stays `inline` pending soak — flipping it, cancellation, and
heartbeat/timeout remain open in #48/#50. The e2e jest config gains the
same pg-boss ESM transform allowlist as the unit config (AppModule now
imports QueueModule, so every e2e suite loads pg-boss).

Refs #48, #50

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
Worker mode made runs survive client disconnects; this adds the
deliberate way to stop one. Modeled as a resource PATCH (the house REST
rule — no RPC verb handles): `cancelled` is the only client-writable
transition, enforced by the DTO.

Mechanics, layered for the coming worker-process split:
- runs.cancel_requested_at (migration 0011) is the durable,
  cross-process signal, stamped atomically only on non-terminal,
  not-yet-requested runs (single-writer guard in the WHERE).
- RunAbortRegistry is the in-process fast path: the worker registers an
  AbortController per executing run; the cancel endpoint aborts it
  directly while API and worker share a process. Mid-flight abort flows
  through the exact path a client abort used in inline mode.
- A run cancelled while still queued is settled as `cancelled` at
  pickup — the model is never called.

Semantics proven in worker-mode e2e: mid-flight cancel terminates the
run as `cancelled` with no assistant answer; re-cancel is idempotent
(200); a finished run answers 409; a cross-tenant run id answers 404
(no existence leak).

Remaining on #48: heartbeat + run timeout, then the per-chat
single-flight index they unblock.

Refs #48

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
The last hard #48 criterion: a crashed or hung worker can no longer
leave a run wedged in running_model forever.

- The executing worker stamps runs.heartbeat_at (migration 0012) at
  start and on a configurable interval (RUN_HEARTBEAT_SECONDS, 15s).
- Every enqueued run gets its own delayed deadman job on a
  `runs.timeouts` queue (pg-boss startAfter, RUN_TIMEOUT_SECONDS, 300s),
  enqueued API-side so it exists even if no worker ever picks the run
  up. At fire time, in the run owner's tenant context: a terminal run
  is left alone; a fresh heartbeat means a long turn — re-check later;
  a heartbeat older than RUN_HEARTBEAT_STALE_SECONDS (60s) means the
  executor died — the run is expired with a run.failed event.
  Deliberately NO cross-tenant reaper scan: the per-run job keeps every
  read inside the tenant boundary, so the RLS moat needs no system
  bypass.
- Terminal statuses are now immutable at the repository level:
  markFinished excludes already-finished runs in its WHERE, so a
  late-finishing stream callback can never overwrite expired/cancelled
  (first writer wins) — this also hardens the cancellation race.

Proven in worker-mode e2e: a hand-crafted zombie (running_model, stale
heartbeat) expires with the event trail while a completed run's own
deadman leaves it untouched; live runs under tight test thresholds keep
completing because their heartbeats stay fresh.

Remaining on #48: the per-chat single-flight index (now unblocked) and
flipping RUN_EXECUTION_MODE's default.

Refs #48

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
At most one non-terminal run per chat, enforced in the database — a
partial unique index on runs(chat_id) WHERE status is non-terminal.
This is the atomic guarantee against the concurrent double-model-call
race deferred from #55 (#73): a second message racing an in-flight run
loses at the index, its transaction rolls back whole (no orphan user
message), and the client gets 409.

Retry semantics preserved by supersession: a retry of the SAME message
first cancels any non-terminal run for that message (same transaction,
run.cancelled event, plus an in-process abort of the superseded
execution after commit) — so a turn whose previous attempt died
silently is always retryable, and at most one generation per message
survives. Content equality of retries was already enforced, so nothing
user-visible is lost.

Resurrection guards: markStarted now refuses terminal runs (same
immutability as markFinished) and worker pickup skips any run that
went terminal while queued — a superseded run can never come back as
running_model.

The v0.1 "overlapping turns" e2e encoded the old concurrent contract
and is rewritten to the serialized one: overlap → 409, first context
never sees the rejected send, and the next turn sees the completed
prior exchange. New e2e also proves the 409 rollback leaves nothing
behind. Unique-violation detection walks drizzle's cause chain.

Safe only now: heartbeat + deadman (previous commit) guarantee every
run reaches a terminal status, so the single-flight slot always frees.

This completes the #48 acceptance list (API-only request path in worker
mode, ordered events, idempotency, cancellation, heartbeat + timeout,
single-flight). Flipping RUN_EXECUTION_MODE's default remains a soak
decision under #50.

Closes #48

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
The three remaining items (single-flight shipped with #48):

- DB-level in_reply_to integrity: a trigger (hand-authored migration
  0014 — Drizzle can't express triggers; documented alongside 0004/0006
  in the gotchas) rejects a reply that references a message in another
  chat or a non-user message, whichever code path writes it. Runs under
  the caller's RLS context. Proven with negative tests in the RLS
  integration suite; a composite-FK approach was rejected because
  partial SET NULL column lists aren't expressible through Drizzle and
  it can't cover the role check anyway.

- Event-driven abort fidelity: the e2e fake model client now reacts to
  the abort EVENT mid-delay (racing the timer), not a post-sleep
  `.aborted` poll — the same interruption shape as the real AI SDK. A
  new e2e with an effectively infinite model delay proves the
  no-partial-write guarantee end-to-end: only the abort event can end
  the turn, onFinish never fires, the persisted assistant row is the
  empty aborted placeholder, and the run terminates cancelled (freeing
  the single-flight slot).

- Unit fake onFinish timing: createFakeModelClient now fires callbacks
  on stream CONSUMPTION (pull-driven; `text` is a lazy getter,
  consumeStream drives the same once-only finish), awaited — instead of
  synchronously and discarded at call time. An unconsumed stream fires
  nothing, matching the real SDK.

Closes #73

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
…, TRUST_PROXY (#68)

First tranche of the #60 auth-surface hardening list:

- Fail-closed by default: SessionAuthGuard registers as a global
  APP_GUARD; @public() is the explicit, reviewable opt-out (login,
  register, the liveness root). Per-route @UseGuards were REMOVED so
  the global guard is load-bearing — every existing 401 e2e assertion
  now proves the default-deny path, not a per-controller annotation.
  This kills the bug class behind the original /users exposure.
- Atomic session validation: findActiveAndTouch validates and stamps
  last_seen_at in one UPDATE … RETURNING (no SELECT→check→UPDATE
  TOCTOU), with a read-only fast path inside a 60s debounce window
  that takes the per-request write off the hot path entirely. Stale
  rows are deleted as housekeeping on the miss path.
- Session listing returns active (unexpired) sessions only, backed by
  a new (user_id, expires) index (migration 0015); the current-session
  lookup is a single WHERE user_id AND id query instead of
  list-then-find.
- TRUST_PROXY (off by default — trusting proxy headers with no proxy
  lets clients spoof their IP): hop count or Express subnet spec, so
  session.ip records the real client behind a reverse proxy
  (SPEC §22.0 prerequisite for IP/subnet policies).

Still open in #68: login/register rate limiting (@nestjs/throttler),
the cross-site SameSite=None + CSRF posture, token-free responses for
cookie clients, session rotation (vacuous until a change-password
endpoint exists), and periodic expired-session cleanup.

Refs #68

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
… cron (#68)

- Rate limiting (@nestjs/throttler): instance-wide 300/min default with
  strict 10/min per-IP overrides on POST /auth/v1/login and /register —
  each attempt burns a bcrypt compare, an unbounded brute-force + DoS
  amplification surface. Guard order is deliberate: ThrottlerGuard
  registers BEFORE SessionAuthGuard, so a flood is rejected without
  paying the session lookup. Uses req.ip, so the TRUST_PROXY setting
  from tranche 1 feeds directly into per-client fairness. Proven by an
  e2e that rapid-fires logins into a 429 (placed last in the suite —
  it spends the instance's login budget).

- Expired-session housekeeping on a pg-boss cron: SessionCleanupService
  schedules 'sessions.cleanup' hourly (off the :00 mark) and purges
  expired/idle rows — the #47 scheduler's first production consumer.
  Cross-user by design (sessions carry no RLS; they are consulted
  pre-authentication and expiry is a global fact, not tenant data);
  idempotent across concurrent instances. The purge SQL is proven
  against real Postgres in the integration suite: expired rows go,
  live rows stay.

Remaining in #68: cross-site SameSite=None + CSRF posture, token-free
responses for cookie clients, and session rotation (vacuous until a
change-password endpoint exists).

Refs #68

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
The API half of refresh-safe resume: GET /api/v1/chats/:id/stream
returns the chat's active run as an AI SDK UI-message stream (the
existing RunStreamBridgeService, reused wholesale — it replays the
run's persisted events from the start and follows it live to
completion), or 204 when there is nothing to resume. This matches the
AI SDK v6 transport contract for reconnectToStream, so the apps/web
hookup that closes #49 is a small custom-transport method + a resume
flag.

"The chat's active run" is well-defined because the per-chat
single-flight index admits at most one non-terminal run — the new
RunsRepository.findActiveByChatId leans on that invariant.

Security: session-guarded like everything else (global guard); a
cross-tenant or unknown chat id answers the same 204 as "no active
run" — resuming leaks neither existence nor state. Proven in
worker-mode e2e: disconnect mid-run, then GET the stream — the full
ordered chunk sequence (start → text-start → deltas → text-end →
finish) arrives with the complete answer; after completion the
endpoint answers 204; another tenant gets 204.

The apps/web resume client (transport reconnectToStream + resume flag
+ browser verification) remains to close #49.

Refs #49

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
The client half of refresh-safe resume, matching the API endpoint from
the previous commit:

- The chat DefaultChatTransport gains prepareReconnectToStreamRequest,
  pointing the SDK's reconnect at GET /api/v1/chats/:id/stream (the
  default convention would have targeted .../messages/:id/stream).
  Shares the existing authAwareFetch + credentials wiring.
- Persisted chat sessions mount with resume: true — on load, useChat
  probes for an active run and, when one exists (worker mode), replays
  its UI-message stream live from the durable event log. Draft chats
  (navigateOnFinish) skip the probe: they cannot have a server-side
  run yet. An idle chat's 204 resolves to null in the SDK — a no-op.

Verification: new vitest unit for the URL preparation, web typecheck +
lint + build clean, and the full 10-test Playwright browser suite
passes against the live api+web stack — the resume probe on every
persisted-chat mount regresses nothing in inline mode.

Remaining to close #49: a browser test of an actual mid-run refresh,
which needs the Playwright-launched API running RUN_EXECUTION_MODE=
worker — the natural companion to the #80 chat-flow e2e and the
worker-mode soak.

Refs #49

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
…49, #80)

The browser proof that closes the durable-runs story:

- e2e/model-server.ts: a deterministic OpenAI-compatible mock speaking
  /chat/completions streaming SSE; the Playwright-launched api points
  OPENAI_BASE_URL at it (#88) — real loop, zero provider spend. A
  "SLOW" prompt drips tokens over ~4s so tests can reload mid-answer;
  title-generation calls get a distinct short answer so message and
  sidebar title are distinguishable.
- The whole Playwright api now runs RUN_EXECUTION_MODE=worker: all 12
  browser tests exercise the queue-executed pipeline — standing soak
  evidence for flipping the default (#50).
- e2e/chat/chat-flow.spec.ts: create → stream → render (#80's scope),
  and the headline — reload the page mid-answer; the run survives in
  the worker, the persisted chat remounts with resume, and the FULL
  answer appears on screen from the durable event log.

Fixes surfaced by building this:
- Latent #88 bug: the model client used the provider default, which
  targets OpenAI's proprietary /responses endpoint — OpenAI-compatible
  providers (OpenRouter, groq, local; the whole point of
  OPENAI_BASE_URL) only implement /chat/completions. Now uses
  openai.chat(model), which works everywhere including OpenAI.
- The strict 10/min per-IP auth throttle starved parallel browser
  workers logging in from one IP: the limit is now env-tunable
  (AUTH_RATE_LIMIT_PER_MINUTE, harness sets 1000); the production
  default stays 10/min.

Verified: 12/12 Playwright browser tests green (auth + chat flows, all
under worker mode), full api unit + RLS/e2e suites green after the
/chat/completions change, builds clean.

Closes #49
Closes #80

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
Consolidation pass over the overnight branch before review:

- chat-loop.service: `let run` in the single-flight path was implicitly
  any (typed now), and the unique-violation matcher used String() on
  unknown values (typeof-narrowed now). Both were real eslint ERRORS
  masked by the local lint wrapper's summary output — the CI workflow
  (#70) would have failed the branch on them.

- runs-worker: at-least-once delivery could start a SECOND model call
  when pg-boss redelivered a job whose run was already executing (e.g.
  a retry after a mid-execution infrastructure error). The pickup gate
  now skips a running_model run with a fresh heartbeat — the live
  execution settles it — while a STALE running run still accepts the
  redelivery as the crash-recovery path, where markStarted refreshes
  the heartbeat. Interplay with the deadman is coherent in both orders:
  expired-first → terminal skip at pickup; restarted-first → the
  deadman sees a fresh heartbeat and reschedules.

Verified: turbo lint green across all workspaces (--force, no cache),
tsc clean, full unit + RLS/e2e suites green.

Refs #48

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
Two independent reviewers (security + concurrency lenses) audited the
full overnight branch diff. Security: no high-severity findings (its
one request — forged cross-tenant run_events INSERT coverage — already
exists in the RLS suite). Concurrency confirmed three real races, all
fixed here:

1. Lost cancellation in the pickup window: a PATCH cancel landing
   after the worker's skip-gate read but before abort registration
   stamped cancel_requested_at yet found no controller to abort — the
   run completed as if never cancelled. The worker now re-checks the
   flag immediately after registering; any later cancel hits the live
   controller, so the window is closed from both sides.

2. Execution without a claim: executeRun discarded markStarted's
   result, so a run that went terminal between enqueue and execution
   (superseded by a retry, cancelled, expired) still appended
   run.started/model.requested onto a terminal run and burned a full
   model call. Claiming is now mandatory and non-best-effort: a failed
   claim throws RunNotRunnableError — the worker treats it as settled
   (no queue retry), the inline path maps it to 409.

3. Permanent chat wedge: with no deadman in inline mode, a process
   crash left a run non-terminal forever and the single-flight index
   409'd every subsequent message to that chat. The single-flight
   conflict handler now inspects the blocking run: stale heartbeat →
   expire it (event + terminal write) and take the slot, in the same
   transaction via a savepoint-wrapped insert (a unique violation no
   longer poisons the outer tx). A live run still 409s. Proven by a
   new e2e: a hand-staled zombie blocking a chat is expired and the
   new turn completes.

Reviewer finding #1 (redelivery double-execution) was already closed
by the previous commit's heartbeat pickup gate + the deadman fencing;
finding #5 (deadman re-enqueue resilience) is accepted as-is — the
queue retries the deadman job itself 3x, and the new unwedge path is
the ultimate backstop for the failure it worried about.

Refs #48

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BMxH1qw3rCdjXAywDrJU9
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces durable, worker-processed chat runs replacing inline streaming: new runs/run_events/compactions schema and migrations, a pg-boss-backed queue, RunExecutionService/RunsWorkerService, SSE stream resumption (API and web), context compaction, chat title generation, auth hardening (rate limiting, fail-closed sessions, public routes), and extensive e2e/integration test coverage.

Changes

Durable Runs & Chat Execution Overhaul

Layer / File(s) Summary
CI, docs and env config
.github/workflows/ci.yml, AGENTS.md, CHANGELOG.md, ROADMAP.md, VISION.md, apps/api/.env.example, apps/api/AGENTS.md
Adds a CI workflow (lint/build/test + RLS check), updates roadmap/changelog/vision docs, and documents new env vars for OpenAI config, compaction, run execution mode, and trust proxy.
Queue abstraction (pg-boss)
apps/api/src/queue/*
Adds Queue interface, PgBossQueueService, QueueModule, and an integration test suite covering enqueue/consume, retries, dead-lettering, and cron scheduling.
Runs/compactions schema & migrations
apps/api/src/db/schema/*, apps/api/src/db/migrations/*
Adds compactions, runs, run_events tables, a reply-integrity trigger, a session index, and per-migration snapshot/journal updates.
Auth hardening
apps/api/src/app.module.ts, apps/api/src/app.controller.ts, apps/api/src/app.setup.ts, apps/api/src/auth/*
Adds global rate limiting, a Public() opt-out with fail-closed session guard, atomic session validate-and-touch/cleanup, and trust-proxy configuration.
Context compaction & title generation
apps/api/src/chats/compaction*.ts, apps/api/src/chats/context-builder.ts, apps/api/src/chats/title*.ts
Adds lineage-based compaction planning/persistence and post-turn chat title generation.
Run persistence & single-run execution
apps/api/src/chats/runs-repository.ts, run-execution.service.ts, run-abort-registry.ts, delta-buffer.ts, dto/runs.dto.ts, chats-repository.ts, chat-loop.service.ts, apps/api/src/models/*
Adds durable run/event repositories, DTOs, RunExecutionService, and reworks ChatLoopService to persist runs instead of streaming inline.
Worker execution & streaming endpoints
apps/api/src/chats/runs-worker.service.ts, run-stream-bridge.ts, runs.controller.ts, chats.controller.ts, chats.module.ts, apps/api/openapi.json
Adds pg-boss based worker execution with deadman liveness checks, an SSE run-event bridge, run management endpoints, and a chat stream-resume endpoint.
API RLS & e2e coverage
apps/api/src/chats/chats-rls.integration.spec.ts, apps/api/test/*, apps/api/package.json, apps/api/test/jest-e2e.json
Extends RLS/isolation tests and adds worker-mode/QA-evals e2e suites plus supporting Jest config.
Web stream resume-on-refresh
apps/web/app/(chat)/components/chat-page.tsx, apps/web/lib/services/chat/transport.ts, e2e/chat/chat-flow.spec.ts, e2e/model-server.ts, playwright.config.ts
Adds reconnect-to-stream transport and a deterministic mock model server for browser e2e resume testing.

Unrelated UI Package Tweaks

Layer / File(s) Summary
UI package tweaks
packages/ui/package.json, packages/ui/src/components/code-block.tsx, packages/ui/types/ua-client-hints.d.ts
Adds an eslint devDependency, removes an unused shiki import, and documents an empty Navigator interface.

Estimated code review effort: 5 (Critical) | ~150 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ChatLoopService
  participant Queue
  participant RunsWorkerService
  participant RunExecutionService
  participant RunEventsRepository
  participant Client

  ChatLoopService->>Queue: enqueue run job
  Queue->>RunsWorkerService: deliver job
  RunsWorkerService->>RunExecutionService: executeRun(runId, chatId, userMessage)
  RunExecutionService->>RunEventsRepository: append run.started / model.delta events
  RunExecutionService->>RunEventsRepository: append run.completed / run.failed
  Client->>ChatLoopService: GET /chats/:id/stream (resume)
  ChatLoopService->>RunEventsRepository: poll events by sequence cursor
  RunEventsRepository-->>Client: SSE replay of run events
Loading
sequenceDiagram
  participant Client
  participant ThrottlerGuard
  participant SessionAuthGuard
  participant AuthService
  participant SessionsRepository

  Client->>ThrottlerGuard: HTTP request
  ThrottlerGuard->>SessionAuthGuard: forward
  SessionAuthGuard->>SessionAuthGuard: check `@Public`() metadata
  alt public route
    SessionAuthGuard-->>Client: allow
  else protected route
    SessionAuthGuard->>AuthService: validateToken(token)
    AuthService->>SessionsRepository: findActiveAndTouch(tokenHash)
    SessionsRepository-->>AuthService: session or undefined
    AuthService-->>SessionAuthGuard: user or reject
  end
Loading

Possibly related issues

Possibly related PRs

  • leon0399/llame#59: Builds on the same multi-tenant RLS chat/messages persistence foundation, extending context-builder.ts with compaction filtering.
  • leon0399/llame#61: Both change apps/api/src/chats/chats.controller.ts route surface (this PR adds the authenticated resume-stream endpoint).
  • leon0399/llame#69: This PR's auth rework (validateToken, SessionAuthGuard, constants.ts) builds directly on the earlier revocable-session implementation.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main scope: v0.1 completion and the v0.2 durable-run pipeline umbrella changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch experiment/overnight-loop

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

e2e/chat/chat-flow.spec.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

e2e/model-server.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

playwright.config.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 86994b15df

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/api/src/chats/run-execution.service.ts
Comment thread apps/web/app/(chat)/components/chat-page.tsx
Comment thread apps/api/src/chats/run-stream-bridge.ts
Comment thread apps/api/src/chats/runs-repository.ts
@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (10)
ROADMAP.md-5-8 (1)

5-8: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep ROADMAP forward-only.

This status block still describes shipped v0.1 work. That belongs in CHANGELOG.md; ROADMAP.md should stay strictly future-facing.

🛠️ Suggested edit
-**Now:** v0.1 (minimal Q&A harness) has shipped — streaming single-model loop, configurable provider, context compaction, and a live-verified eval set (see [CHANGELOG.md](CHANGELOG.md)).
 **Next:** v0.2, durable runs — every message becomes a worker-processed run.

Based on learnings: "Keep ROADMAP.md forward-only: it may list only work that is not yet done."

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

In `@ROADMAP.md` around lines 5 - 8, Update the status block in ROADMAP.md so it
only describes future work: remove the shipped v0.1 summary and any “Now”
language, and keep only upcoming items such as v0.2 and tracking references. Use
the existing ROADMAP section content as the target, and move any
completed-release wording to CHANGELOG.md instead.

Source: Learnings

apps/web/lib/services/chat/transport.test.ts-1-23 (1)

1-23: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Restore prepareSendMessagesRequest coverage
This test file now only covers the URL helpers and reconnect request, so prepareSendMessagesRequest loses unit coverage even though it still powers the chat send path in apps/web/lib/services/chat/transport.ts.

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

In `@apps/web/lib/services/chat/transport.test.ts` around lines 1 - 23, The chat
transport test file no longer exercises prepareSendMessagesRequest, leaving the
send-path helper in transport.ts untested. Add back a unit test alongside
buildChatMessagesUrl, buildChatStreamUrl, and prepareReconnectToStreamRequest
that calls prepareSendMessagesRequest with a chat id and verifies the generated
request targets the chat messages endpoint. Keep the coverage in the existing
chat transport describe block so the helper is easy to locate if symbols change.
apps/api/src/queue/pgboss-queue.service.ts-118-136 (1)

118-136: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add a direct integration test for cancel() The spec only exercises stopConsumer() in teardown, where errors are swallowed, and never asserts cancel(); add a job-cancellation case here.

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

In `@apps/api/src/queue/pgboss-queue.service.ts` around lines 118 - 136, Add a
direct integration test for pgboss-queue.service’s cancel() path, since the
current spec only hits stopConsumer() in teardown and won’t fail on cancel
regressions. Update the existing queue service test suite to explicitly call
PgbossQueueService.cancel(queue, jobId) and assert the job is removed/cancelled
in the database or via the pg-boss API, using the cancel method name as the
anchor. Keep the existing stopConsumer() teardown helper unchanged and add a
separate job-cancellation case that verifies cancel() behavior directly.
apps/api/test/worker-mode.e2e-spec.ts-290-300 (1)

290-300: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Wait for the run to be executing before testing mid-flight cancel.

latestRun(chatId) can return a queued run, so this test can pass through the “cancelled before start” path instead of proving the abort path works. Poll for status === 'running_model' before PATCHing.

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

In `@apps/api/test/worker-mode.e2e-spec.ts` around lines 290 - 300, The mid-flight
cancel test is using latestRun(chatId), which can return a queued run and
accidentally validate the pre-start cancel path instead of the abort path. In
the worker-mode e2e spec, update the wait logic around latestRun/chatId to poll
until the run status is running_model before issuing the PATCH to
/api/v1/runs/:id, so the test only cancels an executing run.
apps/api/openapi.json-626-756 (1)

626-756: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add security requirements for the new run endpoints.

The run routes document 401 but omit the security blocks present on /api/v1/chats/{id}/stream. Generated clients may treat these authenticated endpoints as public. Prefer fixing the source Swagger decorators and regenerating this file.

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

In `@apps/api/openapi.json` around lines 626 - 756, The new run endpoints are
missing explicit auth metadata even though they return 401, so generated clients
may treat them as public. Add the same security requirements used by the
authenticated chat stream route to the Swagger decorators for
RunsController_getRun, RunsController_updateRun, and
RunsController_streamRunEvents, then regenerate the openapi spec so the
/api/v1/runs/{id} and /api/v1/runs/{id}/events entries include the security
blocks.
apps/api/test/qa-evals.e2e-spec.ts-98-127 (1)

98-127: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Restore COMPACTION_TOKEN_THRESHOLD after evals.

This suite forces a low threshold before app bootstrap but leaves it in process.env, which can change behavior for later tests in the same process.

Suggested fix
   const tag = Date.now();
   let cookie = '';
   let userId = '';
+  let previousCompactionTokenThreshold: string | undefined;
 
   beforeAll(async () => {
+    previousCompactionTokenThreshold = process.env.COMPACTION_TOKEN_THRESHOLD;
     // Force an aggressive compaction threshold BEFORE the app reads config, so the
@@
   afterAll(async () => {
     await app?.close();
+    if (previousCompactionTokenThreshold === undefined) {
+      delete process.env.COMPACTION_TOKEN_THRESHOLD;
+    } else {
+      process.env.COMPACTION_TOKEN_THRESHOLD =
+        previousCompactionTokenThreshold;
+    }
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/test/qa-evals.e2e-spec.ts` around lines 98 - 127, The eval setup in
qa-evals.e2e-spec.ts sets COMPACTION_TOKEN_THRESHOLD in beforeAll but never
restores it, so later tests can inherit the override. Update the qa-evals suite
to save the previous process.env.COMPACTION_TOKEN_THRESHOLD value before
assigning '300', and restore or delete it in afterAll after app.close(); use the
beforeAll/afterAll hooks in the qa-evals.e2e-spec.ts setup to keep the override
scoped to this suite.
apps/api/test/worker-mode.e2e-spec.ts-172-209 (1)

172-209: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Restore previous env values after the suite.

Deleting these variables can pollute later tests in the same Jest process when the caller had them set already.

Suggested fix
     let userId = '';
+    let previousEnv: Partial<Record<string, string | undefined>>;
 
     beforeAll(async () => {
+      previousEnv = {
+        RUN_EXECUTION_MODE: process.env.RUN_EXECUTION_MODE,
+        RUN_TIMEOUT_SECONDS: process.env.RUN_TIMEOUT_SECONDS,
+        RUN_HEARTBEAT_STALE_SECONDS:
+          process.env.RUN_HEARTBEAT_STALE_SECONDS,
+        RUN_HEARTBEAT_SECONDS: process.env.RUN_HEARTBEAT_SECONDS,
+      };
       process.env.RUN_EXECUTION_MODE = 'worker';
@@
     afterAll(async () => {
-      delete process.env.RUN_EXECUTION_MODE;
-      delete process.env.RUN_TIMEOUT_SECONDS;
-      delete process.env.RUN_HEARTBEAT_STALE_SECONDS;
-      delete process.env.RUN_HEARTBEAT_SECONDS;
       await app?.close();
+      for (const [key, value] of Object.entries(previousEnv)) {
+        if (value === undefined) {
+          delete process.env[key];
+        } else {
+          process.env[key] = value;
+        }
+      }
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/test/worker-mode.e2e-spec.ts` around lines 172 - 209, The
worker-mode e2e setup is mutating RUN_EXECUTION_MODE, RUN_TIMEOUT_SECONDS,
RUN_HEARTBEAT_STALE_SECONDS, and RUN_HEARTBEAT_SECONDS and then deleting them
unconditionally, which can clobber pre-existing test env state. In
worker-mode.e2e-spec.ts, update the beforeAll/afterAll block to capture the
original values before setting them and restore those exact values after the
suite instead of deleting them. Use the existing beforeAll/afterAll hooks and
the process.env assignments in this spec as the place to implement the
save/restore logic.
apps/api/src/auth/auth.service.ts-112-117 (1)

112-117: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Keep stale-session cleanup best-effort.

This cleanup is not part of the authorization decision, but any delete failure currently rejects validateToken instead of returning undefined for the invalid token. Swallow or log cleanup failures so expired/unknown tokens still produce the intended unauthenticated result.

Proposed fix
     if (!session?.userId?.trim()) {
       // Housekeeping, not authorization: an expired/idle row serves no one.
-      await this.sessionsRepository.deleteStaleByTokenHash(
-        tokenHash,
-        SESSION_IDLE_TTL_MS,
-      );
+      try {
+        await this.sessionsRepository.deleteStaleByTokenHash(
+          tokenHash,
+          SESSION_IDLE_TTL_MS,
+        );
+      } catch {
+        // Best-effort cleanup must not change the invalid-token auth result.
+      }
       return undefined;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/auth/auth.service.ts` around lines 112 - 117, In validateToken,
the stale-session cleanup path under the !session?.userId?.trim() check should
be best-effort and must not fail the auth flow. Wrap the
this.sessionsRepository.deleteStaleByTokenHash call in a try/catch (or
equivalent) and swallow or log any cleanup error so the method still returns
undefined for expired/unknown tokens. Keep the change scoped to validateToken
and the deleteStaleByTokenHash cleanup branch.
apps/api/src/auth/constants.ts-17-19 (1)

17-19: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Require an integer throttle limit.

AUTH_RATE_LIMIT_PER_MINUTE=0.5 currently passes validation even though request limits are counts. Reject non-integers to avoid surprising auth endpoint lockouts or ineffective configuration.

Proposed fix
 export const AUTH_RATE_LIMIT_PER_MINUTE = (() => {
   const raw = Number(process.env.AUTH_RATE_LIMIT_PER_MINUTE);
-  return Number.isFinite(raw) && raw > 0 ? raw : 10;
+  return Number.isInteger(raw) && raw > 0 ? raw : 10;
 })();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/auth/constants.ts` around lines 17 - 19, The
AUTH_RATE_LIMIT_PER_MINUTE validation in the AUTH_RATE_LIMIT_PER_MINUTE
initializer currently accepts fractional values because it only checks
Number.isFinite and > 0. Update this logic to require a whole number by also
validating that the parsed value is an integer, and keep the existing fallback
behavior for invalid input; use the AUTH_RATE_LIMIT_PER_MINUTE constant block as
the place to apply the fix.
apps/api/src/auth/sessions.repository.ts-106-112 (1)

106-112: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Filter idle sessions out of session listings.

findActiveAndTouch rejects idle sessions, but listForUser only filters by expires, so /auth/v1/sessions can show sessions that are no longer usable until hourly cleanup runs. Pass the idle TTL into this query and filter lastSeenAt consistently.

Proposed fix
-  async listForUser(userId: string): Promise<SessionRecord[]> {
+  async listForUser(
+    userId: string,
+    idleTtlMs: number,
+  ): Promise<SessionRecord[]> {
+    const now = Date.now();
     return this.db
       .select()
       .from(sessions)
-      .where(and(eq(sessions.userId, userId), gt(sessions.expires, new Date())))
+      .where(
+        and(
+          eq(sessions.userId, userId),
+          gt(sessions.expires, new Date(now)),
+          gt(sessions.lastSeenAt, new Date(now - idleTtlMs)),
+        ),
+      )
       .orderBy(desc(sessions.createdAt));
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/auth/sessions.repository.ts` around lines 106 - 112, The
listForUser query in SessionsRepository only filters by expires, so it can
return idle sessions that findActiveAndTouch would reject. Update listForUser to
also apply the same lastSeenAt idle-TTL cutoff used for active session
validation, passing the idle TTL into this method and filtering sessions
consistently alongside the existing expires check in the sessions repository
logic.
🧹 Nitpick comments (5)
apps/api/src/queue/queue.integration.spec.ts (1)

47-213: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider adding coverage for Queue.cancel().

The suite covers enqueue/consume/retry/DLQ/cron/deferred delivery but never exercises cancel(), which is part of the public Queue contract and (per the PR's run-cancellation work) is on a critical path.

✅ Suggested additional test
it('cancels a queued job before it is consumed', async () => {
  const name = `${tag}-cancel`;
  await queue.ensureQueue(name);

  const received: unknown[] = [];
  const jobId = await queue.enqueue(name, { should: 'not-run' }, { startAfter: 5 });
  await queue.cancel(name, jobId as string);

  await consume(name, (data) => {
    received.push(data);
    return Promise.resolve();
  });

  await new Promise((resolve) => setTimeout(resolve, 6_000));
  expect(received).toHaveLength(0);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/queue/queue.integration.spec.ts` around lines 47 - 213, Add
integration coverage for Queue.cancel() in the existing queue test suite so the
public contract is exercised alongside enqueue/consume/retry/DLQ/cron/deferred
flows. Create a new test near the other cases in the describe block that
enqueues a delayed job, captures the returned job id from queue.enqueue, calls
queue.cancel(name, jobId), then sets up a consumer and verifies the payload is
never delivered. Use the existing tag, consume helper, and Queue interface
methods to keep the test consistent and easy to locate.
apps/api/src/chats/run-stream-bridge.ts (1)

166-176: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Extra findById query on every idle poll cycle.

While waiting for the next event (the common steady-state case), each ~200ms tick issues both listByRunId and a follow-up findById just to check the run still exists. Over a multi-minute stream this doubles DB round-trips for a check that only needs to catch the rare "terminal without a terminal event" edge case.

♻️ Proposed fix: only re-check run existence every N empty polls
+            let emptyPolls = 0;
             if (events.length === 0) {
-              const run = await tenantDb.runAs(input.userId, (tx) =>
-                new RunsRepository(tx).findById(input.runId, input.userId),
-              );
-              if (!run) {
-                break;
-              }
+              emptyPolls += 1;
+              if (emptyPolls % 25 === 0) {
+                const run = await tenantDb.runAs(input.userId, (tx) =>
+                  new RunsRepository(tx).findById(input.runId, input.userId),
+                );
+                if (!run) {
+                  break;
+                }
+              }
               await sleep(POLL_MS);
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/chats/run-stream-bridge.ts` around lines 166 - 176, The idle
polling path in run-stream-bridge is doing an extra RunsRepository.findById
lookup on every empty events cycle, which doubles DB traffic in the common
steady state. Update the polling loop in run-stream-bridge to only re-check run
existence after N consecutive empty polls (or another throttled cadence) while
keeping the existing terminal-without-event safeguard, and reuse the existing
listByRunId/events.length logic to avoid redundant tenantDb.runAs lookups.
apps/api/src/chats/chats.module.ts (1)

20-33: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a dedicated RunsModule per the one-module-per-feature guideline.

RunsController plus the run/compaction/title providers are all bundled into ChatsModule. As per path instructions, "Use one NestJS module per feature, wire providers/controllers through dependency injection, and register feature modules in src/app.module.ts." Splitting Runs (and possibly Compaction/Title) into their own module(s) registered in app.module.ts would better match this convention, though the current grouping is defensible given how tightly runs are coupled to chats.

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

In `@apps/api/src/chats/chats.module.ts` around lines 20 - 33, The ChatsModule
currently bundles the RunsController and several run-related providers, which
conflicts with the one-module-per-feature guideline. Extract the run-specific
pieces into a dedicated RunsModule (including RunsController,
RunExecutionService, RunStreamBridgeService, RunsWorkerService,
RunAbortRegistry, and any related compaction/title services if appropriate),
then import that feature module from the app-level module instead of wiring them
through ChatsModule.

Source: Path instructions

apps/api/src/db/migrations/0013_round_expediter.sql (1)

1-1: 🚀 Performance & Scalability | 🔵 Trivial

Consider CONCURRENTLY for this index if runs sees production traffic.

A plain CREATE UNIQUE INDEX takes a table lock that blocks writes to runs for the duration of the build. If this migration runs against a populated table, prefer CREATE UNIQUE INDEX CONCURRENTLY (requires running outside a transaction block).

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

In `@apps/api/src/db/migrations/0013_round_expediter.sql` at line 1, The new
unique index on runs should be built without blocking writes in production.
Update the migration to create the runs_chat_inflight_unique index using
CONCURRENTLY, and ensure the migration is structured to run outside a
transaction block since concurrent index creation is not allowed inside one.
Refer to the index definition in the migration itself when making the change.
apps/api/src/db/migrations/meta/0011_snapshot.json (1)

799-1075: 🔒 Security & Privacy | 🔵 Trivial

Run the RLS test suite for these new tables.

This snapshot introduces RLS-enabled runs and run_events tables with owner-scoping policies keyed on user_id. Based on learnings, run scripts/rls-test.sh after touching the schema, RLS, TenantDbService, or the auth/HTTP surface — please confirm it has been run (or is wired into CI) for this new surface.

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

In `@apps/api/src/db/migrations/meta/0011_snapshot.json` around lines 799 - 1075,
Add RLS coverage for the new runs and run_events snapshot by running
scripts/rls-test.sh and ensuring the owner-scoping policies in runs_owner and
run_events_owner are exercised. If this test is not already part of CI for
schema/RLS changes, wire it in so future updates to this surface automatically
validate the current user_id-based access rules.

Source: Learnings

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

Inline comments:
In `@apps/api/src/app.setup.ts`:
- Around line 34-43: Reject non-integer TRUST_PROXY values in
getTrustProxySetting, since Number.isFinite() currently allows decimals and
scientific notation that Express will treat as hop counts. Update the
parsing/validation in getTrustProxySetting so only plain non-negative integers
are accepted, and otherwise return undefined or fail startup on invalid
configuration; keep the behavior localized around the TRUST_PROXY handling in
app.setup.ts.

In `@apps/api/src/chats/compaction.service.ts`:
- Around line 130-153: Serialize compactions per chat in CompactionService so
overlapping fire-and-forget runs cannot both create rows. The current staleness
check in the runAs block only rejects after another compaction has already
committed, so add per-chat locking/serialization around the compaction flow
(around the latest lookup and create in the compaction service) or enforce a
unique/locking constraint in CompactionsRepository/compactions to guarantee only
one active compaction per chat.

In `@apps/api/src/chats/run-execution.service.ts`:
- Around line 248-263: The fire-and-forget post-turn tasks in
run-execution.service.ts are missing rejection handling, so failed compaction or
title generation can surface as unhandled promise rejections. Update the
telemetry.status === 'completed' block in RunExecutionService to keep the
non-blocking behavior but attach explicit .catch() handlers (or equivalent
guarded logging) to both this.compaction.maybeCompact and
this.titles.maybeGenerateTitle so failures are swallowed and recorded without
affecting the turn or worker.
- Around line 206-246: The `onFinish` handler in `run-execution.service.ts` maps
every non-completed turn to `cancelled`, which hides model errors that arrive
with `finishReason === 'error'`. Update the status derivation in `onFinish` to
mirror the `onError` mapping: keep `completed` as-is, treat `aborted` as
`cancelled`, and map error finishes to `failed`. Then ensure the recorded
terminal event and `RunsRepository.markFinished` use the corrected status so
`run.failed` is emitted and the UI can surface the error.

In `@apps/api/src/chats/run-stream-bridge.ts`:
- Around line 138-180: The timeout path in runStreamBridge currently breaks out
of the loop and sends [DONE] without a terminal translator chunk, which can
leave the UI streaming state open. Update the bridge flow in runStreamBridge so
that when the MAX_STREAM_MS limit is hit it emits a terminal chunk through
translator.translate or otherwise finalizes the stream with the same
text-end/finish sequence used on normal completion before
controller.enqueue('[DONE]'). Keep the change localized to the runStreamBridge
loop and translator.finished handling.

In `@apps/api/src/chats/runs-worker.service.ts`:
- Around line 132-145: The terminal event is being appended before the guarded
terminal-state update wins, which can leave stale run.failed/run.cancelled
events in replay. In runs-worker.service.ts, update the run via
markFinished(...) first in the affected branches, then only call
RunEventsRepository.append(...) if the transition succeeds, following the same
“transition first, append only if updated” pattern used around RunsRepository
and RunEventsRepository. Apply this to the stale-heartbeat expiration path and
the other cancellation paths referenced by the review so that only the executor
that actually wins the terminal transition writes the terminal event.

In `@apps/api/src/chats/runs.controller.ts`:
- Around line 174-193: The streaming loop in RunsController is sending the
terminal sentinel even when it exits due to the max stream timeout, which can
stop clients from reconnecting with their cursor. Update the logic around the
timeout break and the final response.write('data: [DONE]\n\n') so that [DONE] is
only sent when a run is truly terminal or deleted, and not when the stream is
capped but still active. Use the existing flags and symbols in this method,
especially terminalSeen, MAX_STREAM_MS, and the current run lookup via
RunsRepository.findById, to decide whether to close with [DONE] or simply end
the capped stream.

In `@apps/api/src/db/migrations/0014_reply_integrity_trigger.sql`:
- Around line 27-29: The reply integrity migration only validates rows being
inserted or updated as replies, but it does not protect the target message later
from changing in a way that breaks existing references. Update the migration
around assert_message_reply_integrity and messages_reply_integrity to add a new
trigger/function pair that checks any message being updated when it is
referenced by child rows via in_reply_to, and reject changes to role or chat_id
unless the message stays a user message in the same chat. Use a new function
like assert_message_reply_target_integrity and a BEFORE UPDATE OF role, chat_id
trigger on messages to enforce this invariant.

In `@apps/api/src/db/schema/chats.ts`:
- Around line 249-279: The `runEvents` table in `chats.ts` is only protected by
the `run_events_owner` RLS policy, but that still allows future writers with
table privileges to update or delete owned rows. Add a DB-level append-only
guard for `run_events` in the schema, using either command-specific RLS policies
or a `BEFORE UPDATE OR DELETE` trigger that raises on mutation, and keep
`runEvents` as the symbol to update. After changing the schema, regenerate the
migration with `db:generate`.
- Around line 139-142: The compaction lineage on parentId only checks that a
parent compaction exists, not that it belongs to the same chat as the child, so
update the chats schema around compactions.parentId to enforce same-chat
ancestry. Add a database-level constraint in the compactions/chatId lineage path
that verifies parent.chat_id matches NEW.chat_id, using the appropriate
composite foreign key or a trigger/check mechanism if the relation cannot be
expressed directly in the schema. Regenerate migrations after updating the
schema definition so the new isolation rule is applied consistently.
- Around line 203-207: The runs schema currently only validates that chatId
exists, so add a database-level constraint or trigger in the runs/chats schema
to enforce that runs.userId always matches chats.ownerUserId for the referenced
chat. Also enforce that any messageId on a run belongs to the same chatId, using
the existing runs table fields and chats/users ownership symbols so the
invariant is enforced even if an internal writer regresses. Update the schema
truth in src/db/schema and regenerate the migration after adding the
constraint/trigger.

In `@apps/api/src/models/fake-model-client.ts`:
- Around line 60-95: The fake stream client eagerly creates textStream in
createFakeStreamTextResult, which can trigger finish() before the stream is
actually consumed. Make textStream lazy like text by deferring
createTextStream(...) until the textStream property is accessed, preserving the
consumption-driven timing in createFakeStreamTextResult/createTextStream.

---

Minor comments:
In `@apps/api/openapi.json`:
- Around line 626-756: The new run endpoints are missing explicit auth metadata
even though they return 401, so generated clients may treat them as public. Add
the same security requirements used by the authenticated chat stream route to
the Swagger decorators for RunsController_getRun, RunsController_updateRun, and
RunsController_streamRunEvents, then regenerate the openapi spec so the
/api/v1/runs/{id} and /api/v1/runs/{id}/events entries include the security
blocks.

In `@apps/api/src/auth/auth.service.ts`:
- Around line 112-117: In validateToken, the stale-session cleanup path under
the !session?.userId?.trim() check should be best-effort and must not fail the
auth flow. Wrap the this.sessionsRepository.deleteStaleByTokenHash call in a
try/catch (or equivalent) and swallow or log any cleanup error so the method
still returns undefined for expired/unknown tokens. Keep the change scoped to
validateToken and the deleteStaleByTokenHash cleanup branch.

In `@apps/api/src/auth/constants.ts`:
- Around line 17-19: The AUTH_RATE_LIMIT_PER_MINUTE validation in the
AUTH_RATE_LIMIT_PER_MINUTE initializer currently accepts fractional values
because it only checks Number.isFinite and > 0. Update this logic to require a
whole number by also validating that the parsed value is an integer, and keep
the existing fallback behavior for invalid input; use the
AUTH_RATE_LIMIT_PER_MINUTE constant block as the place to apply the fix.

In `@apps/api/src/auth/sessions.repository.ts`:
- Around line 106-112: The listForUser query in SessionsRepository only filters
by expires, so it can return idle sessions that findActiveAndTouch would reject.
Update listForUser to also apply the same lastSeenAt idle-TTL cutoff used for
active session validation, passing the idle TTL into this method and filtering
sessions consistently alongside the existing expires check in the sessions
repository logic.

In `@apps/api/src/queue/pgboss-queue.service.ts`:
- Around line 118-136: Add a direct integration test for pgboss-queue.service’s
cancel() path, since the current spec only hits stopConsumer() in teardown and
won’t fail on cancel regressions. Update the existing queue service test suite
to explicitly call PgbossQueueService.cancel(queue, jobId) and assert the job is
removed/cancelled in the database or via the pg-boss API, using the cancel
method name as the anchor. Keep the existing stopConsumer() teardown helper
unchanged and add a separate job-cancellation case that verifies cancel()
behavior directly.

In `@apps/api/test/qa-evals.e2e-spec.ts`:
- Around line 98-127: The eval setup in qa-evals.e2e-spec.ts sets
COMPACTION_TOKEN_THRESHOLD in beforeAll but never restores it, so later tests
can inherit the override. Update the qa-evals suite to save the previous
process.env.COMPACTION_TOKEN_THRESHOLD value before assigning '300', and restore
or delete it in afterAll after app.close(); use the beforeAll/afterAll hooks in
the qa-evals.e2e-spec.ts setup to keep the override scoped to this suite.

In `@apps/api/test/worker-mode.e2e-spec.ts`:
- Around line 290-300: The mid-flight cancel test is using latestRun(chatId),
which can return a queued run and accidentally validate the pre-start cancel
path instead of the abort path. In the worker-mode e2e spec, update the wait
logic around latestRun/chatId to poll until the run status is running_model
before issuing the PATCH to /api/v1/runs/:id, so the test only cancels an
executing run.
- Around line 172-209: The worker-mode e2e setup is mutating RUN_EXECUTION_MODE,
RUN_TIMEOUT_SECONDS, RUN_HEARTBEAT_STALE_SECONDS, and RUN_HEARTBEAT_SECONDS and
then deleting them unconditionally, which can clobber pre-existing test env
state. In worker-mode.e2e-spec.ts, update the beforeAll/afterAll block to
capture the original values before setting them and restore those exact values
after the suite instead of deleting them. Use the existing beforeAll/afterAll
hooks and the process.env assignments in this spec as the place to implement the
save/restore logic.

In `@apps/web/lib/services/chat/transport.test.ts`:
- Around line 1-23: The chat transport test file no longer exercises
prepareSendMessagesRequest, leaving the send-path helper in transport.ts
untested. Add back a unit test alongside buildChatMessagesUrl,
buildChatStreamUrl, and prepareReconnectToStreamRequest that calls
prepareSendMessagesRequest with a chat id and verifies the generated request
targets the chat messages endpoint. Keep the coverage in the existing chat
transport describe block so the helper is easy to locate if symbols change.

In `@ROADMAP.md`:
- Around line 5-8: Update the status block in ROADMAP.md so it only describes
future work: remove the shipped v0.1 summary and any “Now” language, and keep
only upcoming items such as v0.2 and tracking references. Use the existing
ROADMAP section content as the target, and move any completed-release wording to
CHANGELOG.md instead.

---

Nitpick comments:
In `@apps/api/src/chats/chats.module.ts`:
- Around line 20-33: The ChatsModule currently bundles the RunsController and
several run-related providers, which conflicts with the one-module-per-feature
guideline. Extract the run-specific pieces into a dedicated RunsModule
(including RunsController, RunExecutionService, RunStreamBridgeService,
RunsWorkerService, RunAbortRegistry, and any related compaction/title services
if appropriate), then import that feature module from the app-level module
instead of wiring them through ChatsModule.

In `@apps/api/src/chats/run-stream-bridge.ts`:
- Around line 166-176: The idle polling path in run-stream-bridge is doing an
extra RunsRepository.findById lookup on every empty events cycle, which doubles
DB traffic in the common steady state. Update the polling loop in
run-stream-bridge to only re-check run existence after N consecutive empty polls
(or another throttled cadence) while keeping the existing terminal-without-event
safeguard, and reuse the existing listByRunId/events.length logic to avoid
redundant tenantDb.runAs lookups.

In `@apps/api/src/db/migrations/0013_round_expediter.sql`:
- Line 1: The new unique index on runs should be built without blocking writes
in production. Update the migration to create the runs_chat_inflight_unique
index using CONCURRENTLY, and ensure the migration is structured to run outside
a transaction block since concurrent index creation is not allowed inside one.
Refer to the index definition in the migration itself when making the change.

In `@apps/api/src/db/migrations/meta/0011_snapshot.json`:
- Around line 799-1075: Add RLS coverage for the new runs and run_events
snapshot by running scripts/rls-test.sh and ensuring the owner-scoping policies
in runs_owner and run_events_owner are exercised. If this test is not already
part of CI for schema/RLS changes, wire it in so future updates to this surface
automatically validate the current user_id-based access rules.

In `@apps/api/src/queue/queue.integration.spec.ts`:
- Around line 47-213: Add integration coverage for Queue.cancel() in the
existing queue test suite so the public contract is exercised alongside
enqueue/consume/retry/DLQ/cron/deferred flows. Create a new test near the other
cases in the describe block that enqueues a delayed job, captures the returned
job id from queue.enqueue, calls queue.cancel(name, jobId), then sets up a
consumer and verifies the payload is never delivered. Use the existing tag,
consume helper, and Queue interface methods to keep the test consistent and easy
to locate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9d9ad8d2-974d-482b-b1c3-586c741a7cfd

📥 Commits

Reviewing files that changed from the base of the PR and between f86689d and 86994b1.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (88)
  • .github/workflows/ci.yml
  • AGENTS.md
  • CHANGELOG.md
  • ROADMAP.md
  • VISION.md
  • apps/api/.env.example
  • apps/api/AGENTS.md
  • apps/api/openapi.json
  • apps/api/package.json
  • apps/api/src/app.controller.ts
  • apps/api/src/app.module.ts
  • apps/api/src/app.setup.ts
  • apps/api/src/auth/auth.controller.ts
  • apps/api/src/auth/auth.module.ts
  • apps/api/src/auth/auth.service.spec.ts
  • apps/api/src/auth/auth.service.ts
  • apps/api/src/auth/constants.ts
  • apps/api/src/auth/public.decorator.ts
  • apps/api/src/auth/session-auth.guard.spec.ts
  • apps/api/src/auth/session-auth.guard.ts
  • apps/api/src/auth/session-cleanup.service.ts
  • apps/api/src/auth/sessions.repository.ts
  • apps/api/src/chats/chat-loop.service.ts
  • apps/api/src/chats/chats-repository.spec.ts
  • apps/api/src/chats/chats-repository.ts
  • apps/api/src/chats/chats-rls.integration.spec.ts
  • apps/api/src/chats/chats.controller.spec.ts
  • apps/api/src/chats/chats.controller.ts
  • apps/api/src/chats/chats.module.ts
  • apps/api/src/chats/compaction.service.ts
  • apps/api/src/chats/compaction.spec.ts
  • apps/api/src/chats/compaction.ts
  • apps/api/src/chats/context-builder.spec.ts
  • apps/api/src/chats/context-builder.ts
  • apps/api/src/chats/delta-buffer.spec.ts
  • apps/api/src/chats/delta-buffer.ts
  • apps/api/src/chats/dto/runs.dto.ts
  • apps/api/src/chats/run-abort-registry.ts
  • apps/api/src/chats/run-execution.service.ts
  • apps/api/src/chats/run-stream-bridge.spec.ts
  • apps/api/src/chats/run-stream-bridge.ts
  • apps/api/src/chats/runs-repository.ts
  • apps/api/src/chats/runs-worker.service.ts
  • apps/api/src/chats/runs.controller.ts
  • apps/api/src/chats/title.service.ts
  • apps/api/src/chats/title.spec.ts
  • apps/api/src/chats/title.ts
  • apps/api/src/db/migrations/0009_cool_lenny_balinger.sql
  • apps/api/src/db/migrations/0010_cuddly_reavers.sql
  • apps/api/src/db/migrations/0011_puzzling_ricochet.sql
  • apps/api/src/db/migrations/0012_nebulous_lila_cheney.sql
  • apps/api/src/db/migrations/0013_round_expediter.sql
  • apps/api/src/db/migrations/0014_reply_integrity_trigger.sql
  • apps/api/src/db/migrations/0015_clean_human_fly.sql
  • apps/api/src/db/migrations/meta/0009_snapshot.json
  • apps/api/src/db/migrations/meta/0010_snapshot.json
  • apps/api/src/db/migrations/meta/0011_snapshot.json
  • apps/api/src/db/migrations/meta/0012_snapshot.json
  • apps/api/src/db/migrations/meta/0013_snapshot.json
  • apps/api/src/db/migrations/meta/0014_snapshot.json
  • apps/api/src/db/migrations/meta/0015_snapshot.json
  • apps/api/src/db/migrations/meta/_journal.json
  • apps/api/src/db/schema/auth.ts
  • apps/api/src/db/schema/chats.ts
  • apps/api/src/models/fake-model-client.ts
  • apps/api/src/models/model-client.spec.ts
  • apps/api/src/models/model-client.ts
  • apps/api/src/models/models.service.spec.ts
  • apps/api/src/models/models.service.ts
  • apps/api/src/models/openai-model-client.ts
  • apps/api/src/queue/pgboss-queue.service.ts
  • apps/api/src/queue/queue.integration.spec.ts
  • apps/api/src/queue/queue.module.ts
  • apps/api/src/queue/queue.ts
  • apps/api/test/auth.e2e-spec.ts
  • apps/api/test/chats-messages.e2e-spec.ts
  • apps/api/test/jest-e2e.json
  • apps/api/test/qa-evals.e2e-spec.ts
  • apps/api/test/worker-mode.e2e-spec.ts
  • apps/web/app/(chat)/components/chat-page.tsx
  • apps/web/lib/services/chat/transport.test.ts
  • apps/web/lib/services/chat/transport.ts
  • e2e/chat/chat-flow.spec.ts
  • e2e/model-server.ts
  • packages/ui/package.json
  • packages/ui/src/components/code-block.tsx
  • packages/ui/types/ua-client-hints.d.ts
  • playwright.config.ts

Comment thread apps/api/src/app.setup.ts
Comment thread apps/api/src/chats/compaction.service.ts
Comment thread apps/api/src/chats/run-execution.service.ts
Comment thread apps/api/src/chats/run-execution.service.ts
Comment thread apps/api/src/chats/run-stream-bridge.ts
Comment thread apps/api/src/db/migrations/0014_reply_integrity_trigger.sql
Comment thread apps/api/src/db/schema/chats.ts
Comment thread apps/api/src/db/schema/chats.ts
Comment thread apps/api/src/db/schema/chats.ts
Comment thread apps/api/src/models/fake-model-client.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

16 issues found across 89 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/api/package.json
Comment thread apps/api/src/chats/run-execution.service.ts
Comment thread apps/api/src/chats/chat-loop.service.ts
Comment thread apps/api/src/chats/context-builder.ts
Comment thread apps/api/src/db/migrations/0009_cool_lenny_balinger.sql
Comment thread apps/api/src/chats/run-stream-bridge.ts
Comment thread apps/api/src/auth/auth.service.ts
Comment thread apps/api/src/db/migrations/0010_cuddly_reavers.sql
Comment thread apps/api/src/chats/run-execution.service.ts
Comment thread apps/api/src/chats/runs.controller.ts
leon0399 added a commit that referenced this pull request Jul 3, 2026
Review findings on the rebased #105:

- ensureQueue assumed pg-boss createQueue() upserts, but in v12 it is
  INSERT ... ON CONFLICT DO NOTHING — a redeploy with changed
  QueueOptions silently kept the old policy forever. ensureQueue now
  follows createQueue with updateQueue (COALESCE per passed field), so
  it is a real idempotent policy apply on every boot; proven by a new
  integration case (re-ensure with a different retryLimit reflects in
  the stored queue).
- cancel() had no direct coverage (CodeRabbit, umbrella #101): a new
  case enqueues a deferred job, cancels it, and asserts the stored
  state is 'cancelled' before delivery was ever possible.
- The spec's private waitFor poll helper moved to test/support.ts as
  the shared copy (4th reimplementation in the tree; the other three
  suites migrate separately).

Integration suite: 7/7 against a throwaway Postgres.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014peA4x8efDtokaggcbyokP
leon0399 added a commit that referenced this pull request Jul 3, 2026
/code-review findings on the rebased #106, plus surviving items from
the #106/#101 review threads:

- extracted finalizeRun with a first-writer-wins gate: onError and
  onFinish can in principle both fire for one stream; markFinished's
  finished_at guard protects the runs ROW but the append-only event log
  had no guard — two contradictory terminal events were possible. Also
  dedupes the drain-then-finalize sequence the two callbacks had
  copy-pasted.
- a synchronous throw from client.streamText (provider/config
  validation before any callback fires) stranded the run at
  'running_model' forever; the call is now wrapped and finalizes the
  run as failed before rethrowing.
- runs.controller wraps the post-headers SSE loop in try/catch: DB
  errors mid-poll can't reach the exception filter on a flushed
  stream (from #101 review).
- CHANGELOG: removed merge-conflict markers committed by the previous
  rebase pass (duplicate/contradictory bullets deduped, the #48/#49
  replay bullet moved to the ship-date section).
- documented the concurrent-duplicate-run gap at the creation site:
  the real fix is per-chat single-flight, deliberately deferred to the
  heartbeat slice of #48 (without heartbeat a crashed run deadlocks
  its chat).

Deferred with rationale (not fixed here): lost terminal dual-writes
leave a run non-terminal until the deadman sweep lands (later #48
slice) — recordRunProgress swallowing failures is by design so the
live stream never breaks.

Verified: tsgo + oxlint clean, unit 117, full rls-test.sh green
(RLS 11, queue 7, e2e 27).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014peA4x8efDtokaggcbyokP
leon0399 added a commit that referenced this pull request Jul 3, 2026
…tence

/code-review (high) + PR #107/#101 thread triage on the rebased branch:

- markStarted is now a real check-and-claim: it refuses a run already
  at running_model unless the caller opts into crash recovery
  (reclaimStaleMs) AND the last sign of life is older than that window
  — the claim stamps a fresh heartbeat in the same UPDATE, so two
  consumers racing to reclaim a stale run can never both win. Prevents
  a duplicate concurrent model call (double provider spend) when a
  heartbeat write merely lagged. Worker redelivery passes the window;
  inline mode never reclaims.
- assistant persistence is decoupled from run bookkeeping: finishRun
  returns a tri-state (won / lost+finalStatus / errored) — a transient
  DB error in bookkeeping no longer drops a fully streamed reply from
  history, and losing the terminal claim to the deadman ('expired', a
  liveness misjudgment) persists the turn anyway; only an intentional
  terminal state written by someone else (cancelled = user stop or
  supersede, or a dual-fire that already recorded it) suppresses it.
- both expiry paths (deadman sweep, single-flight unwedge) now append
  'run.expired' — the RunEventType invariant's own type, previously
  unused while the log said run.failed — and the stream-bridge
  translator surfaces it; all terminal writers outside finishRun now
  markFinished FIRST and append the event only when they won the
  transition (deadman, pickup-gate cancel, cancelled-meanwhile,
  unwedge), so a racing finish can't leave a contradictory terminal
  event.
- migration 0012's backfill now appends matching run.cancelled events
  for the runs it cancels (CTE UPDATE…RETURNING → INSERT) — the
  invariant holds for historical rows too.
- the run job and its deadman timeout job enqueue in parallel (no
  ordering dependency; a timeout job for a failed run-enqueue just
  expires the orphan sooner); the enqueue-not-transactional design
  gap and its self-heal story are documented at the site (#48
  constraint 1).

Rejected with rationale: making the run claim best-effort again — it
is now load-bearing for single-execution exclusivity and deliberately
fails the turn instead of double-running it.

Verified: tsgo + oxlint + prettier clean, unit 125, nest build +
openapi, full rls-test.sh green (RLS 11, queue 7, e2e 34).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014peA4x8efDtokaggcbyokP
leon0399 added a commit that referenced this pull request Jul 3, 2026
…tence

/code-review (high) + PR #107/#101 thread triage on the rebased branch:

- markStarted is now a real check-and-claim: it refuses a run already
  at running_model unless the caller opts into crash recovery
  (reclaimStaleMs) AND the last sign of life is older than that window
  — the claim stamps a fresh heartbeat in the same UPDATE, so two
  consumers racing to reclaim a stale run can never both win. Prevents
  a duplicate concurrent model call (double provider spend) when a
  heartbeat write merely lagged. Worker redelivery passes the window;
  inline mode never reclaims.
- assistant persistence is decoupled from run bookkeeping: finishRun
  returns a tri-state (won / lost+finalStatus / errored) — a transient
  DB error in bookkeeping no longer drops a fully streamed reply from
  history, and losing the terminal claim to the deadman ('expired', a
  liveness misjudgment) persists the turn anyway; only an intentional
  terminal state written by someone else (cancelled = user stop or
  supersede, or a dual-fire that already recorded it) suppresses it.
- both expiry paths (deadman sweep, single-flight unwedge) now append
  'run.expired' — the RunEventType invariant's own type, previously
  unused while the log said run.failed — and the stream-bridge
  translator surfaces it; all terminal writers outside finishRun now
  markFinished FIRST and append the event only when they won the
  transition (deadman, pickup-gate cancel, cancelled-meanwhile,
  unwedge), so a racing finish can't leave a contradictory terminal
  event.
- migration 0012's backfill now appends matching run.cancelled events
  for the runs it cancels (CTE UPDATE…RETURNING → INSERT) — the
  invariant holds for historical rows too.
- the run job and its deadman timeout job enqueue in parallel (no
  ordering dependency; a timeout job for a failed run-enqueue just
  expires the orphan sooner); the enqueue-not-transactional design
  gap and its self-heal story are documented at the site (#48
  constraint 1).

Rejected with rationale: making the run claim best-effort again — it
is now load-bearing for single-execution exclusivity and deliberately
fails the turn instead of double-running it.

Verified: tsgo + oxlint + prettier clean, unit 125, nest build +
openapi, full rls-test.sh green (RLS 11, queue 7, e2e 34).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014peA4x8efDtokaggcbyokP
leon0399 added a commit that referenced this pull request Jul 4, 2026
…tence

/code-review (high) + PR #107/#101 thread triage on the rebased branch:

- markStarted is now a real check-and-claim: it refuses a run already
  at running_model unless the caller opts into crash recovery
  (reclaimStaleMs) AND the last sign of life is older than that window
  — the claim stamps a fresh heartbeat in the same UPDATE, so two
  consumers racing to reclaim a stale run can never both win. Prevents
  a duplicate concurrent model call (double provider spend) when a
  heartbeat write merely lagged. Worker redelivery passes the window;
  inline mode never reclaims.
- assistant persistence is decoupled from run bookkeeping: finishRun
  returns a tri-state (won / lost+finalStatus / errored) — a transient
  DB error in bookkeeping no longer drops a fully streamed reply from
  history, and losing the terminal claim to the deadman ('expired', a
  liveness misjudgment) persists the turn anyway; only an intentional
  terminal state written by someone else (cancelled = user stop or
  supersede, or a dual-fire that already recorded it) suppresses it.
- both expiry paths (deadman sweep, single-flight unwedge) now append
  'run.expired' — the RunEventType invariant's own type, previously
  unused while the log said run.failed — and the stream-bridge
  translator surfaces it; all terminal writers outside finishRun now
  markFinished FIRST and append the event only when they won the
  transition (deadman, pickup-gate cancel, cancelled-meanwhile,
  unwedge), so a racing finish can't leave a contradictory terminal
  event.
- migration 0012's backfill now appends matching run.cancelled events
  for the runs it cancels (CTE UPDATE…RETURNING → INSERT) — the
  invariant holds for historical rows too.
- the run job and its deadman timeout job enqueue in parallel (no
  ordering dependency; a timeout job for a failed run-enqueue just
  expires the orphan sooner); the enqueue-not-transactional design
  gap and its self-heal story are documented at the site (#48
  constraint 1).

Rejected with rationale: making the run claim best-effort again — it
is now load-bearing for single-execution exclusivity and deliberately
fails the turn instead of double-running it.

Verified: tsgo + oxlint + prettier clean, unit 125, nest build +
openapi, full rls-test.sh green (RLS 11, queue 7, e2e 34).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014peA4x8efDtokaggcbyokP
@leon0399

leon0399 commented Jul 5, 2026

Copy link
Copy Markdown
Owner Author

Superseded and fully absorbed by the replacement stack — every change in this umbrella reached master through the reviewed slices: #104 (v0.1 completion), #105 (pg-boss queue substrate, #47), #106 (durable-run substrate, #48 part 1), #107 (worker run execution, #48/#50), #108 (streaming-loop hardening, #73), #109 (auth hardening, #68), #110 (chat stream resume + browser e2e, #49/#80). All review threads here were individually triaged: fixed in the slices, refuted with evidence, or tracked in #125. Closing without merge.

@leon0399 leon0399 closed this Jul 5, 2026
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