Skip to content

Commit 81d1917

Browse files
committed
Merge branch 'feat/thread-running-state-event-driven'
2 parents a0da3ab + c8d6be6 commit 81d1917

20 files changed

Lines changed: 612 additions & 105 deletions

File tree

desktop/garyx-desktop/src/main/gary-client.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,9 @@ interface ThreadSummaryPayload {
402402
teamDisplayName?: string | null;
403403
team?: ThreadTeamBlockPayload | null;
404404
recent_run_id?: string | null;
405+
recentRunId?: string | null;
406+
run_state?: string | null;
407+
runState?: string | null;
405408
sdk_session_id?: string | null;
406409
worktree?: unknown;
407410
}
@@ -2098,9 +2101,8 @@ function mapThreadSummary(value: ThreadSummaryPayload): DesktopThreadSummary {
20982101
: null),
20992102
team,
21002103
recentRunId:
2101-
typeof (value as { recent_run_id?: unknown }).recent_run_id === "string"
2102-
? ((value as { recent_run_id?: string }).recent_run_id ?? null)
2103-
: null,
2104+
asString(value.recent_run_id) || asString(value.recentRunId) || null,
2105+
runState: asString(value.run_state) || asString(value.runState) || null,
21042106
worktree: mapThreadWorktreeInfo(value.worktree),
21052107
};
21062108
}

desktop/garyx-desktop/src/renderer/src/app-shell/AppShell.tsx

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,8 +1241,32 @@ const TRANSIENT_STATUS_MS = 3200;
12411241
const ERROR_TOAST_MS = 4400;
12421242
const GATEWAY_HEALTHY_POLL_MS = 12000;
12431243
const SILENT_DESKTOP_STATE_REFRESH_MS = 60000;
1244+
const RUN_STATE_LIST_REFRESH_DEBOUNCE_MS = 350;
1245+
const GATEWAY_READY_STATE_REFRESH_THROTTLE_MS = 12000;
12441246
const GATEWAY_RETRY_BACKOFF_MS = [2500, 4000, 6500, 10000, 15000];
12451247

1248+
function threadRunStateIsRunning(thread: DesktopThreadSummary): boolean {
1249+
return (thread.runState || "").trim().toLowerCase() === "running";
1250+
}
1251+
1252+
function chatStreamEventHasRunLifecycle(event: DesktopChatStreamEvent): boolean {
1253+
const events =
1254+
event.type === "thread_render_frame"
1255+
? event.events
1256+
: event.type === "committed_message"
1257+
? [event]
1258+
: [];
1259+
return events.some((committed) => {
1260+
const controlKind = transcriptControlKind(committed.message);
1261+
return (
1262+
controlKind === "run_start" ||
1263+
controlKind === "run_complete" ||
1264+
controlKind === "run_interrupted" ||
1265+
controlKind === "interrupt_confirmed"
1266+
);
1267+
});
1268+
}
1269+
12461270
function savedContentView(): ContentView {
12471271
const saved = sessionStorage.getItem("gary-content-view");
12481272
const valid: ContentView[] = [
@@ -1770,6 +1794,8 @@ export function AppShell() {
17701794
const gatewaySetupSavedConnectionRef = useRef<ConnectionStatus | null>(null);
17711795
const botBindingRequestSequenceRef = useRef(0);
17721796
const previousConnectionOkRef = useRef<boolean | null>(null);
1797+
const desktopStateRefreshTimeoutRef = useRef<number | null>(null);
1798+
const lastGatewayReadyStateRefreshAtRef = useRef(0);
17731799
const lastRemoteStateWarningKeyRef = useRef<string | null>(null);
17741800
const threadLogsPanelWidthRef = useRef(
17751801
DEFAULT_DESKTOP_SETTINGS.threadLogsPanelWidth,
@@ -3110,11 +3136,10 @@ export function AppShell() {
31103136
isActive:
31113137
visibleThreadEntrySelectionSource === "recent" &&
31123138
visibleSelectedThreadId === thread.id,
3113-
isBusy: isRuntimeBusy(selectThreadRuntime(messageState, thread.id)?.state),
3139+
isBusy: threadRunStateIsRunning(thread),
31143140
})),
31153141
[
31163142
desktopState?.threads,
3117-
messageState,
31183143
visibleSelectedThreadId,
31193144
visibleThreadEntrySelectionSource,
31203145
],
@@ -3130,10 +3155,9 @@ export function AppShell() {
31303155
isActive:
31313156
visibleThreadEntrySelectionSource === "pinned" &&
31323157
visibleSelectedThreadId === thread.id,
3133-
isBusy: isRuntimeBusy(selectThreadRuntime(messageState, thread.id)?.state),
3158+
isBusy: threadRunStateIsRunning(thread),
31343159
})),
31353160
[
3136-
messageState,
31373161
pinnedThreadIds,
31383162
threadAvatarCatalog,
31393163
threadSummaryById,
@@ -3654,6 +3678,30 @@ export function AppShell() {
36543678
return nextState;
36553679
}
36563680

3681+
function scheduleDesktopStateRefresh(delayMs = RUN_STATE_LIST_REFRESH_DEBOUNCE_MS) {
3682+
if (desktopStateRefreshTimeoutRef.current !== null) {
3683+
window.clearTimeout(desktopStateRefreshTimeoutRef.current);
3684+
}
3685+
desktopStateRefreshTimeoutRef.current = window.setTimeout(() => {
3686+
desktopStateRefreshTimeoutRef.current = null;
3687+
void refreshDesktopState().catch((refreshError) => {
3688+
console.debug("Desktop state refresh failed.", refreshError);
3689+
});
3690+
}, delayMs);
3691+
}
3692+
3693+
function scheduleGatewayReadyStateRefresh() {
3694+
const now = Date.now();
3695+
if (
3696+
now - lastGatewayReadyStateRefreshAtRef.current <
3697+
GATEWAY_READY_STATE_REFRESH_THROTTLE_MS
3698+
) {
3699+
return;
3700+
}
3701+
lastGatewayReadyStateRefreshAtRef.current = now;
3702+
scheduleDesktopStateRefresh(0);
3703+
}
3704+
36573705
async function refreshAgentTargets() {
36583706
const [nextAgents, nextTeams] = await Promise.all([
36593707
window.garyxDesktop
@@ -4363,6 +4411,9 @@ export function AppShell() {
43634411
useEffect(() => {
43644412
const listener = (event: DesktopChatStreamEvent) => {
43654413
enqueueClientLogEvent(event);
4414+
if (chatStreamEventHasRunLifecycle(event)) {
4415+
scheduleDesktopStateRefresh();
4416+
}
43664417
streamEventHandlerRef.current(event);
43674418
};
43684419
window.garyxDesktop.subscribeChatStream(listener);
@@ -4381,6 +4432,15 @@ export function AppShell() {
43814432
};
43824433
}, []);
43834434

4435+
useEffect(() => {
4436+
return () => {
4437+
if (desktopStateRefreshTimeoutRef.current !== null) {
4438+
window.clearTimeout(desktopStateRefreshTimeoutRef.current);
4439+
desktopStateRefreshTimeoutRef.current = null;
4440+
}
4441+
};
4442+
}, []);
4443+
43844444
useEffect(() => {
43854445
const handlePointerDown = (event: MouseEvent) => {
43864446
const target = event.target;
@@ -4809,6 +4869,7 @@ export function AppShell() {
48094869
}
48104870
if (nextOk) {
48114871
gatewayRetryStepRef.current = 0;
4872+
scheduleGatewayReadyStateRefresh();
48124873
} else {
48134874
gatewayRetryStepRef.current = Math.min(
48144875
gatewayRetryStepRef.current + 1,

desktop/garyx-desktop/src/shared/contracts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,7 @@ export interface DesktopThreadSummary {
883883
teamId?: string | null;
884884
teamName?: string | null;
885885
recentRunId?: string | null;
886+
runState?: string | null;
886887
worktree?: ThreadWorktreeInfo | null;
887888
/**
888889
* Full team block when this thread is bound to a Team. It is filled by

desktop/garyx-desktop/src/web/web-api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ type ThreadSummaryPayload = {
3131
message_count?: number;
3232
last_user_message?: string | null;
3333
last_assistant_message?: string | null;
34+
recent_run_id?: string | null;
35+
recentRunId?: string | null;
36+
run_state?: string | null;
37+
runState?: string | null;
3438
};
3539

3640
type ThreadsPayload = {
@@ -262,6 +266,8 @@ function mapThreadSummary(value: ThreadSummaryPayload): DesktopThreadSummary {
262266
lastMessagePreview: preview,
263267
workspacePath: value.workspace_dir || null,
264268
messageCount: value.message_count,
269+
recentRunId: value.recent_run_id || value.recentRunId || null,
270+
runState: value.run_state || value.runState || null,
265271
};
266272
}
267273

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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`).

garyx-bridge/src/multi_provider/run_management.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2809,6 +2809,21 @@ impl MultiProviderBridge {
28092809
(!aborted.is_empty(), aborted)
28102810
}
28112811

2812+
/// Abort every active run across all threads. Called on graceful shutdown
2813+
/// so a clean restart writes close controls and leaves no orphaned
2814+
/// `running` projection behind; the startup reconcile only needs to back up
2815+
/// true hard crashes (SIGKILL / power loss).
2816+
pub async fn abort_all_active_runs(&self) -> Vec<String> {
2817+
let run_ids = self.get_active_runs().await;
2818+
let mut aborted = Vec::new();
2819+
for run_id in &run_ids {
2820+
if self.abort_run(run_id).await {
2821+
aborted.push(run_id.clone());
2822+
}
2823+
}
2824+
aborted
2825+
}
2826+
28122827
/// Get list of active run IDs.
28132828
pub async fn get_active_runs(&self) -> Vec<String> {
28142829
self.inner

0 commit comments

Comments
 (0)