Skip to content

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

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

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

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Feb 27, 2026

Copy link
Copy Markdown
Member

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

coderabbitai Bot commented Feb 27, 2026

Copy link
Copy Markdown

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.

@coderabbitai coderabbitai 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.

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)

@cursor cursor 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.

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

@coderabbitai coderabbitai 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.

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

@cursor cursor 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.

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
aorwall added a commit to aorwall/t3code that referenced this pull request Sep 6, 2026
Merges upstream t3code into the fork, from the merge base `36c4e9cf`
through
upstream `b438447f`: 395 non-merge upstream commits, 1175 files changed
upstream against 658 in the fork.

Stacks on `harden-fork-merge` (pingdotgg#106), whose merge tooling this merge
exercises
end to end.

## Conflict resolution, by concern

17 files conflicted. Resolved by concern:

- **Contracts** (`packages/contracts/src/rpc.ts`, converged): kept the
fork's
  import block (sandbox, servers, subtasks, threadShellLookup, scripts)
alongside upstream's new `providerUsageLimits` import, and re-applied
the
fork's `UnsupportedMethodError` union entries onto the RPC declarations
  upstream reformatted.
- **Chat and shell**: `ChatMarkdown` keeps the mermaid fence branch on
upstream's new module-level renderer; `Sidebar` keeps the `FEATURES` and
  `useTouchContextMenu` imports; `MessagesTimeline` keeps the fork's
thread-forking activity state and `AssistantForkButton`; `SidebarChrome`
  keeps the `APP_BASE_NAME` span in place of upstream's `T3Wordmark`.
- **Preview**: `previewStateStore` keeps `previewRuntimeCapability` and
`isPreviewSupportedInRuntime`; `PreviewView` and
`ThreadPreviewMiniPlayer`
take upstream's z-index work and keep the fork's framed-preview surface
and
  `hasPreviewSurface` gating.
- **Settings**: upstream's six-section split, with the fork's
  `assistantStreaming`, `projectGrouping`, `projectManagement` and
`threadDeletion` gates preserved inside it, plus the personal/admin nav
split.
- **Docs, AGENTS.md, CI**: `ci.yml` converges upstream's apt-mirror and
  libsecret steps with the fork's Moatless API spec-freshness step;
  `docs/README.md` is the union of both indexes with dead links dropped.

`apps/web/src/AppRoot.test.tsx` was a modify/delete: kept, because it
still
guards the fork-only `WebBrowserHost` render tree. `pnpm-lock.yaml` took
upstream per path policy.

Verification and any follow-ups are recorded in
`docs/fork/upstream-merge-log.md`.

---
Moatless task:
https://moatless.soaplabstest.com/tasks/5f0f1eaf-ab9d-4410-bcc4-f79b4c1f3cef
aorwall added a commit to aorwall/t3code that referenced this pull request Sep 6, 2026
…ngdotgg#106)

The 2026-09-02 merge (pingdotgg#90) lost work twice to sandbox restarts.
Conflicted files came back with their `<<<<<<<` markers and were
obvious; files edited as collateral of resolving a conflict elsewhere
reverted in silence, and one was caught only because typecheck happened
to fail on an export it had removed.

That merge got lucky. Both files it lost were fork-only, and fork-only
files already have a detector — the inventory's `guard` entries are what
turned `features.ts` losing `FEATURES.prThreadSettling` into a failing
test. **Nothing covered the other half.** Had the restart reverted the
resolution in an upstream-owned file instead — `Sidebar.tsx`, `ws.ts`,
any of the fourteen that conflicted — no guard names it,
`merge-stats.mjs` only counts files and one file is lost in the noise,
and the merge is green.

## What this adds

**`resolution-check.mjs`** reads each candidate path against both merge
parents and reports where the result contradicts its verdict: a
`converged` path byte-identical to upstream has lost its delta; one
identical to the fork's pre-merge copy never took upstream's change. It
runs mid-merge as well as after, because before the commit exists a
finding is still a plain edit.

Calibrated against pingdotgg#90's own merge commit, which it passes clean.
Reverting a delta there produces:

```
XX apps/web/src/components/Sidebar.tsx landed byte-identical to upstream [mobile-touch-upstream-files, converged]
     Take upstream, then apply Mobile Touch Delta. […] all must survive.
     `// Fork:` markers 4 → 0.
```

Three scoping decisions, each of which would otherwise have shipped a
permanently-red check:

- Rule 3 covers `theirs-verbatim` only, never plain `theirs` —
`pnpm-lock.yaml` is the only `theirs` path and is meant to differ from
upstream forever.
- `decide` only warns, and only where the fork side carried a `// Fork:`
marker. `apps/web/src/browser/**` is six files the fork has never
touched; landing on upstream there is not a resolution.
- The marker census is reported, never enforced.
`apps/server/src/bin.ts` carries its whole delta as a *deletion*, so the
census reads `0 → 0` either way. A signal blind to half its failure
class does not get a vote.

It says nothing about fork-only (`ours`) paths and cannot — with no
upstream side there is no third reference. Guards cover those. Stated in
the file header.

**Exceptions as data.** `unsupported-methods.mjs` exited 1 on every
merge for `scripts.run`, whose derivation reads backwards because it is
upstream's own server that refuses it. Exceptions now live in
`unsupportedMethodExceptions` in `inventory.json`, each carrying the
condition that retires it, printed under `KNOWN EXCEPTIONS` and not
counted. The script also names an exception that has stopped firing, so
they get deleted rather than accumulate.

On unmodified `main` the check reports 4 DROPs; it now reports the 3
real ones. **Those three are genuine pre-existing backend drift**
(`server.getUsageSummary`, `subtasks.list`, `threads.getShell`) that the
permanent failure had been sitting next to. Left for a separate PR —
they need `rpc.ts` and `gaps.md` edits, which is a different concern.

**Procedure.** Commit the merge as soon as the markers are gone, then
`--amend` through the fix cycle: in this sandbox only committed history
survives a restart, so a gitignored state file is not crash insurance
and committing early is. Plus a restart-recovery checklist, never `git
add -A` during a merge (the restarts wiped a symlink and a submodule
gitlink, which `-A` stages as deletions the merge appears to have made),
and re-check `pnpm-lock.yaml` before amending.

**Delegation.** A documented section: what splits (convergence
validation, feature classification, gaps reconciliation), that conflicts
split **by concern, not by file** — pingdotgg#90's settings-search work spanned
five files and dropping a re-export in one broke another — and that
`verify.mjs` must not be delegated, since its packages already contend
for one sandbox's CPU. `preflight.mjs --json` emits the forecast grouped
by concern. Sonnet for these agents.

## Verification

`resolution-check.mjs` green on pingdotgg#90's merge commit; fails correctly when
a delta is reverted (rule 1a) and when an upstream change is dropped
(rule 2a); catches a silent revert in a real in-progress merge before
any commit exists, skipping the 14 unresolved paths. `preflight --json`
parses with empty stderr and keeps the staleness gate.
`features.test.ts` 16 passed. lint and typecheck clean.

Two `verify.mjs --fast` steps remain red, both pre-existing on `main`
and neither touched here: the three real DROPs above, and `fmt:check` on
`apps/web/src/fork/mermaidDiagram.ts` and
`docs/fork/upstream-merge-inventory.md` (both byte-identical to `main`).

Reviewed adversarially by a subagent, which found four real defects
since fixed: `--json` skipped the inventory staleness gate and always
exited 0; `report.failed` was used as a per-section gate so one failure
blanked out later sections' tallies; `--package` dropped OOM attribution
and would report a killed suite as a plain test failure; and
`autoMerged` repeated the conflict set when `merge-tree` cannot predict.

Claude Opus 4.8 via Claude Code.

---
Moatless task:
https://moatless.soaplabstest.com/tasks/ef3f3a62-fa94-4dc0-9bc3-3612a6a4a8de
aorwall added a commit to aorwall/t3code that referenced this pull request Sep 7, 2026
…merge

`resolution-check.mjs` exits 1 on `main` after any pull request lands.

Rules 1 and 2 read the second parent as "upstream's side". That holds only when
that parent is an upstream commit. A pull request merging into `main` makes it
the fork's own branch, and the merge takes that branch whole, so every
`converged` path the branch touched reports as "landed byte-identical to
upstream" — a dropped delta that was never dropped. Six files on the merge of
pingdotgg#106, none of them real.

The rules are not weaker on a fork-into-fork merge, they are meaningless, so
they are skipped and say so. A check that is red on every merged pull request
is a check nobody reads, which is the failure this script exists to prevent.

Rule 3 keeps running, since it resolves its own upstream reference — now from
the merge result rather than from the first parent, which was comparing against
whatever upstream the fork had before the merged branch brought a newer one in.

Verified: the upstream merge on this branch still reports 56 fork deltas
preserved, 58 upstream changes landed, 17 theirs-verbatim byte-identical and
exits 0; reverting a `converged` file to upstream inside it still fails and
names the concern; `main` is now green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant