|
| 1 | +# Thread Running-State Projection — Event-Driven, Backend Source of Truth |
| 2 | + |
| 3 | +Status: approved for implementation (user-confirmed direction "按 v2 干"). |
| 4 | + |
| 5 | +## Goal |
| 6 | + |
| 7 | +The thread list (pinned + recent) shows a per-row "running" badge (the |
| 8 | +three-dot typing indicator). Today each client decides it differently: |
| 9 | + |
| 10 | +- iOS `homeThreadRunningThreadIds` mixes three sources (local run tracker, |
| 11 | + per-thread committed run-state, server `run_state` fallback). |
| 12 | +- Desktop ignores the server field entirely and infers busy purely from its |
| 13 | + local `messageState` stream machine, so runs started elsewhere never light up. |
| 14 | +- Widget reads the server snapshot only. |
| 15 | + |
| 16 | +We converge on a single rule: **the backend owns one `run_state` field per |
| 17 | +thread; all clients dumb-read it.** No client recomputes "is it running". |
| 18 | + |
| 19 | +## Source Of Truth |
| 20 | + |
| 21 | +The authoritative per-thread value is `recent_threads.run_state` (and its |
| 22 | +mirror `active_run_id`), which already exists in the SQLite projection |
| 23 | +(`garyx_db.rs`). What changes is *how it is decided*: |
| 24 | + |
| 25 | +> A thread is `running` iff its transcript's last run-lifecycle control is an |
| 26 | +> open (`run_start` with no paired close) **AND** the bridge's in-memory run |
| 27 | +> index still holds that run as active. |
| 28 | +
|
| 29 | +The two-condition AND is deliberate: |
| 30 | + |
| 31 | +- **transcript event** drives the steady state immediately. `run_complete` / |
| 32 | + `run_interrupted` / `interrupt_confirmed` flips the reduced state to |
| 33 | + not-busy the moment it is committed, independent of any in-memory cleanup |
| 34 | + ordering. |
| 35 | +- **in-memory confirmation** vetoes orphans. After a crash / SIGKILL the |
| 36 | + transcript may keep a dangling `run_start` (the close was never written), but |
| 37 | + the bridge run index is rebuilt empty on restart, so the orphan resolves to |
| 38 | + `idle`. |
| 39 | + |
| 40 | +This is still event-driven (transcript run-lifecycle controls are the events); |
| 41 | +the in-memory index only removes the one failure mode events cannot cover |
| 42 | +(process death with no close event). |
| 43 | + |
| 44 | +### Why not "pure in-memory truth" |
| 45 | + |
| 46 | +`active_runs.remove` happens in the run task's cleanup block *after* |
| 47 | +`run_complete` is committed (`run_management.rs:2092-2095`). A projection that |
| 48 | +read in-memory state on the `run_complete`-triggered re-projection would still |
| 49 | +see the run present and fail to clear the badge. Gating on |
| 50 | +`transcript_busy AND memory_active` avoids touching bridge ordering: once |
| 51 | +`run_complete` is committed, `transcript_busy` is already false, so the result |
| 52 | +is `idle` regardless of remove timing. |
| 53 | + |
| 54 | +## State Machine (`idle` ⇄ `running`) |
| 55 | + |
| 56 | +We only care about the **thread itself**, not sub-tasks. A thread is serial: |
| 57 | +sending a new message first `abort_thread_runs` the in-flight run |
| 58 | +(`run_management.rs:1451`), and `max_concurrent_runs` is a global cap, not |
| 59 | +per-thread. So a thread has at most one of its own runs at a time → the field |
| 60 | +is a boolean (has its own active run / not). Sub-agent / workflow child runs |
| 61 | +live on their own child threads (already `exclude_from_recent`) and never |
| 62 | +aggregate onto the parent row. |
| 63 | + |
| 64 | +- **Open → running:** `run_start` (your message, automation, another device). |
| 65 | +- **Close → idle:** `run_complete` (status completed / error / interrupted / |
| 66 | + aborted), `run_interrupted`, `interrupt_confirmed`. |
| 67 | + |
| 68 | +### Completeness |
| 69 | + |
| 70 | +| End scenario | Event | Covered by | |
| 71 | +| --- | --- | --- | |
| 72 | +| Normal finish | `run_complete` (completed) | transcript event | |
| 73 | +| Error / provider failure | `run_complete` (error) | transcript event | |
| 74 | +| User stop | `run_interrupted` / `interrupt_confirmed` | transcript event | |
| 75 | +| New message preempts in-flight | `run_complete` (interrupted) via `abort_thread_runs` | transcript event | |
| 76 | +| Program/system abort | `run_complete` (aborted) | transcript event | |
| 77 | +| Same thread concurrency | n/a (serial) | only ever 0 or 1 | |
| 78 | +| **Process crash / SIGKILL / power loss** | **no event** | in-memory confirm + startup reconcile + SIGTERM abort-all | |
| 79 | + |
| 80 | +Events cover every process-alive end path. The single blind spot (process death |
| 81 | +with no close event) is closed by the in-memory AND + the startup reconcile + |
| 82 | +graceful-shutdown abort. |
| 83 | + |
| 84 | +## Backend Changes (garyx-gateway, garyx-bridge) |
| 85 | + |
| 86 | +1. **bridge**: add `MultiProviderBridge::abort_all_active_runs()` — iterate |
| 87 | + `get_active_runs()` and `abort_run` each. `is_run_active(run_id)` already |
| 88 | + exists (`run_management.rs:2825`) and is reused as the in-memory probe. |
| 89 | +2. **gateway**: introduce an `ActiveRunProbe` trait and a |
| 90 | + `BridgeActiveRunProbe(Weak<MultiProviderBridge>)` adapter. **Weak** is |
| 91 | + required: the bridge holds `Arc<thread_store>` (the projecting store) via |
| 92 | + `set_thread_store_blocking`, so the projecting store must not hold an |
| 93 | + `Arc<MultiProviderBridge>` back (would leak/cycle). |
| 94 | +3. **gateway**: `RecentThreadProjectingStore` gains the probe. In |
| 95 | + `project_thread`, after computing the transcript `active_run_id`, drop it to |
| 96 | + `None` when `!probe.is_run_active(id)`. Same gate in |
| 97 | + `reconcile_active_recent_thread_projection` (startup orphan clear — at boot |
| 98 | + the index is empty, so every stale `running` row clears to `idle`). |
| 99 | +4. **gateway**: `server.rs` `serve` — after graceful shutdown returns, call |
| 100 | + `bridge.abort_all_active_runs()` with a bounded timeout so a clean restart |
| 101 | + writes close events and leaves no orphan; the startup reconcile only backs |
| 102 | + up true hard crashes. |
| 103 | + |
| 104 | +The steady-state write path (`project_thread` on every `thread_store.set`) is |
| 105 | +unchanged in shape; only the `active_run_id` value gains the memory veto. |
| 106 | +Contract preserved: recent_threads stays write-time maintained, read routes do |
| 107 | +not repair (`docs/agents/repository-contracts.md`). |
| 108 | + |
| 109 | +## Client Changes (dumb-read the one field) |
| 110 | + |
| 111 | +- **Desktop**: `DesktopThreadSummary` gains `runState`; `gary-client.ts` |
| 112 | + `mapThreadSummary` parses `run_state`; `AppShell.tsx` `recentThreadRows` / |
| 113 | + `pinnedThreadRows` `isBusy` reads `thread.runState === "running"` instead of |
| 114 | + `isRuntimeBusy(selectThreadRuntime(messageState, …))`. Keep the local stream |
| 115 | + machine for the open thread's transcript; only the list badge switches source. |
| 116 | + A light list refresh reuses existing poll/stream hooks. |
| 117 | +- **iOS**: `homeThreadRunningThreadIds` collapses to |
| 118 | + `threads.filter { $0.run_state == "running" }`. Remove the runTracker / |
| 119 | + runStateByThread contributions to the *list badge* (they remain for the open |
| 120 | + thread's composer/steer state). Drop the |
| 121 | + `GaryxThreadSummaryRunStateResolver` local override of `thread.runState`. |
| 122 | + Keep the background refresh loop alive on home-visible+ready (decouple it from |
| 123 | + the local-candidate gate). |
| 124 | +- **Widget**: already dumb-reads `run_state`/`active_run_id`; converge to |
| 125 | + `run_state == "running"` only; verify against the host snapshot. |
| 126 | + |
| 127 | +### Optional immediacy (not required) |
| 128 | + |
| 129 | +Pure polling means a locally-sent message lights the badge on the next poll |
| 130 | +(≤1.5s on iOS). Optional: the locally-interacting thread may optimistically |
| 131 | +light its badge as a display-only override that yields to the backend field — |
| 132 | +never delays the off. Ship without it first. |
| 133 | + |
| 134 | +## Migration / Validation |
| 135 | + |
| 136 | +- Backend lands first (steady state unchanged; only orphan handling + the AND |
| 137 | + gate change behavior). Each client is independent and ships separately because |
| 138 | + the field already exists in the payload. |
| 139 | +- Tests: `cargo test -p garyx-gateway --all-targets`, |
| 140 | + `cargo test -p garyx-bridge --all-targets`. Add: project clears `running` when |
| 141 | + probe reports inactive; reconcile clears all `running` on empty index; |
| 142 | + `abort_all_active_runs` writes close controls. |
| 143 | +- Orphan e2e: start a run, `kill -9` gateway, restart, assert |
| 144 | + `/api/recent-threads` `run_state != "running"`. |
| 145 | +- Cross-end: one running thread, assert badge parity across desktop + iOS + |
| 146 | + widget + `/api/recent-threads`. |
| 147 | +- Review: Claude implements → Codex adversarial review (`garyx task create |
| 148 | + --assignee codex --workspace-dir <worktree> --notify current-thread`). |
0 commit comments