Skip to content

[Loops] Reconciliation cycle blocks SQLite writes for ~20 s; tool results that land inside it are lost #689

Closed

Description

@AlexandreAkiraAkao

Version: compozy 0.3.0-beta.27, macOS, one daemon. ~/.compozy/compozy.db is 337 MB, plus a 17 MB WAL.

Symptom

A loop action tool runs to the end, and its side effects are on disk, but the daemon fails to persist the result:

{"level":"ERROR","msg":"daemon: execute loop action run",
 "task_id":"loop.looprun-18818d74440c30de.g1.node.watch.0",
 "error":"call action tool \"ext__work__deliver_watch\": tool \"ext__work__deliver_watch\" observability emit failed\nstore: fail task run lease transaction: store: begin immediate sqlite write: context deadline exceeded"}

Afterwards the node never settles correctly. We saw three outcomes:

  1. The output goes to enqueued and stays there. The node is never dispatched again, even with retry.max_attempts: 3 and attempt 1 (loop.looprun-42b94147209f7373.g1.node.prepare.0).
  2. The output stays running until the node timeout. In our case the timeout was 1440m30s, so the run, its parent run-loop, and the grandparent all hang (loop.looprun-18818d74440c30de.g1.node.watch.0).
  3. The node fails with failure_class: transport and is retried. The retry runs a tool that has already run once.

Correlation with the reconciliation cycle

The log has one line per reconciliation cycle:

{"level":"INFO","msg":"daemon: Loop reconciliation cycle complete","runs_examined":0,"records_settled":0,"orphans_repaired":0,"provenance_backfilled":0,"duration_ms":20426}

All 7 lost results on 2026-10-01 fell inside a cycle window (from complete − duration_ms to complete), each 10–13 s after the cycle started:

Lost (local) Node Cycle window
13:06:36 settle 13:06:26–13:06:43
14:00:16 review_gate 14:00:03–14:00:20
14:32:56 release 14:32:44–14:33:02
17:51:39 collect 17:51:29–17:51:47
18:15:25 prepare 18:15:12–18:15:33
18:28:37 wave_ok 18:28:27–18:28:46
19:25:40 watch 19:25:23–19:25:44

The same windows also show session supervision failed: store: sweep heartbeat wake events: database is locked (5) (SQLITE_BUSY). CLI calls made during them fail with SQLITE_BUSY (exit 69) or context deadline exceeded.

The cycle keeps getting longer

The cycle runs every ~72 s (median), and every cycle reports runs_examined: 0.

Day Median duration Max Lost results
09-27 6.7 s 21.2 s 0
09-29 8.1 s 13.6 s 5
09-30 11.8 s 20.9 s 3
10-01 16.0 s (≈20 s by evening) 23.9 s 8

The losses began on 09-29 between 18:00 and 20:00 local time. That is when the median duration passed ~10 s, which suggests the result write waits a bounded time (~15 s in the reproduction below) that the longer cycles outlast. On 10-01 the cycles filled ~23% of wall-clock time. On that day, from 15:00Z on, about 2% of node attempts lost their result (9 of 475).

Row counts at the time: loop_runs 689, loop_generations 1466, loop_node_attempts 3728, task_runs 9421, task_events 55882, event_summaries 120266, sessions 787. We found no CLI command that prunes history. We have not changed reconcile_interval.

Reproduction (no fault injection)

Use a test tool that returns at a chosen offset from the start of the next reconciliation cycle. The start is predicted from the complete − duration_ms lines.

Return offset Outcome
~0.7 s 15 s later, fail task run lease transaction … context deadline exceeded. The node output stayed enqueued (attempt 1) for at least 22 min, the tool was never invoked again, and scheduler.lease_sweep reported recovered_leases: 0 (3 nodes).
12.7–14.7 s observability emit failed. The node went to retrying and the tool ran again (the transport form).
15.7 s Fine.

There is also no way to recover such a node from the CLI:

  • loop node resume --mode plain|immediate on the enqueued node is refused with exit 78, node_not_paused, allowed=pause,cancel.
  • loop node pause --mode cancel followed by resume --mode immediate is accepted, but the tool is never invoked again (13 and 26 min observed).
  • loop node cancel fails the run.

Related: the automation scheduler spins on SQLITE_BUSY

When the database is busy, the automation scheduler retries the job claim in a tight loop: automation.scheduler.dispatch_failed … claim scheduled job … database is locked (5), about 18k lines in one minute. The log rotated 5 times in 15 s, and since only 5 backups are kept, the earlier history was overwritten.

Expected

  • A reconciliation cycle that examines 0 runs should not hold the write lock for seconds, and its cost should not grow with history (an indexed or incremental scan, or short transactions).
  • When the daemon cannot persist a tool result, it should retry the write instead of dropping it. A node whose tool already returned should never stay running or enqueued, and should not be retried as a transport failure.

Workaround on our side

An external supervisor polls the run tree and cancels or recovers stuck nodes. It treats the symptom only.

Related

#678 (sustained SQLite contention degrading session tools) looks like the same lock pressure seen from the session side.

Activity

  1. AlexandreAkiraAkao commented on Oct 4, 2026

    @AlexandreAkiraAkao
    Author

    Update 2026-10-03: the cycle keeps growing, and a restart does not help

    Same setup: compozy 0.3.0-beta.27, macOS arm64, one daemon, reconcile_interval unchanged.

    The cycle is now ~26–30 s

    From the daemon: Loop reconciliation cycle complete lines (runs_examined is 0 in every one):

    Day Cycles Median Max Cadence (median)
    10-01 (from the report above) — 16.0 s 23.9 s ~72 s
    10-02 549 22.9 s 43.7 s 83 s
    10-03 701 25.6 s 54.0 s 86 s
    10-03, right after a daemon restart 8 ~30 s 33.6 s —

    The cycles now fill about 30–35% of wall-clock time.

    The history grew much less than the cycle did over the same period:

    Table 10-01 10-03
    loop_runs 689 832
    loop_generations 1466 1683
    loop_node_attempts 3728 4404
    task_runs 9421 11125
    task_events 55882 65457
    event_summaries 120266 123385
    sessions 787 820

    compozy.db is still 337 MB.

    More results are lost

    About 11 tool results were lost in ~3 h of one loop tree today, against ~2% of node attempts on 10-01. In each case the tool finished, its side effects were on disk, and the node stayed running/enqueued/queued. The affected nodes: prepare (3), wave_start (3), review_gate (2), publish, fix_push, verify. One tool that takes 100–130 s, because it makes CLI calls itself, lost its result three times in a row.

    Two more shapes of the same loss:

    • The node whose tool finished shows enqueued in loop status --run-id <id>, while loop nodes --run <id> --all shows it not_taken with no timestamps. Its successors stay pending and the run stays running.
    • In a run-agent node, the agent finished and wrote its final structured output, and then the session recorded {"code":-32603,"message":"Internal error: Claude ended the turn without returning results for tool calls: …","data":{"errorKind":"incomplete_tool_call"}} and a file_mutation_unverified marker. The attempt was classified payload_declared and was not retried, despite retry.max_attempts: 2. We cannot tell whether this shares the cause.

    CLI calls took from 20 s to over 2 min each, and many needed retries after SQLITE_BUSY (exit 69) or context deadline exceeded.

    A restart does not help

    Before the restart, the daemon (33 h uptime) used 100–200% CPU with no live loop run. The log showed:

    • scheduler.lease_sweep and scheduler.task_block_sweep every ~0.5 s;
    • every ~5 s, for the same stopped session (health dead):
      daemon: spawn reaper sweep failed ... session: recovery persistence failed: session: project recovered state for "sess-…": store: update session state "sess-…": database is locked (5) (SQLITE_BUSY)
      
    • session supervision failed: store: sweep heartbeat wake events: database is locked (5) (SQLITE_BUSY).

    After compozy daemon stop and a fresh daemon start --foreground:

    • the boot reconciliation took 48 s, and the socket came up after ~2 min;
    • the first cycles already took ~30 s;
    • SQLITE_BUSY came back within ~3 min (the spawn reaper on the same session and the heartbeat sweep);
    • CPU stayed at ~100–110% with no live run;
    • the first lost result came 4 min after the restart.

    Is there a supported way to prune loop and task history, or to make the reconciliation cycle skip what it already settled? Today the only workaround we have is to detect the lost result from outside and cancel the run.

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

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions