Skip to content

Guard provider lifecycle and checkpoints to primary thread/active turn#106

Merged
juliusmarminge merged 4 commits intomainfrom
codething/64ab84d3
Feb 27, 2026
Merged

Guard provider lifecycle and checkpoints to primary thread/active turn#106
juliusmarminge merged 4 commits intomainfrom
codething/64ab84d3

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Feb 27, 2026

restores the correct order and shows the response banner

CleanShot 2026-02-27 at 14 06 44@2x

Summary

  • Enforce lifecycle ownership in runtime ingestion so only primary-scope events update session lifecycle state (status, activeTurnId, providerThreadId).
  • Add strict guards for turn.completed/runtime.error handling to ignore auxiliary or conflicting thread/turn events.
  • Prevent checkpoint capture from auxiliary thread completions or non-active turn completions while a primary turn is active.
  • Tighten provider runtime contracts to require provider-native IDs (ProviderThreadId, ProviderTurnId) for runtime events.
  • Add ADR 0001 documenting lifecycle ownership invariant, tradeoffs, and follow-ups.
  • Expand tests across ingestion/checkpoint/provider layers for auxiliary-thread and non-active-turn completion scenarios.

Testing

  • Added unit/integration coverage in apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts for auxiliary-thread and non-active-turn completion guards.
  • Added checkpoint guard coverage in apps/server/src/orchestration/Layers/CheckpointReactor.test.ts for ignoring auxiliary completions during active primary turns.
  • Updated provider service fanout tests in apps/server/src/provider/Layers/ProviderService.test.ts to use ProviderThreadId typing.
  • Not run: lint/test commands were not executed as part of preparing this PR description.

Note

High Risk
Touches core orchestration state transitions, checkpoint capture, and shared contracts/UI thread models; incorrect guards or schema mismatches could drop lifecycle updates or misreport turn state across clients.

Overview
Prevents auxiliary/provider-spawned work from disrupting the user-visible thread by gating lifecycle transitions in ProviderRuntimeIngestion (env-togglable T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD) and filtering checkpoint capture in CheckpointReactor to ignore mismatched ProviderThreadId events and non-active turn.completed while a primary turn is running.

Upgrades the thread model from latestTurnId to a structured latestTurn (state + timestamps + assistant message id), hydrates it in ProjectionSnapshotQuery from projection_turns, and updates the projector and web UI/store logic accordingly. Contracts are tightened so provider runtime events use provider-native IDs only (ProviderThreadId/ProviderTurnId), with expanded server/web tests and a new ADR documenting the lifecycle ownership invariant.

Written by Cursor Bugbot for commit 4e3e689. This will update automatically on new commits. Configure here.

Note

Guard provider runtime lifecycle to the active turn and switch threads to structured orchestration.OrchestrationLatestTurn across server and web in ProviderRuntimeIngestion.ts and projector.ts

Introduce strict checks that ignore out‑of‑scope provider events and non‑active turn completions, gated by STRICT_PROVIDER_LIFECYCLE_GUARD, and replace latestTurnId with structured latestTurn state in contracts, server projection, and web store/components.

📍Where to Start

Start with lifecycle filtering in ProviderRuntimeIngestion.processRuntimeEvent in ProviderRuntimeIngestion.ts, then review CheckpointReactor.start in CheckpointReactor.ts and projection updates in projector.projectEvent in projector.ts.

Macroscope summarized 4e3e689.

Summary by CodeRabbit

  • Bug Fixes

    • Stricter lifecycle checks so auxiliary or non-matching turn completions no longer create checkpoints or mutate primary thread state.
  • Tests

    • Added tests covering auxiliary-thread completions, missing-thread scenarios, and multi-thread checkpoint behavior to validate lifecycle enforcement.
  • Documentation

    • Added an ADR describing provider runtime lifecycle ownership and enforcement.
  • Chores

    • Standardized runtime events to use provider-specific thread/turn identifiers and changed thread snapshots: latestTurn is now a structured object with state and timestamps (replacing separate id/timestamp fields).

- Ignore auxiliary/non-active turn completions for session lifecycle and checkpoints
- Restrict runtime thread/turn IDs to provider IDs in contracts
- Add regression tests and ADR for lifecycle ownership rules
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 27, 2026

Walkthrough

Adds provider-scoped ProviderThreadId/ProviderTurnId checks and a STRICT_PROVIDER_LIFECYCLE_GUARD that restricts session/thread/turn lifecycle mutations and checkpoint creation to matching primary provider-thread/turn contexts; concurrently replaces scalar latestTurnId with a structured latestTurn across projection, projector, contracts, tests, and web UI state.

Changes

Cohort / File(s) Summary
Provider lifecycle & ingestion
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts, apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
Adds STRICT_PROVIDER_LIFECYCLE_GUARD, toProviderThreadId/sameId, and shouldApplyThreadLifecycle to gate lifecycle mutations and runtime.error handling by provider-thread/active-turn parity; tests added for ignored auxiliary/missing-thread completions.
Checkpoint reconciliation & tests
apps/server/src/orchestration/Layers/CheckpointReactor.ts, apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
Introduces ProviderThreadId checks (toProviderThreadId, sameId) and active-turn consistency in checkpoint completion paths; updates tests to use ProviderTurnId and to assert behaviour when auxiliary/non-matching completions occur.
Runtime contracts (provider events)
packages/contracts/src/providerRuntime.ts
Removes union runtime ID schemas and standardizes provider runtime event types to use ProviderThreadId / ProviderTurnId throughout (thread/turn/message/tool/approval/checkpoint/error events).
Projection / projector / read-model shape
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts, apps/server/src/orchestration/projector.ts, packages/contracts/src/orchestration.ts
Replaces latestTurnId with structured latestTurn (turnId, state, requestedAt, startedAt, completedAt, assistantMessageId); adds DB query for latest turns and maps rows into latestTurn on threads; updates public schema/type and mapping logic.
Tests & read-model usages (server)
apps/server/.../ProjectionSnapshotQuery.test.ts, CheckpointDiffQuery.test.ts, orchestration/projector.test.ts, orchestration/commandInvariants.test.ts, orchestration/Layers/*, integration/orchestrationEngine.integration.test.ts, apps/server/src/checkpointing/...
Widespread test updates to replace latestTurnId with latestTurn and to access latestTurn?.turnId/timestamps; snapshot/test fixtures updated to the richer latestTurn shape.
Provider service tests helper
apps/server/src/provider/Layers/ProviderService.test.ts
Adds asProviderThreadId test helper (wraps makeUnsafe) and replaces ThreadId usage in test fixtures with ProviderThreadId-typed values.
ADR / docs
docs/adr/0001-provider-runtime-lifecycle-ownership.md
New ADR describing invariant that only primary provider thread/turn may mutate lifecycle or create checkpoints and outlining enforcement via ingestion guards and follow-ups.
Web UI: state, types, and components
apps/web/src/types.ts, apps/web/src/store.ts, apps/web/src/store.test.ts, apps/web/src/components/ChatView.tsx, apps/web/src/components/Sidebar.tsx, apps/web/src/session-logic.ts, apps/web/src/session-logic.test.ts, apps/web/src/worktreeCleanup.test.ts
Replaces scalar latestTurn fields with `latestTurn: OrchestrationLatestTurn
Misc tests updated
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts, apps/server/.../ProviderRuntimeIngestion.test.ts, apps/server/integration/...
Test assertions and wait predicates updated to use optional-chained latestTurn?.turnId / latestTurn timestamps and ProviderTurnId helpers.

Sequence Diagram(s)

sequenceDiagram
    actor Provider as Provider Runtime
    participant Ingestion as Provider Runtime Ingestion
    participant Guard as Lifecycle Guard
    participant Domain as Domain Layer
    participant Checkpoint as Checkpoint Reactor

    Provider->>Ingestion: emit turn.completed (threadId?, turnId?)
    Ingestion->>Guard: shouldApplyThreadLifecycle(event, session, thread)
    alt Guard passes (matching providerThreadId & active primary turn)
        Guard-->>Ingestion: allow
        Ingestion->>Domain: update session lifecycle (status, activeTurnId)
        Ingestion->>Checkpoint: forward turn completion for checkpointing
        Checkpoint->>Checkpoint: verify providerThreadId & runtime turn alignment
        Checkpoint-->>Domain: record checkpoint
    else Guard fails (auxiliary thread, missing/mismatched providerThreadId, or non-active turn)
        Guard-->>Ingestion: deny
        Ingestion->>Domain: skip lifecycle mutation (preserve primary state)
        Note over Checkpoint: no checkpoint created from this event
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: introducing guards for provider lifecycle and checkpoint operations scoped to primary thread/active turn contexts, which is the core intent across CheckpointReactor, ProviderRuntimeIngestion, and contract updates.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codething/64ab84d3

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts (1)

336-337: ⚠️ Potential issue | 🔴 Critical

Fix type mismatch causing pipeline failure.

The turnId field in turn.started events now requires ProviderTurnId per the updated schema, but asTurnId returns TurnId. This causes the TS2322 error reported in the pipeline.

🐛 Proposed fix

Add the ProviderTurnId import and helper, then update the event:

 import {
   CommandId,
   EventId,
   MessageId,
   ProjectId,
   ProviderSessionId,
   ProviderThreadId,
+  ProviderTurnId,
   ThreadId,
   TurnId,
 } from "@t3tools/contracts";

Add a helper near line 43:

 const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
+const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);

Then update line 337:

-      turnId: asTurnId("turn-1"),
+      turnId: asProviderTurnId("turn-1"),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/orchestration/Layers/CheckpointReactor.test.ts` around lines
336 - 337, The test fails because the turn.started event uses asTurnId("turn-1")
which yields a TurnId but the schema now requires a ProviderTurnId; import
ProviderTurnId, add a small helper that constructs ProviderTurnId (e.g.
ProviderTurnId.makeUnsafe helper) alongside other test helpers, and replace the
asTurnId usage in the turn.started event's turnId with the new ProviderTurnId
helper so the turnId field is typed as ProviderTurnId.
🧹 Nitpick comments (1)
apps/server/src/orchestration/Layers/CheckpointReactor.ts (1)

40-49: Consider extracting shared helpers to reduce duplication.

The toProviderThreadId and sameId helper functions are duplicated in ProviderRuntimeIngestion.ts (lines 57-66). Consider extracting these to a shared utility module to maintain DRY principles.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/orchestration/Layers/CheckpointReactor.ts` around lines 40 -
49, The helpers toProviderThreadId and sameId are duplicated between
CheckpointReactor.ts and ProviderRuntimeIngestion.ts; extract them into a shared
utility module (e.g., create a new file like idUtils or providerIdUtils) and
replace the local implementations in both files with imports of those functions;
ensure the exported function names match (toProviderThreadId, sameId) and keep
the same behavior (returning null or boolean as currently implemented) and
update any imports/usages in CheckpointReactor.ts and
ProviderRuntimeIngestion.ts accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@apps/server/src/orchestration/Layers/CheckpointReactor.test.ts`:
- Around line 336-337: The test fails because the turn.started event uses
asTurnId("turn-1") which yields a TurnId but the schema now requires a
ProviderTurnId; import ProviderTurnId, add a small helper that constructs
ProviderTurnId (e.g. ProviderTurnId.makeUnsafe helper) alongside other test
helpers, and replace the asTurnId usage in the turn.started event's turnId with
the new ProviderTurnId helper so the turnId field is typed as ProviderTurnId.

---

Nitpick comments:
In `@apps/server/src/orchestration/Layers/CheckpointReactor.ts`:
- Around line 40-49: The helpers toProviderThreadId and sameId are duplicated
between CheckpointReactor.ts and ProviderRuntimeIngestion.ts; extract them into
a shared utility module (e.g., create a new file like idUtils or
providerIdUtils) and replace the local implementations in both files with
imports of those functions; ensure the exported function names match
(toProviderThreadId, sameId) and keep the same behavior (returning null or
boolean as currently implemented) and update any imports/usages in
CheckpointReactor.ts and ProviderRuntimeIngestion.ts accordingly.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 041acf1 and d19b2f5.

📒 Files selected for processing (7)
  • apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
  • apps/server/src/orchestration/Layers/CheckpointReactor.ts
  • apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
  • apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
  • apps/server/src/provider/Layers/ProviderService.test.ts
  • docs/adr/0001-provider-runtime-lifecycle-ownership.md
  • packages/contracts/src/providerRuntime.ts

return sameId(activeTurnId, eventTurnId);
}
// Without an active turn, only accept completion when no thread mismatch signal exists.
return eventProviderThreadId === null || sessionProviderThreadId === null;
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.

🟡 Medium Layers/ProviderRuntimeIngestion.ts:398

When activeTurnId is null but both eventProviderThreadId and sessionProviderThreadId are non-null and equal, the final return eventProviderThreadId === null || sessionProviderThreadId === null evaluates to false, skipping the session update. Consider returning true when thread IDs match (e.g., return ... || sameId(eventProviderThreadId, sessionProviderThreadId)) so orphan turn completions still update session state.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts around line 398:

When `activeTurnId` is null but both `eventProviderThreadId` and `sessionProviderThreadId` are non-null and equal, the final return `eventProviderThreadId === null || sessionProviderThreadId === null` evaluates to `false`, skipping the session update. Consider returning `true` when thread IDs match (e.g., `return ... || sameId(eventProviderThreadId, sessionProviderThreadId)`) so orphan turn completions still update session state.

Evidence trail:
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts lines 352-355 (definition of matchesThreadScope includes sameId check), lines 389-398 (turn.completed case logic with final return at line 398), lines 435-452 (session update dispatch that depends on shouldApplyThreadLifecycle being true)

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Turn completion blocked when no active turn tracked
    • Changed the turn.completed fallback from return eventProviderThreadId === null || sessionProviderThreadId === null to return true, since matchesThreadScope is already confirmed true at that point and the old condition incorrectly blocked completions when both thread IDs were present and matching.
  • ✅ Fixed: Unreachable duplicate guard for thread scope check
    • Removed the dead code block (lines 373-379) that duplicated the !matchesThreadScope check already handled on line 369, since its condition could never be true after the preceding guard.

Create PR

Or push these changes by commenting:

@cursor push 1c6fa9456a
Preview (1c6fa9456a)
diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
@@ -369,14 +369,6 @@
             if (!matchesThreadScope) {
               return false;
             }
-            // Never let auxiliary/provider-side spawned threads replace the primary thread binding.
-            if (
-              eventProviderThreadId !== null &&
-              sessionProviderThreadId !== null &&
-              !sameId(eventProviderThreadId, sessionProviderThreadId)
-            ) {
-              return false;
-            }
             return true;
           case "turn.started":
             if (!matchesThreadScope) {
@@ -394,8 +386,8 @@
             if (activeTurnId !== null && eventTurnId !== undefined) {
               return sameId(activeTurnId, eventTurnId);
             }
-            // Without an active turn, only accept completion when no thread mismatch signal exists.
-            return eventProviderThreadId === null || sessionProviderThreadId === null;
+            // Without an active turn, accept completion since thread scope already matches.
+            return true;
           default:
             return true;
         }
@@ -415,8 +407,7 @@
             : event.threadId !== undefined
               ? ProviderThreadId.makeUnsafe(event.threadId)
               : null;
-        const providerThreadId =
-          providerThreadIdFromEvent ?? sessionProviderThreadId ?? null;
+        const providerThreadId = providerThreadIdFromEvent ?? sessionProviderThreadId ?? null;
         const status =
           event.type === "turn.started"
             ? "running"

return sameId(activeTurnId, eventTurnId);
}
// Without an active turn, only accept completion when no thread mismatch signal exists.
return eventProviderThreadId === null || sessionProviderThreadId === null;
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.

Turn completion blocked when no active turn tracked

Medium Severity

The turn.completed fallback in shouldApplyThreadLifecycle returns eventProviderThreadId === null || sessionProviderThreadId === null, which evaluates to false when both provider thread IDs are present and matching but activeTurnId is null. Since matchesThreadScope was already verified on line 387, reaching the fallback means the thread IDs either match or one is absent — so the fallback can safely return true. Instead, it incorrectly blocks legitimate turn completions from the matching provider thread whenever no active turn is tracked (e.g., missed turn.started, restart recovery, or a late/corrected completion arriving after a prior completion already cleared activeTurnId).

Fix in Cursor Fix in Web

Comment thread apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
- Remove redundant runtime schema aliases for thread and turn IDs
- Use `ProviderThreadId` and `ProviderTurnId` directly across runtime event contracts
}

// When a primary turn is active, only that turn may produce completion checkpoints.
if (thread.session?.activeTurnId && !sameId(thread.session.activeTurnId, turnId)) {
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.

🟡 Medium Layers/CheckpointReactor.ts:199

Race condition: activeTurnId is read from the live read model, but if a new turn starts before this turn.completed event is processed, activeTurnId will have advanced, causing this check to incorrectly skip checkpoint capture for the completed turn. Consider removing this guard or comparing against the turn that was active when the completion event was emitted.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/orchestration/Layers/CheckpointReactor.ts around line 199:

Race condition: `activeTurnId` is read from the live read model, but if a new turn starts before this `turn.completed` event is processed, `activeTurnId` will have advanced, causing this check to incorrectly skip checkpoint capture for the completed turn. Consider removing this guard or comparing against the turn that was active when the completion event was emitted.

Evidence trail:
apps/server/src/orchestration/Layers/CheckpointReactor.ts lines 180-202 (REVIEWED_COMMIT): Shows `getReadModel()` call at line 180, the guard at lines 199-201 comparing `thread.session?.activeTurnId` against `turnId` from the event. apps/server/src/orchestration/Layers/OrchestrationEngine.ts lines 219-220 (REVIEWED_COMMIT): Shows `getReadModel` implementation returning direct reference to the live `readModel` object via `Effect.sync((): OrchestrationReadModel => readModel)`. apps/server/src/orchestration/Layers/CheckpointReactor.ts lines 175-176: Shows `turnId` is extracted from the `turn.completed` event parameter.

- Use `ProviderTurnId` in `CheckpointReactor` tests instead of `TurnId`
- Guard `event.turnId` access in provider runtime ingestion when the field is absent
- add `OrchestrationLatestTurn` schema and store full turn state/timestamps/message linkage
- update server projector/snapshot queries to populate `latestTurn` from session + projection_turns
- migrate web store/components to consume `latestTurn` and adjust related tests
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/contracts/src/orchestration.ts (1)

194-200: Deduplicate turn-state schema to avoid drift.

OrchestrationLatestTurnState now duplicates ProjectionThreadTurnStatus semantics. Consider aliasing one to the other so future lifecycle additions cannot diverge.

♻️ Suggested consolidation
-export const ProjectionThreadTurnStatus = Schema.Literals([
-  "running",
-  "completed",
-  "interrupted",
-  "error",
-]);
+export const ProjectionThreadTurnStatus = OrchestrationLatestTurnState;
 export type ProjectionThreadTurnStatus = typeof ProjectionThreadTurnStatus.Type;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/contracts/src/orchestration.ts` around lines 194 - 200,
OrchestrationLatestTurnState duplicates the semantics of
ProjectionThreadTurnStatus; remove the duplicate literal schema and instead
export OrchestrationLatestTurnState as an alias to ProjectionThreadTurnStatus
(or vice versa) so both the runtime Schema and the Type remain identical and
future lifecycle values can't diverge—update any references to
OrchestrationLatestTurnState to use the aliased symbol and keep the exported
type (e.g., export const OrchestrationLatestTurnState =
ProjectionThreadTurnStatus; export type OrchestrationLatestTurnState = typeof
ProjectionThreadTurnStatus.Type).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/server/src/orchestration/Layers/CheckpointReactor.test.ts`:
- Around line 438-442: Remove the timing-based mid-assertion that uses
Effect.runPromise(Effect.sleep(...)) and the immediate read-model check via
harness.engine.getReadModel(); instead synchronize deterministically: either
remove the mid-assertion entirely or replace the sleep+check with a wait-for
pattern that polls harness.engine.getReadModel() (or a harness-provided
processing-complete hook) until the expected state is observed with a bounded
timeout. Locate the usage of Effect.runPromise(Effect.sleep(...)), the
subsequent call to harness.engine.getReadModel(), and
ThreadId.makeUnsafe("thread-1") in the test and change the logic to
poll/getReadModel repeatedly (with a short interval and overall timeout) or use
a deterministic hook so the test does not rely on fixed sleep timing.

---

Nitpick comments:
In `@packages/contracts/src/orchestration.ts`:
- Around line 194-200: OrchestrationLatestTurnState duplicates the semantics of
ProjectionThreadTurnStatus; remove the duplicate literal schema and instead
export OrchestrationLatestTurnState as an alias to ProjectionThreadTurnStatus
(or vice versa) so both the runtime Schema and the Type remain identical and
future lifecycle values can't diverge—update any references to
OrchestrationLatestTurnState to use the aliased symbol and keep the exported
type (e.g., export const OrchestrationLatestTurnState =
ProjectionThreadTurnStatus; export type OrchestrationLatestTurnState = typeof
ProjectionThreadTurnStatus.Type).

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5b13d20 and 4e3e689.

📒 Files selected for processing (17)
  • apps/server/integration/orchestrationEngine.integration.test.ts
  • apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts
  • apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
  • apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts
  • apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
  • apps/server/src/orchestration/commandInvariants.test.ts
  • apps/server/src/orchestration/projector.test.ts
  • apps/server/src/orchestration/projector.ts
  • apps/web/src/components/ChatView.tsx
  • apps/web/src/components/Sidebar.tsx
  • apps/web/src/session-logic.test.ts
  • apps/web/src/session-logic.ts
  • apps/web/src/store.test.ts
  • apps/web/src/store.ts
  • apps/web/src/types.ts
  • apps/web/src/worktreeCleanup.test.ts
  • packages/contracts/src/orchestration.ts

Comment on lines +438 to +442
await Effect.runPromise(Effect.sleep("40 millis"));
const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
expect(midThread?.checkpoints).toHaveLength(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove timing-based mid-assertion to avoid flaky/false-positive behavior.

Line 438 uses a fixed sleep, and Line 441 can pass before the auxiliary event is actually processed. This makes the test nondeterministic under load.

♻️ Suggested test hardening
-    await Effect.runPromise(Effect.sleep("40 millis"));
-    const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
-    const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
-    expect(midThread?.checkpoints).toHaveLength(0);

Based on learnings: "For integration tests: Prefer deterministic inputs and explicit state checks; avoid relying on logs or timing assumptions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/orchestration/Layers/CheckpointReactor.test.ts` around lines
438 - 442, Remove the timing-based mid-assertion that uses
Effect.runPromise(Effect.sleep(...)) and the immediate read-model check via
harness.engine.getReadModel(); instead synchronize deterministically: either
remove the mid-assertion entirely or replace the sleep+check with a wait-for
pattern that polls harness.engine.getReadModel() (or a harness-provided
processing-complete hook) until the expected state is observed with a bounded
timeout. Locate the usage of Effect.runPromise(Effect.sleep(...)), the
subsequent call to harness.engine.getReadModel(), and
ThreadId.makeUnsafe("thread-1") in the test and change the logic to
poll/getReadModel repeatedly (with a short interval and overall timeout) or use
a deterministic hook so the test does not rely on fixed sleep timing.

Comment on lines +62 to +70
const ProjectionLatestTurnDbRowSchema = Schema.Struct({
threadId: ProjectionThread.fields.threadId,
turnId: TurnId,
state: Schema.String,
requestedAt: IsoDateTime,
startedAt: Schema.NullOr(IsoDateTime),
completedAt: Schema.NullOr(IsoDateTime),
assistantMessageId: Schema.NullOr(MessageId),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid coercing unknown turn states to "running" during snapshot reconstruction.

On Line 431, unexpected persisted values are silently mapped to "running". That can mislabel thread state (and downstream unread/completion UX) instead of surfacing data drift.

Proposed fix
 const ProjectionLatestTurnDbRowSchema = Schema.Struct({
   threadId: ProjectionThread.fields.threadId,
   turnId: TurnId,
-  state: Schema.String,
+  state: Schema.Union(
+    Schema.Literal("running"),
+    Schema.Literal("completed"),
+    Schema.Literal("interrupted"),
+    Schema.Literal("error"),
+  ),
   requestedAt: IsoDateTime,
   startedAt: Schema.NullOr(IsoDateTime),
   completedAt: Schema.NullOr(IsoDateTime),
   assistantMessageId: Schema.NullOr(MessageId),
 });
@@
             latestTurnByThread.set(row.threadId, {
               turnId: row.turnId,
-              state:
-                row.state === "error"
-                  ? "error"
-                  : row.state === "interrupted"
-                    ? "interrupted"
-                    : row.state === "completed"
-                      ? "completed"
-                      : "running",
+              state: row.state,
               requestedAt: row.requestedAt,
               startedAt: row.startedAt,
               completedAt: row.completedAt,
               assistantMessageId: row.assistantMessageId,
             });

As per coding guidelines apps/**/*.ts: Use Zod schemas and TypeScript contracts from packages/contracts for provider events, WebSocket protocol, and model/session types.

Also applies to: 428-437

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Duplicate sameId and toProviderThreadId across two files
    • Extracted the duplicated sameId and toProviderThreadId functions into a shared orchestration/providerIdUtils.ts module and updated both CheckpointReactor.ts and ProviderRuntimeIngestion.ts to import from it.

Create PR

Or push these changes by commenting:

@cursor push 12b701365a
Preview (12b701365a)
diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts
--- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts
+++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts
@@ -3,7 +3,6 @@
   EventId,
   MessageId,
   ProviderSessionId,
-  ProviderThreadId,
   ThreadId,
   TurnId,
   type OrchestrationEvent,
@@ -21,6 +20,7 @@
 import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
 import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
 import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
+import { sameId, toProviderThreadId } from "../providerIdUtils.ts";
 import { OrchestrationDispatchError } from "../Errors.ts";
 
 type ReactorInput =
@@ -37,17 +37,6 @@
   return value === undefined ? null : TurnId.makeUnsafe(value);
 }
 
-function toProviderThreadId(value: string | undefined): ProviderThreadId | null {
-  return value === undefined ? null : ProviderThreadId.makeUnsafe(value);
-}
-
-function sameId(left: string | null | undefined, right: string | null | undefined): boolean {
-  if (left === null || left === undefined || right === null || right === undefined) {
-    return false;
-  }
-  return left === right;
-}
-
 function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" {
   switch (status) {
     case "failed":

diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
@@ -20,6 +20,7 @@
   ProviderRuntimeIngestionService,
   type ProviderRuntimeIngestionShape,
 } from "../Services/ProviderRuntimeIngestion.ts";
+import { sameId, toProviderThreadId } from "../providerIdUtils.ts";
 
 const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`;
 const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>
@@ -54,17 +55,6 @@
   return value === undefined ? undefined : TurnId.makeUnsafe(value);
 }
 
-function toProviderThreadId(value: string | undefined): ProviderThreadId | null {
-  return value === undefined ? null : ProviderThreadId.makeUnsafe(value);
-}
-
-function sameId(left: string | null | undefined, right: string | null | undefined): boolean {
-  if (left === null || left === undefined || right === null || right === undefined) {
-    return false;
-  }
-  return left === right;
-}
-
 function truncateDetail(value: string, limit = 180): string {
   return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
 }
@@ -415,8 +405,7 @@
             : event.threadId !== undefined
               ? ProviderThreadId.makeUnsafe(event.threadId)
               : null;
-        const providerThreadId =
-          providerThreadIdFromEvent ?? sessionProviderThreadId ?? null;
+        const providerThreadId = providerThreadIdFromEvent ?? sessionProviderThreadId ?? null;
         const status =
           event.type === "turn.started"
             ? "running"

diff --git a/apps/server/src/orchestration/providerIdUtils.ts b/apps/server/src/orchestration/providerIdUtils.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/orchestration/providerIdUtils.ts
@@ -1,0 +1,12 @@
+import { ProviderThreadId } from "@t3tools/contracts";
+
+export function toProviderThreadId(value: string | undefined): ProviderThreadId | null {
+  return value === undefined ? null : ProviderThreadId.makeUnsafe(value);
+}
+
+export function sameId(left: string | null | undefined, right: string | null | undefined): boolean {
+  if (left === null || left === undefined || right === null || right === undefined) {
+    return false;
+  }
+  return left === right;
+}

return false;
}
return left === right;
}
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.

Duplicate sameId and toProviderThreadId across two files

Low Severity

Both sameId and toProviderThreadId are identically implemented in CheckpointReactor.ts and ProviderRuntimeIngestion.ts. Duplicating these utility functions increases the risk of inconsistent fixes if the comparison logic needs to change (e.g., adding case-insensitive matching or logging). A shared utility module would reduce this maintenance burden.

Additional Locations (1)

Fix in Cursor Fix in Web

@juliusmarminge juliusmarminge merged commit 3f81f9d into main Feb 27, 2026
5 checks passed
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