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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
//! In-memory progress registry for asynchronous managed-session provisioning
//! (#2605).
//!
//! Why: `POST /api/v1/sessions/managed` used to run the whole workspace
//! provision (git clone / `clone --bare` + worktree + agent/skill deploy)
//! SYNCHRONOUSLY inside the request handler. On a large repo the clone
//! outlasts the CLI's HTTP timeout, so the POST fails even though the daemon
//! keeps working — and the blocking clone runs on the request path, degrading
//! `/health` responsiveness. The fix moves provisioning onto a background task
//! and returns a job id immediately; this registry is where that background
//! task records live phase/detail progress and its terminal outcome so the
//! poll route (`GET .../{id}/provision-status`) and the CLI can follow along.
//! What: [`ProvisioningLifecycle`] (in-flight vs terminal), [`ProvisioningProgress`]
//! (the per-job snapshot), and [`ProvisioningRegistry`] (a `DashMap` keyed by
//! job id, with begin/update/finish/get + stale-entry pruning). No global
//! state — the registry is a field on `DaemonState`, shared via its `Arc`.
//! Test: `registry_lifecycle_provisioning_to_ready`,
//! `registry_lifecycle_provisioning_to_failed`, `update_stage_ignored_after_terminal`,
//! `prune_stale_removes_only_old_terminal_entries` in the `tests` submodule.
use chrono::{DateTime, Duration, Utc};
use dashmap::DashMap;
use crate::core::provisioning_stage::ProvisioningStage;
/// How long a terminal (ready/failed) job snapshot is retained before
/// [`ProvisioningRegistry::begin`] prunes it.
///
/// Why: the registry must not grow without bound over a long-lived daemon, but
/// a terminal entry has to outlive the CLI's poll loop (which reads it once it
/// flips to ready/failed) plus a comfortable margin for a slow/backgrounded
/// operator. Ten minutes is far longer than any poll loop yet keeps the map
/// small.
/// What: the age threshold, measured from `finished_at`, past which a terminal
/// entry is dropped on the next `begin`.
/// Test: `prune_stale_removes_only_old_terminal_entries`.
const TERMINAL_TTL_MINUTES: i64 = 10;
/// Lifecycle of one background provisioning job.
///
/// Why: the poll route and CLI need a single, unambiguous state to branch on —
/// keep waiting, attach, or surface an error — independent of the coarse
/// [`ProvisioningStage`] (which only says WHICH step, not whether the whole job
/// succeeded or failed).
/// What: three states — `Provisioning` (in flight), `Ready` (the session is up
/// and attachable), `Failed` (provisioning errored; `error` carries why).
/// Test: `registry_lifecycle_provisioning_to_ready`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProvisioningLifecycle {
/// Provisioning is still running on the background task.
Provisioning,
/// The session is provisioned, spawned, and ready to attach.
Ready,
/// Provisioning failed; the record (if any) is left for post-mortem.
Failed,
}
impl ProvisioningLifecycle {
/// Stable lowercase wire string for the poll-route JSON `state` field.
///
/// Why: a fixed string keeps the wire contract independent of variant
/// renames and matches the lowercase state vocabulary the rest of the
/// managed API already speaks (`active`, `stopped`, …).
/// What: `"provisioning"` | `"ready"` | `"failed"`.
/// Test: `registry_lifecycle_provisioning_to_ready`.
pub fn wire(&self) -> &'static str {
match self {
ProvisioningLifecycle::Provisioning => "provisioning",
ProvisioningLifecycle::Ready => "ready",
ProvisioningLifecycle::Failed => "failed",
}
}
/// Whether this is a terminal (no further transitions) state.
///
/// Why: stage updates arriving after completion (a late SSE frame) must not
/// overwrite a terminal snapshot, and only terminal entries are eligible
/// for TTL pruning.
/// What: `true` for `Ready`/`Failed`, `false` for `Provisioning`.
/// Test: `update_stage_ignored_after_terminal`.
pub fn is_terminal(&self) -> bool {
!matches!(self, ProvisioningLifecycle::Provisioning)
}
}
/// A point-in-time snapshot of one background provisioning job.
///
/// Why: the poll route serialises this into its response; keeping every field
/// the CLI might render (coarse stage, fine detail, final id/name, error) in
/// one struct means the route handler is a pure mapping with no extra lookups.
/// What: the lifecycle, the latest coarse [`ProvisioningStage`] and optional
/// fine `detail`, the final `session_id`/`name` (populated on success — the
/// final record id may differ from the job id when the daemon reconnects to an
/// existing session), an `error` (populated on failure), and timestamps.
/// Test: `registry_lifecycle_provisioning_to_ready`.
#[derive(Debug, Clone)]
pub struct ProvisioningProgress {
/// Current lifecycle state.
pub lifecycle: ProvisioningLifecycle,
/// Latest coarse provisioning stage, if any has been observed yet.
pub stage: Option<ProvisioningStage>,
/// Fine-grained detail within the current stage (e.g. clone percent).
pub detail: Option<String>,
/// Final managed-session id once ready (may differ from the job id on a
/// reconnect-to-existing-session outcome).
pub session_id: Option<String>,
/// tmux session name once ready.
pub name: Option<String>,
/// Failure reason once failed.
pub error: Option<String>,
/// When the job was registered.
pub started_at: DateTime<Utc>,
/// When the job reached a terminal state, if it has.
pub finished_at: Option<DateTime<Utc>>,
}
impl ProvisioningProgress {
/// Construct a fresh in-flight snapshot with `started_at = now`.
fn new_in_flight() -> Self {
Self {
lifecycle: ProvisioningLifecycle::Provisioning,
stage: None,
detail: None,
session_id: None,
name: None,
error: None,
started_at: Utc::now(),
finished_at: None,
}
}
}
/// Concurrent registry of background provisioning jobs, keyed by job id.
///
/// Why: the async-spawn handler, its stage-updater task, and the poll-route
/// handler all touch the same job snapshots from different tokio tasks; a
/// `DashMap` gives lock-free-per-entry interior mutability so the registry can
/// live as a plain `DaemonState` field shared via the daemon `Arc` (no global
/// state, no outer `Mutex`).
/// What: `begin`/`update_stage`/`finish_ready`/`finish_failed`/`get` over a
/// `DashMap<String, ProvisioningProgress>`, plus stale-entry pruning driven off
/// [`TERMINAL_TTL_MINUTES`].
/// Test: the `tests` submodule.
#[derive(Debug, Default)]
pub struct ProvisioningRegistry {
entries: DashMap<String, ProvisioningProgress>,
}
impl ProvisioningRegistry {
/// Register a new in-flight job under `job_id`, pruning stale terminals first.
///
/// Why: the async-spawn handler calls this before returning `202` so the
/// very first poll — which can race the background task's first stage
/// event — always finds an entry (state `provisioning`) rather than a 404.
/// What: prunes expired terminal entries, then inserts a fresh
/// [`ProvisioningProgress::new_in_flight`] for `job_id`.
/// Test: `registry_lifecycle_provisioning_to_ready`.
pub fn begin(&self, job_id: &str) {
self.prune_stale();
self.entries
.insert(job_id.to_string(), ProvisioningProgress::new_in_flight());
}
/// Record the latest coarse stage (and optional fine detail) for `job_id`.
///
/// Why: the stage-updater task translates each `provisioning_stage` SSE
/// frame for this job into a registry update so the poll route reflects
/// live movement.
/// What: if an entry exists and is NOT terminal, sets its `stage` and
/// `detail`; a stage update after completion (a late frame) is ignored so it
/// cannot clobber a terminal snapshot.
/// Test: `update_stage_ignored_after_terminal`.
pub fn update_stage(&self, job_id: &str, stage: ProvisioningStage, detail: Option<String>) {
if let Some(mut e) = self.entries.get_mut(job_id)
&& !e.lifecycle.is_terminal()
{
e.stage = Some(stage);
e.detail = detail;
}
}
/// Mark `job_id` ready, recording the final session id and tmux name.
///
/// Why: on a successful background spawn the poll route must hand the CLI
/// the REAL final session id and name to attach to (which can differ from
/// the job id when the daemon reconnected to an existing session).
/// What: sets lifecycle `Ready`, `stage = Complete`, clears `detail`, and
/// stamps `finished_at`. A no-op if the job is unknown.
/// Test: `registry_lifecycle_provisioning_to_ready`.
pub fn finish_ready(&self, job_id: &str, session_id: String, name: String) {
if let Some(mut e) = self.entries.get_mut(job_id) {
e.lifecycle = ProvisioningLifecycle::Ready;
e.stage = Some(ProvisioningStage::Complete);
e.detail = None;
e.session_id = Some(session_id);
e.name = Some(name);
e.finished_at = Some(Utc::now());
}
}
/// Mark `job_id` failed with a human-readable `error`.
///
/// Why: a provisioning failure (bad ref, clone error, gate refusal) must be
/// surfaced to the CLI as a clear terminal state, not an indefinite wait.
/// What: sets lifecycle `Failed`, records `error`, and stamps `finished_at`.
/// A no-op if the job is unknown.
/// Test: `registry_lifecycle_provisioning_to_failed`.
pub fn finish_failed(&self, job_id: &str, error: String) {
if let Some(mut e) = self.entries.get_mut(job_id) {
e.lifecycle = ProvisioningLifecycle::Failed;
e.error = Some(error);
e.finished_at = Some(Utc::now());
}
}
/// Fetch a clone of the current snapshot for `job_id`, if present.
///
/// Why: the poll route needs an owned snapshot to serialise without holding
/// a `DashMap` guard across the response build.
/// What: returns `Some(ProvisioningProgress)` clone or `None`.
/// Test: `registry_lifecycle_provisioning_to_ready`.
pub fn get(&self, job_id: &str) -> Option<ProvisioningProgress> {
self.entries.get(job_id).map(|e| e.clone())
}
/// Drop terminal entries whose `finished_at` is older than the TTL.
///
/// Why: bounds the registry's memory over a long-lived daemon without
/// evicting an in-flight job or a just-completed one the CLI is still about
/// to read.
/// What: retains every non-terminal entry and every terminal entry younger
/// than [`TERMINAL_TTL_MINUTES`].
/// Test: `prune_stale_removes_only_old_terminal_entries`.
pub fn prune_stale(&self) {
let cutoff = Utc::now() - Duration::minutes(TERMINAL_TTL_MINUTES);
self.entries.retain(|_, e| match e.finished_at {
Some(finished) => finished >= cutoff,
None => true,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_lifecycle_provisioning_to_ready() {
let reg = ProvisioningRegistry::default();
reg.begin("job-1");
let snap = reg.get("job-1").expect("entry after begin");
assert_eq!(snap.lifecycle, ProvisioningLifecycle::Provisioning);
assert!(snap.stage.is_none());
reg.update_stage(
"job-1",
ProvisioningStage::CloningRepo,
Some("Receiving objects: 42%".into()),
);
let snap = reg.get("job-1").expect("entry after stage");
assert_eq!(snap.stage, Some(ProvisioningStage::CloningRepo));
assert_eq!(snap.detail.as_deref(), Some("Receiving objects: 42%"));
reg.finish_ready("job-1", "sess-9".into(), "tm-repo-01".into());
let snap = reg.get("job-1").expect("entry after ready");
assert_eq!(snap.lifecycle, ProvisioningLifecycle::Ready);
assert_eq!(snap.stage, Some(ProvisioningStage::Complete));
assert_eq!(snap.session_id.as_deref(), Some("sess-9"));
assert_eq!(snap.name.as_deref(), Some("tm-repo-01"));
assert!(snap.detail.is_none());
assert!(snap.finished_at.is_some());
}
#[test]
fn registry_lifecycle_provisioning_to_failed() {
let reg = ProvisioningRegistry::default();
reg.begin("job-2");
reg.finish_failed("job-2", "workspace provisioning failed: boom".into());
let snap = reg.get("job-2").expect("entry after failed");
assert_eq!(snap.lifecycle, ProvisioningLifecycle::Failed);
assert_eq!(
snap.error.as_deref(),
Some("workspace provisioning failed: boom")
);
assert!(snap.finished_at.is_some());
}
#[test]
fn update_stage_ignored_after_terminal() {
let reg = ProvisioningRegistry::default();
reg.begin("job-3");
reg.finish_ready("job-3", "sess".into(), "name".into());
// A late stage frame must not resurrect/clobber the terminal snapshot.
reg.update_stage("job-3", ProvisioningStage::CloningRepo, None);
let snap = reg.get("job-3").expect("entry");
assert_eq!(snap.lifecycle, ProvisioningLifecycle::Ready);
assert_eq!(snap.stage, Some(ProvisioningStage::Complete));
}
#[test]
fn prune_stale_removes_only_old_terminal_entries() {
let reg = ProvisioningRegistry::default();
// In-flight: never pruned.
reg.begin("in-flight");
// Fresh terminal: retained.
reg.begin("fresh-done");
reg.finish_ready("fresh-done", "s".into(), "n".into());
// Old terminal: hand-age its finished_at past the TTL, then prune.
reg.begin("old-done");
reg.finish_failed("old-done", "err".into());
if let Some(mut e) = reg.entries.get_mut("old-done") {
e.finished_at = Some(Utc::now() - Duration::minutes(TERMINAL_TTL_MINUTES + 1));
}
reg.prune_stale();
assert!(reg.get("in-flight").is_some(), "in-flight must survive");
assert!(
reg.get("fresh-done").is_some(),
"fresh terminal must survive"
);
assert!(
reg.get("old-done").is_none(),
"stale terminal must be pruned"
);
}
}