1use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use serde_json::{json, Map, Value};
12
13use crate::{
14 ingress_plans, schedule_decision, ActionIntent, ActionState, CapabilityKind,
15 CapabilityManifest, CatchUpPolicy, EvaluationOutcome, Event, HostError, MemoryState,
16 NodeRegistry, ScheduleCadence, SchedulePolicy, Spec, Terminal, WorkDisposition, WorkItem,
17 WorkflowDriver, WorkflowHost, WorkflowTransitionCommand,
18};
19
20const IDLE_WAIT_SECS: i64 = 86_400;
24
25pub struct SpecDriver {
27 spec_id: String,
28 host: WorkflowHost,
29 schedule: Option<SchedulePolicy>,
30 actions: BTreeMap<String, CapabilityManifest>,
31}
32
33impl SpecDriver {
34 pub fn new(spec: &Spec, registry: &NodeRegistry) -> Result<Self, HostError> {
36 let host = WorkflowHost::from_spec(spec, registry)?;
37 let schedule = ingress_plans(spec, registry)
38 .into_iter()
39 .find(|plan| plan.branch_id == host.branch_id())
40 .map(|plan| {
41 let required_u64 = |name: &str| {
42 plan.ingress_config[name]
43 .as_u64()
44 .ok_or_else(|| HostError::Schedule {
45 spec_id: spec.spec_id.clone(),
46 reason: format!("{} requires integer '{name}'", plan.ingress_type),
47 })
48 };
49 let cadence = match plan.ingress_type.as_str() {
50 "ingress.cron" => ScheduleCadence::Cron {
51 expression: plan.ingress_config["expression"]
52 .as_str()
53 .ok_or_else(|| HostError::Schedule {
54 spec_id: spec.spec_id.clone(),
55 reason: "ingress.cron requires a string 'expression'".into(),
56 })?
57 .to_owned(),
58 },
59 "ingress.fixed_rate" => ScheduleCadence::FixedRate {
60 milliseconds: required_u64("milliseconds")?,
61 },
62 "ingress.fixed_delay" => ScheduleCadence::FixedDelay {
63 milliseconds: required_u64("milliseconds")?,
64 },
65 _ => return Ok(None),
66 };
67 let catch_up = match plan.ingress_config["catch_up"].as_str().unwrap_or("once") {
68 "skip" => CatchUpPolicy::Skip,
69 "once" => CatchUpPolicy::CatchUpOnce,
70 "all" => CatchUpPolicy::CatchUpAll {
71 limit: plan.ingress_config["catch_up_limit"]
72 .as_u64()
73 .and_then(|value| u32::try_from(value).ok())
74 .ok_or_else(|| HostError::Schedule {
75 spec_id: spec.spec_id.clone(),
76 reason: "catch_up=all requires positive integer catch_up_limit"
77 .into(),
78 })?,
79 },
80 value => {
81 return Err(HostError::Schedule {
82 spec_id: spec.spec_id.clone(),
83 reason: format!("unknown catch_up policy '{value}'"),
84 })
85 }
86 };
87 let policy = SchedulePolicy {
88 cadence,
89 timezone: plan.ingress_config["timezone"]
90 .as_str()
91 .unwrap_or("UTC")
92 .to_owned(),
93 catch_up,
94 };
95 policy.validate().map_err(|error| HostError::Schedule {
96 spec_id: spec.spec_id.clone(),
97 reason: error.to_string(),
98 })?;
99 Ok::<_, HostError>(Some(policy))
100 })
101 .transpose()?
102 .flatten();
103 let actions = registry
104 .capability_manifests()
105 .filter(|manifest| manifest.kind == CapabilityKind::Action)
106 .map(|manifest| (manifest.id.clone(), manifest.clone()))
107 .collect();
108 Ok(Self {
109 spec_id: spec.spec_id.clone(),
110 host,
111 schedule,
112 actions,
113 })
114 }
115
116 fn action_intents(
117 &self,
118 item: &WorkItem,
119 requests: Vec<crate::PreparedAction>,
120 ) -> Result<Vec<ActionIntent>, String> {
121 requests
122 .into_iter()
123 .enumerate()
124 .map(|(index, request)| {
125 request.validate().map_err(|error| error.to_string())?;
126 let manifest = self
127 .actions
128 .get(request.capability_id.as_str())
129 .ok_or_else(|| {
130 format!(
131 "action step emitted unknown capability '{}'",
132 request.capability_id
133 )
134 })?;
135 let capability = item
136 .capability_pins
137 .iter()
138 .find(|pin| {
139 pin.id == manifest.id
140 && pin.contract_version == manifest.contract_version
141 && pin.content_digest == manifest.content_digest
142 })
143 .cloned()
144 .ok_or_else(|| {
145 format!(
146 "workflow revision does not pin action capability '{}'",
147 manifest.id
148 )
149 })?;
150 Ok(ActionIntent {
151 id: format!("{}:{}:action:{index}", item.id, item.state_version),
152 tenant_id: item.tenant_id.clone(),
153 instance_id: item
154 .id
155 .parse()
156 .map_err(|error: af_context::EmptyId| error.to_string())?,
157 run_id: item.run_id.clone(),
158 capability,
159 idempotency_key: format!(
160 "{}:{}:{}",
161 item.id, item.state_version, request.idempotency_key
162 ),
163 state: ActionState::Prepared,
164 input: request.input,
165 effect: manifest.effect,
166 retry_class: manifest.idempotency_mode,
167 control_epochs: item.control_epochs,
168 resource_scope_id: request.resource_scope_id,
169 lease_epoch: item.lease_version,
170 action_epoch: item.state_version,
171 deadline: request.deadline,
174 reservation: request.reservation,
175 created_at: item.claimed_at,
176 })
177 })
178 .collect()
179 }
180
181 fn terminal_action(
182 &self,
183 item: &WorkItem,
184 next_state: &mut Map<String, Value>,
185 ) -> Result<Option<WorkflowTransitionCommand>, String> {
186 let Some(wakeup) = item
187 .wakeups
188 .iter()
189 .find(|wakeup| wakeup.payload["kind"] == "terminal_action")
190 else {
191 return Ok(None);
192 };
193 let observation: crate::ActionObservation =
194 serde_json::from_value(wakeup.payload["observation"].clone())
195 .map_err(|error| format!("terminal action fact: {error}"))?;
196 let Some(internal) = next_state
197 .get_mut("__workflow")
198 .and_then(Value::as_object_mut)
199 else {
200 return Err("terminal action has no durable pending-action state".into());
201 };
202 let pending = internal
203 .get_mut("pending_actions")
204 .and_then(Value::as_array_mut)
205 .ok_or_else(|| "terminal action has no pending action list".to_string())?;
206 let before = pending.len();
207 pending.retain(|id| id.as_str() != Some(&observation.action_intent_id));
208 if pending.len() == before {
209 return Err("terminal action does not belong to this workflow instance".into());
210 }
211 let disposition = if pending.is_empty() {
212 internal
213 .remove("resume_at")
214 .map(serde_json::from_value)
215 .transpose()
216 .map_err(|error| format!("stored workflow resume_at: {error}"))?
217 .map(|at| WorkDisposition::Reschedule { at })
218 .unwrap_or_else(|| {
219 if internal
220 .get("static_event_consumed")
221 .and_then(Value::as_bool)
222 .unwrap_or(false)
223 {
224 WorkDisposition::Complete
225 } else {
226 Self::idle_disposition()
227 }
228 })
229 } else {
230 WorkDisposition::Continue {
231 delay_secs: IDLE_WAIT_SECS,
232 }
233 };
234 let succeeded = matches!(
235 observation.state.as_str(),
236 "completed" | "max_steps_reached" | "succeeded"
237 );
238 Ok(Some(command(
239 item,
240 Value::Object(next_state.clone()),
241 Vec::new(),
242 EvaluationOutcome {
243 triggered: true,
244 matched: true,
245 succeeded,
246 action_terminal: true,
247 },
248 disposition,
249 "workflow.action_observed",
250 )))
251 }
252
253 fn catch_up_count(state: &Map<String, Value>) -> u32 {
254 state
255 .get("__workflow")
256 .and_then(Value::as_object)
257 .and_then(|internal| internal.get("catch_up_count"))
258 .and_then(Value::as_u64)
259 .and_then(|value| u32::try_from(value).ok())
260 .unwrap_or(0)
261 }
262
263 fn set_internal(state: &mut Map<String, Value>, key: &str, value: Value) -> Result<(), String> {
264 let internal = state
265 .entry("__workflow")
266 .or_insert_with(|| json!({}))
267 .as_object_mut()
268 .ok_or_else(|| "workflow internal state must be an object".to_string())?;
269 internal.insert(key.into(), value);
270 Ok(())
271 }
272
273 fn schedule_decision(
274 &self,
275 item: &WorkItem,
276 state: &Map<String, Value>,
277 ) -> Result<Option<crate::ScheduleDecision>, String> {
278 if self.schedule.is_some() && !item.wakeups.is_empty() {
279 return Ok(Some(crate::ScheduleDecision {
280 tick_at: None,
281 next_at: item.scheduled_at,
282 catch_up_count: Self::catch_up_count(state),
283 }));
284 }
285 self.schedule
286 .as_ref()
287 .map(|schedule| {
288 schedule_decision(
289 schedule,
290 item.scheduled_at,
291 item.claimed_at,
292 Self::catch_up_count(state),
293 )
294 })
295 .transpose()
296 }
297
298 fn idle_disposition() -> WorkDisposition {
299 WorkDisposition::Continue {
300 delay_secs: IDLE_WAIT_SECS,
301 }
302 }
303}
304
305#[async_trait]
306impl<Context: Send + Sync> WorkflowDriver<Context> for SpecDriver {
307 fn name(&self) -> &'static str {
308 "spec"
309 }
310
311 fn spec_ids(&self) -> Vec<&str> {
312 vec![&self.spec_id]
313 }
314
315 fn validate_specs(&self) -> Result<(), String> {
316 Ok(())
317 }
318
319 async fn evaluate(
320 &self,
321 _: &Context,
322 item: &WorkItem,
323 ) -> Result<WorkflowTransitionCommand, String> {
324 let mut next_state = match &item.config {
325 Value::Object(map) => map.clone(),
326 Value::Null => Map::new(),
327 _ => return Err("spec instance config must be a JSON object".into()),
328 };
329 if item.cancel_requested {
330 return Ok(command(
331 item,
332 Value::Object(next_state),
333 Vec::new(),
334 EvaluationOutcome::default(),
335 WorkDisposition::Complete,
336 "workflow.cancelled",
337 ));
338 }
339 if let Some(command) = self.terminal_action(item, &mut next_state)? {
340 return Ok(command);
341 }
342 if next_state
343 .get("__workflow")
344 .and_then(Value::as_object)
345 .and_then(|internal| internal.get("pending_actions"))
346 .and_then(Value::as_array)
347 .is_some_and(|pending| !pending.is_empty())
348 {
349 return Ok(command(
350 item,
351 Value::Object(next_state),
352 Vec::new(),
353 EvaluationOutcome::default(),
354 Self::idle_disposition(),
355 "workflow.action_waiting",
356 ));
357 }
358 let schedule = self.schedule_decision(item, &next_state)?;
359 if let Some(decision) = schedule {
360 Self::set_internal(
361 &mut next_state,
362 "catch_up_count",
363 json!(decision.catch_up_count),
364 )?;
365 if decision.tick_at.is_none() && item.wakeups.is_empty() {
366 return Ok(command(
367 item,
368 Value::Object(next_state),
369 Vec::new(),
370 EvaluationOutcome::default(),
371 WorkDisposition::Reschedule {
372 at: decision.next_at,
373 },
374 "workflow.schedule_skipped",
375 ));
376 }
377 }
378 let state = Arc::new(MemoryState::from_snapshot(
379 next_state
380 .get("state")
381 .and_then(Value::as_object)
382 .cloned()
383 .unwrap_or_default(),
384 ));
385 let wakeup_payload = item.wakeups.first().map(|wakeup| wakeup.payload.clone());
386 let static_event = next_state.get("event").cloned().filter(|_| {
387 !next_state
388 .get("__workflow")
389 .and_then(Value::as_object)
390 .and_then(|internal| internal.get("static_event_consumed"))
391 .and_then(Value::as_bool)
392 .unwrap_or(false)
393 });
394 let consumed_static_event = wakeup_payload.is_none() && static_event.is_some();
395 let payload = wakeup_payload
396 .or(static_event)
397 .or_else(|| {
398 schedule
399 .and_then(|decision| decision.tick_at)
400 .map(|tick_at| json!({ "tick_at": tick_at }))
401 })
402 .ok_or_else(|| "event workflow requires a trigger delivery".to_string())?;
403 if consumed_static_event {
404 Self::set_internal(&mut next_state, "static_event_consumed", json!(true))?;
405 }
406 let context = self.host.context(state.clone());
407 let outcome = self
408 .host
409 .run_event(&context, Event::from_json(payload))
410 .await
411 .ok_or_else(|| "spec has no root branch".to_string())?;
412 let (terminal, exit_reason) = match &outcome.terminal {
413 Terminal::Completed => ("completed", Value::Null),
414 Terminal::Dropped { node_id, reason } => {
415 ("dropped", json!({ "node_id": node_id, "reason": reason }))
416 }
417 };
418 next_state.insert("state".into(), Value::Object(state.snapshot()));
419 next_state.insert(
420 "last_run".into(),
421 json!({
422 "terminal": terminal,
423 "exit": exit_reason,
424 "steps_run": outcome.steps_run,
425 "survivors": outcome.survivors.len(),
426 "at": item.claimed_at,
427 }),
428 );
429 let action_intents = self.action_intents(item, outcome.actions)?;
430 let disposition = match schedule {
431 Some(decision) if action_intents.is_empty() => WorkDisposition::Reschedule {
432 at: decision.next_at,
433 },
434 Some(decision) => {
435 Self::set_internal(&mut next_state, "resume_at", json!(decision.next_at))?;
436 Self::idle_disposition()
437 }
438 None if action_intents.is_empty() && consumed_static_event => WorkDisposition::Complete,
439 None => Self::idle_disposition(),
440 };
441 if !action_intents.is_empty() {
442 let internal = next_state
443 .entry("__workflow")
444 .or_insert_with(|| json!({}))
445 .as_object_mut()
446 .ok_or_else(|| "workflow internal state must be an object".to_string())?;
447 let pending = internal
448 .entry("pending_actions")
449 .or_insert_with(|| json!([]))
450 .as_array_mut()
451 .ok_or_else(|| "workflow pending actions must be an array".to_string())?;
452 pending.extend(action_intents.iter().map(|intent| json!(intent.id)));
453 }
454 let evaluation = EvaluationOutcome {
455 triggered: true,
456 matched: outcome.matched,
457 succeeded: outcome.succeeded && action_intents.is_empty(),
458 action_terminal: false,
459 };
460 Ok(command(
461 item,
462 Value::Object(next_state),
463 action_intents,
464 evaluation,
465 disposition,
466 "workflow.spec_evaluated",
467 ))
468 }
469}
470
471fn command(
472 item: &WorkItem,
473 next_state: Value,
474 action_intents: Vec<ActionIntent>,
475 outcome: EvaluationOutcome,
476 disposition: WorkDisposition,
477 event_type: &str,
478) -> WorkflowTransitionCommand {
479 WorkflowTransitionCommand {
480 delivery_key: format!("spec:{}:{}", item.id, item.state_version),
481 delivery_digest: format!(
482 "{}:{}:{}",
483 item.workflow_revision_digest, item.execution_profile_digest, item.state_version
484 ),
485 event_type: event_type.into(),
486 event_digest: format!("{event_type}:{}:{}", item.id, item.state_version),
487 event_payload: json!({ "spec_id": item.spec_id, "wakeups": item.wakeups.len() }),
488 next_state,
489 action_intents,
490 outcome,
491 disposition,
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::{ControlEpochs, DriverRegistry, PreparedAction, StepNode, StepResult, Wakeup};
499
500 struct PassNode;
501
502 #[async_trait::async_trait]
503 impl StepNode for PassNode {
504 async fn process(&self, event: &Event, _: &crate::WorkflowContext) -> StepResult {
505 StepResult::Pass(event.clone())
506 }
507 }
508
509 struct ActionNode;
510
511 #[async_trait::async_trait]
512 impl StepNode for ActionNode {
513 async fn process(&self, event: &Event, _: &crate::WorkflowContext) -> StepResult {
514 StepResult::Action {
515 event: event.clone(),
516 action: Box::new(PreparedAction::new(
517 "execute.demo".parse().unwrap(),
518 "request-1",
519 json!({"value": 1}),
520 )),
521 }
522 }
523 }
524
525 fn item(spec_id: &str, config: Value) -> WorkItem {
526 WorkItem {
527 id: "instance".into(),
528 run_id: uuid::Uuid::new_v4().to_string().parse().unwrap(),
529 tenant_id: "tenant".parse().unwrap(),
530 subject_id: "subject".parse().unwrap(),
531 spec_id: spec_id.into(),
532 definition_id: spec_id.into(),
533 workflow_revision: 1,
534 workflow_revision_digest: "digest".into(),
535 execution_profile_id: "profile".into(),
536 execution_profile_revision: 1,
537 execution_profile_digest: "profile-digest".into(),
538 kernel_abi_version: "1".into(),
539 capability_pins: Vec::new(),
540 lifecycle: crate::LifecyclePolicy::run_once(),
541 scheduled_at: chrono::Utc::now(),
542 claimed_at: chrono::Utc::now(),
543 config,
544 state_version: 0,
545 control_epochs: ControlEpochs::default(),
546 cancel_requested: false,
547 lease_version: 1,
548 wakeups: Vec::new(),
549 }
550 }
551
552 fn spec(ingress: &str, ingress_config: Value) -> Spec {
553 Spec::from_json(
554 &json!({
555 "spec_id": "counter", "version": "1",
556 "branches": [{
557 "branch_id": "__root__",
558 "nodes": [
559 {"id": "in", "type": ingress, "config": ingress_config},
560 {"id": "count", "type": "transform.state_append",
561 "config": {"key": "seen", "path": "value", "max_len": 3}}
562 ],
563 "edges": [{"source": "in", "target": "count"}]
564 }]
565 })
566 .to_string(),
567 )
568 .unwrap()
569 }
570
571 #[tokio::test]
572 async fn cron_spec_reschedules_and_persists_branch_state() {
573 let registry = NodeRegistry::with_builtins();
574 let driver = SpecDriver::new(
575 &spec("ingress.cron", json!({"expression": "0 0 * * * *"})),
576 ®istry,
577 )
578 .unwrap();
579 let first = WorkflowDriver::<()>::evaluate(
580 &driver,
581 &(),
582 &item("counter", json!({"event": {"value": 1}})),
583 )
584 .await
585 .unwrap();
586 let WorkDisposition::Reschedule { at } = first.disposition else {
587 panic!("cron spec must reschedule");
588 };
589 assert_eq!(first.next_state["last_run"]["terminal"], "completed");
590 let mut next = item("counter", first.next_state);
591 next.scheduled_at = at;
592 next.claimed_at = at;
593 let second = WorkflowDriver::<()>::evaluate(&driver, &(), &next)
594 .await
595 .unwrap();
596 assert_eq!(
597 second.next_state["state"]["__root__.seen"],
598 json!([1]),
599 "the consumed bootstrap event must not be replayed on a cron tick"
600 );
601 assert_eq!(second.next_state["last_run"]["at"], json!(at));
602 }
603
604 #[tokio::test]
605 async fn event_spec_consumes_a_wakeup_then_waits() {
606 let registry = NodeRegistry::with_builtins();
607 let driver = SpecDriver::new(&spec("ingress.event", json!({})), ®istry).unwrap();
608 assert_eq!(
609 WorkflowDriver::<()>::evaluate(&driver, &(), &item("counter", json!({})))
610 .await
611 .unwrap_err(),
612 "event workflow requires a trigger delivery"
613 );
614 let mut work = item("counter", json!({}));
615 work.wakeups.push(Wakeup {
616 id: "delivery".into(),
617 kind: "delivery".into(),
618 payload: json!({"value": 7}),
619 });
620 let command = WorkflowDriver::<()>::evaluate(&driver, &(), &work)
621 .await
622 .unwrap();
623 assert_eq!(command.next_state["state"]["__root__.seen"], json!([7]));
624 assert!(matches!(
625 command.disposition,
626 WorkDisposition::Continue { .. }
627 ));
628 let mut registry = DriverRegistry::<()>::new();
629 registry.register(Arc::new(driver)).unwrap();
630 assert_eq!(registry.spec_ids(), ["counter"]);
631 }
632
633 #[tokio::test]
634 async fn effectful_steps_are_lifted_into_pinned_intents() {
635 let mut registry = NodeRegistry::with_builtins();
636 registry.register_step("guard.auth", |_| Ok(Box::new(PassNode)));
637 registry.register_side_effect_guard("guard.auth");
638 registry.register_step("execute.demo", |_| Ok(Box::new(ActionNode)));
639 let manifest = CapabilityManifest::action(
640 "execute.demo",
641 "1",
642 "demo-digest",
643 crate::Effect::ExternalWrite,
644 crate::IdempotencyMode::Native,
645 true,
646 );
647 registry.register_capability(manifest.clone()).unwrap();
648 let effect = Spec::from_json(
649 &json!({
650 "spec_id": "effect", "version": "1",
651 "branches": [{
652 "branch_id": "__root__",
653 "nodes": [
654 {"id": "in", "type": "ingress.event", "config": {}},
655 {"id": "auth", "type": "guard.auth", "config": {}},
656 {"id": "do", "type": "execute.demo", "config": {}}
657 ],
658 "edges": [
659 {"source": "in", "target": "auth"},
660 {"source": "auth", "target": "do"}
661 ]
662 }]
663 })
664 .to_string(),
665 )
666 .unwrap();
667 let driver = SpecDriver::new(&effect, ®istry).unwrap();
668 let mut work = item("effect", json!({"event": {"value": 1}}));
669 work.capability_pins.push(crate::CapabilityPin {
670 id: manifest.id,
671 contract_version: manifest.contract_version,
672 content_digest: manifest.content_digest,
673 });
674 let command = WorkflowDriver::<()>::evaluate(&driver, &(), &work)
675 .await
676 .unwrap();
677 assert_eq!(command.action_intents.len(), 1);
678 assert_eq!(command.action_intents[0].state, ActionState::Prepared);
679 assert_eq!(command.action_intents[0].capability.id, "execute.demo");
680 assert_eq!(command.action_intents[0].deadline, None);
681
682 let mut waiting = item("effect", command.next_state.clone());
683 waiting.capability_pins = work.capability_pins.clone();
684 let waiting_command = WorkflowDriver::<()>::evaluate(&driver, &(), &waiting)
685 .await
686 .unwrap();
687 assert!(waiting_command.action_intents.is_empty());
688 assert_eq!(waiting_command.event_type, "workflow.action_waiting");
689
690 waiting.wakeups.push(Wakeup {
691 id: "terminal".into(),
692 kind: "timer".into(),
693 payload: json!({
694 "kind": "terminal_action",
695 "observation": {
696 "id": "observation",
697 "action_intent_id": command.action_intents[0].id,
698 "provider_version": "1",
699 "observed_at": chrono::Utc::now(),
700 "state": "rejected",
701 "resource_ref": null,
702 "raw_receipt_digest": "receipt",
703 "terminal": true,
704 "retry_authorized": false
705 }
706 }),
707 });
708 let terminal = WorkflowDriver::<()>::evaluate(&driver, &(), &waiting)
709 .await
710 .unwrap();
711 assert!(terminal.action_intents.is_empty());
712 assert!(matches!(terminal.disposition, WorkDisposition::Complete));
713 assert!(matches!(
714 SpecDriver::new(&spec("ingress.cron", json!({})), ®istry),
715 Err(HostError::Schedule { .. })
716 ));
717 }
718
719 #[tokio::test]
720 async fn delivery_does_not_advance_the_cron_cursor() {
721 let registry = NodeRegistry::with_builtins();
722 let driver = SpecDriver::new(
723 &spec("ingress.cron", json!({"expression": "0 0 * * * *"})),
724 ®istry,
725 )
726 .unwrap();
727 let mut work = item("counter", json!({}));
728 work.scheduled_at = work.claimed_at + chrono::Duration::hours(1);
729 work.wakeups.push(Wakeup {
730 id: "delivery".into(),
731 kind: "delivery".into(),
732 payload: json!({"value": 7}),
733 });
734 let command = WorkflowDriver::<()>::evaluate(&driver, &(), &work)
735 .await
736 .unwrap();
737 assert_eq!(command.next_state["state"]["__root__.seen"], json!([7]));
738 assert!(matches!(
739 command.disposition,
740 WorkDisposition::Reschedule { at } if at == work.scheduled_at
741 ));
742 }
743}