Skip to content

Startup cleanup scan re-reads all V2 events every launch (V1 projector cursors never advance), blocking the event loop past the Pi probe's 4 s timeout #13982

Description

@astarktc

What happened

On every launch of a desktop build from the Orchestrator V2 branch (#2829), the Pi provider shows "Pi CLI is installed but timed out while running pi --version." The status clears on the next provider health refresh 5 minutes later. pi --version takes 0.15–0.22 s on the same machine.

The Pi probe is not the problem. A synchronous startup SQLite scan blocks the server event loop for longer than the probe's 4 s timer. The scan's size grows without bound because the V1 projector cursors stop advancing once V2 traffic takes over.

Diagnosis

  1. checkPiProviderStatus starts at 12:56:32.548 with VERSION_PROBE_TIMEOUT_MS = 4_000 (PiProvider.ts).
  2. At 12:56:32.627, ProjectionPipeline.bootstrap runs listAttachmentCleanupReplayRows. The span is sql.execute and it takes 5236 ms. The client is node:sqlite DatabaseSync, so the query runs synchronously on the main thread.
  3. pi --version exits after about 150 ms, but the exit is not processed until the query returns. libuv runs expired timers before poll-phase I/O, so the 4 s timer fires first. The probe span ends at 12:56:37.868, 5 ms after the SQL returns, and takes the Option.none "timed out" branch.
  4. The next refresh, at 13:01:32 (DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL = 5 min), succeeds.

Why the scan is large. The bootstrap scans from cleanupStart = min(cleanup cursor, every V1 projector cursor). The V1 projectors read through readFromSequence, which returns only application_event_version = 1 OR aggregate_kind = 'project'. Their cursors move only when a project event arrives, and V2 thread traffic never moves them.

projection_state on one machine:

projection.projects … projection.threads   58658   (2026-09-21, the last project event)
projection.attachment-cleanup              427880
max(sequence)                              428271

So cleanupStart is 58658, and every restart re-reads about 370k V2 rows with the NOT INDEXED rowid scan. That window grows with all traffic since the last project change, not with traffic since the previous restart, which is what #12846 aimed for. Timing the same query read-only with the sqlite3 CLI gives 2.3 s cold / 0.36 s warm from 58658, and 1 ms from a current cursor.

A second machine has the same pinning (cursors at 277538, head 714625), but it is fast enough (M5 Max, about 0.3 s) to stay under 4 s. The affected machine is an M2 Pro with a 2.7 GB statev2.sqlite and endpoint security (CrowdStrike, Defender), which pushes the cold scan past the 4 s timer.

Steps to reproduce

  1. Use a V2 build with some thread history. Create no project events afterwards, so the V1 projector cursors stay behind the head.
  2. Let V2 thread traffic accumulate.
  3. Relaunch. When the cleanup scan exceeds 4 s on the machine, the Pi provider shows the timeout error for one refresh interval.

Mechanism in isolation (Node, no T3): spawn pi --version with a 4 s setTimeout, then busy-block the loop.

block 0ms     → ok 0.87.1 at 109ms
block 3500ms  → ok 0.87.1 at 3502ms
block 5200ms  → TIMED OUT at 5202ms

Version

Built from #2829 head 402205e2c7 (0.0.42, Electron 44.4.2), Pi 0.87.1.

Environment

macOS 26.6.2, Apple M2 Pro, 32 GB. EDR installed: CrowdStrike Falcon, Microsoft Defender, Kandji ESF.

Evidence

server.trace.ndjson spans from the launch at 12:56:25:

12:56:32.548  5320 ms  checkPiProviderStatus   (ends 5 ms after the SQL returns; no discovery phase)
12:56:32.627  5236 ms  sql.execute             SELECT sequence, occurred_at … FROM orchestration_events NOT INDEXED WHERE sequence > 58658 …
13:01:32.551           checkPiProviderStatus   (next refresh, succeeds)

Related issues

#12846 (perf: stop decoding unrelated events during startup) added the separate cleanup cursor. The min() over projector cursors that stop advancing under V2 undoes that bound.

Fix applied or workaround

A local patch (with a regression test) is applied in a fork: astarktc@587ae54

At the end of bootstrap's projector replay, it advances every projector cursor still below the pre-replay log head up to that head. The replay has consumed everything through the head; the V2 events were simply filtered out. The next cleanup scan window is then bounded by the traffic since the previous start. This needs no migration.

Suggested follow-ups, in order of value:

  • Make the cleanup query independent of history size. A partial index such as ON orchestration_events(sequence) WHERE aggregate_kind = 'thread' AND event_type IN ('thread.reverted','thread.deleted'), plus MAX(sequence) from the rowid, would make the scan O(reverts + deletes) regardless of cursors. That needs a migration, which is why the fork patch does not do it.
  • Treat a timed-out health probe as retryable. Re-check after a short backoff instead of holding a false error for the full 5-minute interval. Any long synchronous stall at startup, not only this query, currently produces this false error.

Filed by

@astarktc, with an AI agent (Pi in T3 Code), after a trace-level diagnosis on the affected machine.

Activity

  1. juliusmarminge commented on Sep 27, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed on t3code/codex-turn-mapping at 402205e2c7 (current head of #2829). Not present on main: there readFromSequence returns every event after the cursor, and runtime projection advances every projector cursor together, so this min() stays near the log head.

    The Pi message is a false timeout. checkPiProviderStatus uses Effect.timeoutOption(4_000) and treats Option.none as "Pi CLI is installed but timed out while running pi --version." That result is then held until the next provider health refresh (DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, 5 minutes). The server SQL client is node:sqlite DatabaseSync, and statement.all() runs on the main thread. A synchronous scan longer than 4 seconds keeps the loop from observing the child exit; when the scan returns, the expired timer runs before the exit callback, so the probe takes the timeout branch even though pi already exited. The same stall would false-fail any other in-flight probe with that timeout.

    The scan grows without bound because of this bootstrap boundary:

    const cleanupStart = Math.min(
      cleanupState?.lastAppliedSequence ?? 0,
      ...projectors.map((projector) => byProjector.get(projector.name)?.lastAppliedSequence ?? 0),
    );

    That value is written back to projection.attachment-cleanup before replay, and listAttachmentCleanupReplayRows then scans orchestration_events NOT INDEXED from it. V1 cursors do not move with V2 thread traffic. readFromSequence only returns application_event_version = 1 OR aggregate_kind = 'project', so after cutover those cursors sit on the last project event. min() therefore throws away the cleanup cursor #12846 added (merged into this branch on 2026-09-21) and re-reads every later row on every launch. The reported cursors (projectors at 58658, cleanup at 427880, head at 428271) match that.

    The fork patch (astarktc@587ae54) is the right small fix. After a successful projector replay it advances any V1 cursor still below the pre-replay head up to that head. It does not move the cleanup cursor, so a failed filesystem cleanup still retries from the rewound boundary. It is safe only while these projectors consume that filtered stream; a projector that must observe V2 thread events must not be snapped past them.

    Two limits to keep in mind:

    • The snap does not shrink the scan on the boot that applies it. The next launch is bounded by traffic since the previous start, not since the last project event. A process that runs long enough for that window to exceed 4 seconds will show the false Pi error again.
    • Making the cleanup query independent of history still wants a migration: a partial index on thread thread.reverted / thread.deleted, with MAX(sequence) read from the rowid, so the scan stays cheap even when a cursor is legitimately reset. Retrying a timed-out health probe after a short backoff is a separate, smaller fix for any future startup stall.

    No duplicate is open. This should be fixed on the orchestrator branch before #2829 merges.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions