1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
//! Timeout configuration and `reqwest::Client` construction for
//! [`super::DaemonClient`].
//!
//! Why: `DaemonClient::new` used to mint a bare `reqwest::Client::new()` with
//! no timeout at all (issue #2471, pre-existing since #2118). A daemon that
//! accepts the TCP connection but never answers — e.g. wedged, or mid-restart
//! with a stale listener — hung every caller forever, and the `tm projects`
//! TUI awaits its poll step in the SAME task that reads keyboard input
//! (`tui/project_ctl/mod.rs`), so the hang froze the whole screen, including
//! `q`/Ctrl-C, until the OS-level socket timeout (minutes, platform-
//! dependent). Bounding the client-level timeout turns an indefinite freeze
//! into a bounded one. Split into its own file (not `mod.rs`) both because
//! the timeout logic is a distinct, independently-testable concern and
//! because `mod.rs` was already at 498/500 SLOC with no headroom to absorb it
//! (same reasoning as the `claude_config`/`managed`/`projects` sibling-file
//! split).
//! What: [`default_client`] is what [`super::DaemonClient::new`] calls; it
//! delegates to [`build_client`] (a pure function of the two `Duration`
//! bounds) so the timeout behavior itself is unit-testable against tiny
//! durations rather than the real 3s/10s production values.
//! [`CHAT_REQUEST_TIMEOUT`] and [`PROVISION_REQUEST_TIMEOUT`] are longer,
//! explicit per-request overrides for the endpoints that legitimately run
//! long (see each constant's own doc).
//! Test: `tests::build_client_bounds_a_stalled_connection` drives a real
//! request against a `TcpListener` that accepts but never answers, and
//! asserts it errors within the (small, test-only) configured bound rather
//! than hanging.
use Duration;
/// Ceiling on establishing the TCP connection to the daemon.
///
/// Why: the daemon is loopback-local in every deployed configuration, so a
/// healthy connect completes in low single-digit milliseconds; 3s is
/// generous slack for a loaded machine while still failing fast when the
/// daemon process is simply not there.
/// What: passed to `reqwest::ClientBuilder::connect_timeout` in
/// [`build_client`].
/// Test: `tests::default_client_uses_default_bounds` pins the value.
pub const DEFAULT_CONNECT_TIMEOUT: Duration = from_secs;
/// Ceiling on one request/response round trip against the daemon.
///
/// Why: every non-chat `DaemonClient` endpoint is a simple local JSON call
/// (list sessions, read breaker state, kill a session, …) that normally
/// completes in milliseconds; 10s is well above any legitimate latency and
/// still bounds a wedged daemon to a short, operator-visible freeze instead
/// of the multi-minute OS socket timeout this replaces.
/// What: passed to `reqwest::ClientBuilder::timeout` in [`build_client`] —
/// this is the CLIENT-LEVEL default; [`CHAT_REQUEST_TIMEOUT`] overrides it
/// per-request on the two endpoints that need longer.
/// Test: `tests::default_client_uses_default_bounds` pins the value;
/// `tests::build_client_bounds_a_stalled_connection` exercises the mechanism
/// with a scaled-down bound.
pub const DEFAULT_REQUEST_TIMEOUT: Duration = from_secs;
/// Per-request timeout override for the LLM-backed chat endpoints.
///
/// Why: `DaemonClient::llm_chat` (`POST /llm/chat`) and
/// `DaemonClient::coordinator_chat` (`POST /api/v1/sessions/chat`) both wait
/// SYNCHRONOUSLY for the daemon's own upstream OpenRouter round trip, which
/// the daemon itself bounds at up to 120s
/// (`OPENROUTER_REQUEST_TIMEOUT_SECS` in
/// `trusty_common::chat::openai_compat::providers`). Applying
/// [`DEFAULT_REQUEST_TIMEOUT`] to these two calls would abort a legitimate,
/// merely-slow completion before the daemon's own timeout even has a chance
/// to fire — trading a real hang for spurious failures on ordinary slow
/// responses. 130s gives the daemon a 10s margin to answer (success or its
/// own timeout error) after its upstream call resolves, while still being a
/// hard, finite bound (never the pre-#2471 "no timeout at all").
/// What: passed to `reqwest::RequestBuilder::timeout` at the `llm_chat` /
/// `coordinator_chat` call sites — a per-request override, not a change to
/// the client-level default every other endpoint uses.
/// Test: `tests::default_client_uses_default_bounds` asserts it exceeds
/// [`DEFAULT_REQUEST_TIMEOUT`].
pub const CHAT_REQUEST_TIMEOUT: Duration = from_secs;
/// Per-request timeout override for session-provisioning endpoints.
///
/// Why: `DaemonClient::spawn_managed_session` (`POST
/// /api/v1/sessions/managed`) has a server handler that runs
/// `inproject::ensure_base_clone` + `create_session_worktree` SYNCHRONOUSLY
/// inside the request — a fetch plus a worktree plus agent/skill deploy. The
/// first spawn against a newly-registered project pays a full clone and can
/// easily exceed [`DEFAULT_REQUEST_TIMEOUT`]'s 10s, which would hard-fail the
/// client mid-provision while the daemon keeps working, orphaning a session
/// the caller believes failed. 180s is bounded but generous: well above any
/// legitimate clone/worktree/deploy sequence on a slow network or loaded
/// machine, while still being a hard, finite bound (never the pre-#2471 "no
/// timeout at all").
/// What: passed to `reqwest::RequestBuilder::timeout` at the
/// `spawn_managed_session` call site — a per-request override, not a change
/// to the client-level default every other endpoint uses. Mirrors the
/// [`CHAT_REQUEST_TIMEOUT`] pattern for the same reason: the endpoint's own
/// server-side work legitimately runs long.
/// Test: `tests::default_client_uses_default_bounds` asserts it exceeds
/// [`DEFAULT_REQUEST_TIMEOUT`].
///
/// `pub(crate)` (#4488): `connectors::tm::TmConnector::create_session` POSTs
/// the SAME route from a different module tree and needs the same bound; a
/// second copy of the constant there would be free to drift.
pub const PROVISION_REQUEST_TIMEOUT: Duration = from_secs;
/// Per-request timeout override for `GET /api/v1/doctor` (#5111).
///
/// Why: the doctor handler runs the whole ~32-check battery SYNCHRONOUSLY
/// inside the request. Several checks are unbounded in the operator's
/// environment rather than in the daemon: `check_gh_account` shells out to
/// `gh auth status` (measured 3.06s on this machine), and `check_worktrees` /
/// `check_worktree_disk` walk the managed workspace root. Measured against
/// [`DEFAULT_REQUEST_TIMEOUT`]: 8 of 10 runs failed, every failure clustered at
/// 10.2–10.3s and every pass at 9.6–9.7s — the client was hanging up on a
/// report the daemon was still producing, so Telegram `/doctor` and Slack
/// `/doctor` reported "daemon unreachable" for a healthy daemon. 120s is well
/// clear of that battery on a loaded machine and stays a hard, finite bound.
/// What: passed to `reqwest::RequestBuilder::timeout` at the
/// [`super::DaemonClient::doctor`] call site — a per-request override, not a
/// change to the client-level default every other endpoint uses. Same pattern
/// and same reason as [`CHAT_REQUEST_TIMEOUT`] and
/// [`PROVISION_REQUEST_TIMEOUT`].
/// Test: `tests::default_client_uses_default_bounds` pins the value;
/// `client::executor::tests::execute_doctor_against_test_daemon` exercises the
/// real round trip that was racing the default bound.
pub const DOCTOR_REQUEST_TIMEOUT: Duration = from_secs;
/// Per-request timeout override for `POST …/managed/prune-worktrees` when the
/// merged-PR reclaim pass is requested (#5830).
///
/// Why: that request runs the whole merged-PR survey synchronously inside the
/// handler. The survey classifies every registered worktree (~92s measured
/// against the real store) and then byte-walks each one, including its
/// multi-gigabyte `target/` — a walk that
/// [`SurveyBudget`](crate::session_manager::worktree_reclaim_sweep::SurveyBudget)'s
/// own doc measures at over 600 seconds for 46 worktrees. Under
/// [`DEFAULT_REQUEST_TIMEOUT`] the client hung up at 10s on EVERY invocation, so
/// `tm session prune-worktrees --merged-prs` had never once completed on this
/// repository. Retrying could not help — the bound was 60x short of the work.
/// 1800s is roughly 3x the measured worst case, and stays a hard, finite bound.
/// What: passed to `reqwest::RequestBuilder::timeout` at the
/// `session_prune_worktrees` call site, and ONLY when `merged_prs` is set — the
/// orphan-only sweep keeps the 10s default, so a wedged daemon still fails fast
/// for every other prune. Mirrors [`CHAT_REQUEST_TIMEOUT`] and
/// [`PROVISION_REQUEST_TIMEOUT`], which exist for the same reason.
/// Test: `tests::default_client_uses_default_bounds` pins the value;
/// `merged_pr_request_outlives_the_default_client_timeout` (in
/// `bin/tm/commands/managed_merged_prs_tests.rs`) proves the override reaches
/// the wire.
///
/// `pub` (re-exported by [`super`]): a `[[bin]]` target is a distinct crate from
/// the library, so `tm`'s own command module cannot see `pub(crate)` — the same
/// crate boundary [`super::default_client`] exists to cross.
pub const RECLAIM_SURVEY_REQUEST_TIMEOUT: Duration = from_secs;
/// Per-request timeout override for the `disk_survey` call `tm session disk`
/// makes over `POST /rpc` (#7313).
///
/// Why: the daemon clamps that survey's own budget to
/// [`crate::daemon::mcp_disk::MAX_BUDGET_SECONDS`] and then still has to
/// serialize a survey covering every managed worktree. Under
/// [`DEFAULT_REQUEST_TIMEOUT`] the client hangs up at 10s while the daemon
/// keeps surveying, so the operator sees a transport error where a truncated
/// survey was available — the #6929 failure mode reached through the client's
/// bound instead of the tool's. This is the same rule the stdio bridge's
/// `REQUEST_TIMEOUT` applies for the same clamp, with more headroom because
/// this caller also pays for the survey's own serialization.
/// What: 75s, passed to `reqwest::RequestBuilder::timeout` at the
/// `session_disk` call site alone; every other request keeps the 10s default.
/// Test: `tests::default_client_uses_default_bounds` pins the value and its
/// headroom over the daemon's clamp.
pub const DISK_SURVEY_REQUEST_TIMEOUT: Duration = from_secs;
/// Build the `reqwest::Client` [`super::DaemonClient::new`] uses by default.
///
/// Why: the one production call site for [`build_client`], pinned to this
/// module's default bounds so `DaemonClient::new` never regresses back to an
/// unbounded client.
/// What: delegates to [`build_client`] with [`DEFAULT_CONNECT_TIMEOUT`] and
/// [`DEFAULT_REQUEST_TIMEOUT`].
/// Test: `tests::default_client_uses_default_bounds`.
pub
/// Build a `reqwest::Client` bounded by `connect_timeout` / `request_timeout`.
///
/// Why: factored out as a pure function of its bounds so the timeout
/// behavior is unit-testable against tiny durations without waiting out the
/// real 3s/10s production values (a full-duration test would be both slow
/// and, on a loaded CI runner, borderline flaky).
/// What: `reqwest::Client::builder().connect_timeout(..).timeout(..).build()`,
/// falling back to `Client::default()` (an unbounded client) on the
/// (practically unreachable — no TLS backend to fail to initialize for a
/// plain-HTTP loopback client) builder error, matching
/// `trusty_common::monitor::memory_client::MemoryClient::new`'s precedent
/// rather than `unwrap()`-ing in library code. The fallback is logged at
/// `warn` level (issue #2512 review) so a silent regression back to an
/// UNBOUNDED default client — the exact failure mode this module exists to
/// prevent — can never happen without a trace.
/// Test: `tests::build_client_bounds_a_stalled_connection`.
pub