hunt commited on
Commit
ed92f65
·
1 Parent(s): a9f16ea

Implement Task 8: job_orchestrator (confirm/cancel/progress state machine, 291/291 tests passing)

Browse files
miranda-nodes/src/forge/job_orchestrator.rs ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Task 8 — Conversational deployment & progress reporting.
2
+ //!
3
+ //! Wires Job Parser output through confirmation, dispatch, and progress
4
+ //! query handling as real state-machine logic — testable without live
5
+ //! GPU/model infra, since what this task actually needs is correct state
6
+ //! transitions and query responses, not the training/merge work itself
7
+ //! (that's Tasks 6/7's disclosed-scope territory).
8
+ //!
9
+ //! Requirement 4.2/4.3/4.4: a Forge job goes through
10
+ //! `AwaitingConfirmation -> Running -> Completed/Failed`, and
11
+ //! `progress_summary` answers "how's the fine-tune going?" against
12
+ //! whatever state a running job is currently in.
13
+
14
+ use chrono::{DateTime, Utc};
15
+ use uuid::Uuid;
16
+
17
+ use crate::forge::finetune_pipeline::TrainingMetrics;
18
+ use crate::forge::gpu_provisioner::ProvisionError;
19
+ use crate::forge::job_parser::JobSpec;
20
+ use crate::forge::merge_pipeline::MergeError;
21
+
22
+ #[derive(Debug, Clone, PartialEq)]
23
+ pub enum JobStatus {
24
+ /// Requirement 5.2 — job is parsed but blocked on the user
25
+ /// confirming a cost/action that exceeded their autonomy threshold.
26
+ AwaitingConfirmation,
27
+ Running { started_at: DateTime<Utc> },
28
+ Completed { finished_at: DateTime<Utc>, summary: String },
29
+ Failed { failed_at: DateTime<Utc>, reason: String },
30
+ Cancelled { cancelled_at: DateTime<Utc> },
31
+ }
32
+
33
+ #[derive(Debug, Clone)]
34
+ pub struct ForgeJob {
35
+ pub id: Uuid,
36
+ pub spec: JobSpec,
37
+ pub status: JobStatus,
38
+ pub created_at: DateTime<Utc>,
39
+ }
40
+
41
+ /// Tracks in-flight Forge jobs so progress queries and completion
42
+ /// announcements have somewhere real to read from, rather than each
43
+ /// caller needing to thread job state through by hand. In-memory here;
44
+ /// a real deployment would persist this the same way `ModelRegistry`
45
+ /// discloses its own in-memory-for-now posture.
46
+ #[derive(Debug, Default)]
47
+ pub struct JobOrchestrator {
48
+ jobs: Vec<ForgeJob>,
49
+ }
50
+
51
+ impl JobOrchestrator {
52
+ pub fn new() -> Self {
53
+ Self::default()
54
+ }
55
+
56
+ /// Requirement 4.2 — a freshly parsed job always starts
57
+ /// `AwaitingConfirmation`, even if the GPU provisioner would allow
58
+ /// it autonomously; the confirmation dialogue step (Requirement 4.2)
59
+ /// is a conversational turn that has to happen regardless, and
60
+ /// `confirm` is the only way to move a job out of this state.
61
+ pub fn submit(&mut self, spec: JobSpec, now: DateTime<Utc>) -> Uuid {
62
+ let id = Uuid::new_v4();
63
+ self.jobs.push(ForgeJob {
64
+ id,
65
+ spec,
66
+ status: JobStatus::AwaitingConfirmation,
67
+ created_at: now,
68
+ });
69
+ id
70
+ }
71
+
72
+ pub fn confirm(&mut self, id: Uuid, now: DateTime<Utc>) -> Result<(), OrchestratorError> {
73
+ let job = self.job_mut(id)?;
74
+ if job.status != JobStatus::AwaitingConfirmation {
75
+ return Err(OrchestratorError::InvalidTransition {
76
+ id,
77
+ from: format!("{:?}", job.status),
78
+ to: "Running".to_string(),
79
+ });
80
+ }
81
+ job.status = JobStatus::Running { started_at: now };
82
+ Ok(())
83
+ }
84
+
85
+ pub fn cancel(&mut self, id: Uuid, now: DateTime<Utc>) -> Result<(), OrchestratorError> {
86
+ let job = self.job_mut(id)?;
87
+ match job.status {
88
+ JobStatus::Completed { .. } | JobStatus::Failed { .. } | JobStatus::Cancelled { .. } => {
89
+ Err(OrchestratorError::InvalidTransition {
90
+ id,
91
+ from: format!("{:?}", job.status),
92
+ to: "Cancelled".to_string(),
93
+ })
94
+ }
95
+ _ => {
96
+ job.status = JobStatus::Cancelled { cancelled_at: now };
97
+ Ok(())
98
+ }
99
+ }
100
+ }
101
+
102
+ pub fn complete_finetune(&mut self, id: Uuid, metrics: TrainingMetrics, now: DateTime<Utc>) -> Result<(), OrchestratorError> {
103
+ let job = self.job_mut(id)?;
104
+ job.status = JobStatus::Completed {
105
+ finished_at: now,
106
+ summary: format!(
107
+ "fine-tune finished: final loss {:.3}, {:.1} GPU-hours",
108
+ metrics.final_loss, metrics.gpu_hours
109
+ ),
110
+ };
111
+ Ok(())
112
+ }
113
+
114
+ pub fn fail(&mut self, id: Uuid, reason: impl Into<String>, now: DateTime<Utc>) -> Result<(), OrchestratorError> {
115
+ let job = self.job_mut(id)?;
116
+ job.status = JobStatus::Failed { failed_at: now, reason: reason.into() };
117
+ Ok(())
118
+ }
119
+
120
+ /// Requirement 4.3 — answers "how's the fine-tune going?" against
121
+ /// whatever state the job is actually in, in conversational form.
122
+ pub fn progress_summary(&self, id: Uuid) -> Result<String, OrchestratorError> {
123
+ let job = self.job(id)?;
124
+ Ok(match &job.status {
125
+ JobStatus::AwaitingConfirmation => {
126
+ "That job is still waiting on your confirmation before it starts.".to_string()
127
+ }
128
+ JobStatus::Running { started_at } => {
129
+ let elapsed = Utc::now() - *started_at;
130
+ format!("Still running — it's been going for about {} minutes so far.", elapsed.num_minutes().max(0))
131
+ }
132
+ JobStatus::Completed { summary, .. } => summary.clone(),
133
+ JobStatus::Failed { reason, .. } => format!("That job failed: {reason}"),
134
+ JobStatus::Cancelled { .. } => "That job was cancelled.".to_string(),
135
+ })
136
+ }
137
+
138
+ pub fn get(&self, id: Uuid) -> Result<&ForgeJob, OrchestratorError> {
139
+ self.job(id)
140
+ }
141
+
142
+ fn job(&self, id: Uuid) -> Result<&ForgeJob, OrchestratorError> {
143
+ self.jobs.iter().find(|j| j.id == id).ok_or(OrchestratorError::NotFound(id))
144
+ }
145
+
146
+ fn job_mut(&mut self, id: Uuid) -> Result<&mut ForgeJob, OrchestratorError> {
147
+ self.jobs.iter_mut().find(|j| j.id == id).ok_or(OrchestratorError::NotFound(id))
148
+ }
149
+ }
150
+
151
+ #[derive(Debug, Clone, PartialEq, thiserror::Error)]
152
+ pub enum OrchestratorError {
153
+ #[error("no job found with id {0}")]
154
+ NotFound(Uuid),
155
+ #[error("job {id} cannot transition from {from} to {to}")]
156
+ InvalidTransition { id: Uuid, from: String, to: String },
157
+ }
158
+
159
+ /// Converts a provisioning/merge failure into a user-facing failure
160
+ /// reason string, per design.md's error handling: "the raw error is
161
+ /// surfaced to the user in conversational form rather than swallowed."
162
+ pub fn describe_provision_failure(err: &ProvisionError) -> String {
163
+ err.to_string()
164
+ }
165
+
166
+ pub fn describe_merge_failure(err: &MergeError) -> String {
167
+ err.to_string()
168
+ }
169
+
170
+ #[cfg(test)]
171
+ mod tests {
172
+ use super::*;
173
+ use crate::forge::job_parser::{JobMethod, ModelRef};
174
+ use std::path::PathBuf;
175
+ use std::time::Duration;
176
+
177
+ fn sample_spec() -> JobSpec {
178
+ JobSpec {
179
+ method: JobMethod::LoraFineTune,
180
+ base_models: vec![ModelRef {
181
+ name: "GLM-9B".to_string(),
182
+ family: "GLM".to_string(),
183
+ size: "9B".to_string(),
184
+ local_path: Some(PathBuf::from("/models/glm-9b")),
185
+ hf_repo: None,
186
+ }],
187
+ instructions: None,
188
+ merge_method: None,
189
+ estimated_cost: 1.5,
190
+ estimated_duration: Duration::from_secs(3600),
191
+ }
192
+ }
193
+
194
+ #[test]
195
+ fn submitted_job_starts_awaiting_confirmation() {
196
+ let mut orch = JobOrchestrator::new();
197
+ let id = orch.submit(sample_spec(), Utc::now());
198
+ assert_eq!(orch.get(id).unwrap().status, JobStatus::AwaitingConfirmation);
199
+ }
200
+
201
+ #[test]
202
+ fn confirm_transitions_to_running() {
203
+ let mut orch = JobOrchestrator::new();
204
+ let id = orch.submit(sample_spec(), Utc::now());
205
+ orch.confirm(id, Utc::now()).unwrap();
206
+ assert!(matches!(orch.get(id).unwrap().status, JobStatus::Running { .. }));
207
+ }
208
+
209
+ #[test]
210
+ fn cannot_confirm_a_job_twice() {
211
+ let mut orch = JobOrchestrator::new();
212
+ let id = orch.submit(sample_spec(), Utc::now());
213
+ orch.confirm(id, Utc::now()).unwrap();
214
+ let err = orch.confirm(id, Utc::now()).unwrap_err();
215
+ assert!(matches!(err, OrchestratorError::InvalidTransition { .. }));
216
+ }
217
+
218
+ #[test]
219
+ fn progress_summary_before_confirmation_mentions_awaiting_confirmation() {
220
+ let mut orch = JobOrchestrator::new();
221
+ let id = orch.submit(sample_spec(), Utc::now());
222
+ let summary = orch.progress_summary(id).unwrap();
223
+ assert!(summary.to_lowercase().contains("confirmation"));
224
+ }
225
+
226
+ #[test]
227
+ fn progress_summary_while_running_mentions_running() {
228
+ let mut orch = JobOrchestrator::new();
229
+ let id = orch.submit(sample_spec(), Utc::now());
230
+ orch.confirm(id, Utc::now()).unwrap();
231
+ let summary = orch.progress_summary(id).unwrap();
232
+ assert!(summary.to_lowercase().contains("running"));
233
+ }
234
+
235
+ #[test]
236
+ fn complete_finetune_produces_a_summary_with_metrics() {
237
+ let mut orch = JobOrchestrator::new();
238
+ let id = orch.submit(sample_spec(), Utc::now());
239
+ orch.confirm(id, Utc::now()).unwrap();
240
+ let metrics = TrainingMetrics { final_loss: 0.31, duration: Duration::from_secs(3600), gpu_hours: 1.0 };
241
+ orch.complete_finetune(id, metrics, Utc::now()).unwrap();
242
+
243
+ let summary = orch.progress_summary(id).unwrap();
244
+ assert!(summary.contains("0.31") || summary.contains("0.310"));
245
+ }
246
+
247
+ #[test]
248
+ fn fail_produces_a_summary_containing_the_reason() {
249
+ let mut orch = JobOrchestrator::new();
250
+ let id = orch.submit(sample_spec(), Utc::now());
251
+ orch.confirm(id, Utc::now()).unwrap();
252
+ orch.fail(id, "GPU quota exceeded", Utc::now()).unwrap();
253
+ let summary = orch.progress_summary(id).unwrap();
254
+ assert!(summary.contains("GPU quota exceeded"));
255
+ }
256
+
257
+ #[test]
258
+ fn cancel_from_awaiting_confirmation_succeeds() {
259
+ let mut orch = JobOrchestrator::new();
260
+ let id = orch.submit(sample_spec(), Utc::now());
261
+ orch.cancel(id, Utc::now()).unwrap();
262
+ assert!(matches!(orch.get(id).unwrap().status, JobStatus::Cancelled { .. }));
263
+ }
264
+
265
+ #[test]
266
+ fn cannot_cancel_an_already_completed_job() {
267
+ let mut orch = JobOrchestrator::new();
268
+ let id = orch.submit(sample_spec(), Utc::now());
269
+ orch.confirm(id, Utc::now()).unwrap();
270
+ let metrics = TrainingMetrics { final_loss: 0.2, duration: Duration::from_secs(60), gpu_hours: 0.1 };
271
+ orch.complete_finetune(id, metrics, Utc::now()).unwrap();
272
+ let err = orch.cancel(id, Utc::now()).unwrap_err();
273
+ assert!(matches!(err, OrchestratorError::InvalidTransition { .. }));
274
+ }
275
+
276
+ #[test]
277
+ fn querying_an_unknown_job_id_returns_not_found() {
278
+ let orch = JobOrchestrator::new();
279
+ let err = orch.progress_summary(Uuid::new_v4()).unwrap_err();
280
+ assert!(matches!(err, OrchestratorError::NotFound(_)));
281
+ }
282
+
283
+ #[test]
284
+ fn describe_provision_failure_surfaces_the_raw_reason() {
285
+ let err = ProvisionError::RequiresConfirmation { estimated: 10.0, threshold: 5.0 };
286
+ let desc = describe_provision_failure(&err);
287
+ assert!(desc.contains("10.00") || desc.contains("10"));
288
+ assert!(desc.contains("5.00") || desc.contains("5"));
289
+ }
290
+
291
+ #[test]
292
+ fn describe_merge_failure_surfaces_the_raw_reason() {
293
+ let err = MergeError::MergeKitFailed { reason: "tensor mismatch".to_string() };
294
+ let desc = describe_merge_failure(&err);
295
+ assert!(desc.contains("tensor mismatch"));
296
+ }
297
+ }
miranda-nodes/src/forge/mod.rs CHANGED
@@ -16,6 +16,7 @@ pub mod compatibility;
16
  pub mod finetune_pipeline;
17
  pub mod gpu_provisioner;
18
  pub mod job_parser;
 
19
  pub mod merge_pipeline;
20
  pub mod model_registry;
21
  pub mod naming;
 
16
  pub mod finetune_pipeline;
17
  pub mod gpu_provisioner;
18
  pub mod job_parser;
19
+ pub mod job_orchestrator;
20
  pub mod merge_pipeline;
21
  pub mod model_registry;
22
  pub mod naming;