@hasna/loops 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sdk/index.js CHANGED
@@ -345,6 +345,7 @@ function rowToLoop(row) {
345
345
  status: row.status,
346
346
  schedule: JSON.parse(row.schedule_json),
347
347
  target: JSON.parse(row.target_json),
348
+ machine: row.machine_json ? JSON.parse(row.machine_json) : undefined,
348
349
  nextRunAt: row.next_run_at ?? undefined,
349
350
  retryScheduledFor: row.retry_scheduled_for ?? undefined,
350
351
  catchUp: row.catch_up,
@@ -487,6 +488,7 @@ class Store {
487
488
  status TEXT NOT NULL,
488
489
  schedule_json TEXT NOT NULL,
489
490
  target_json TEXT NOT NULL,
491
+ machine_json TEXT,
490
492
  next_run_at TEXT,
491
493
  retry_scheduled_for TEXT,
492
494
  catch_up TEXT NOT NULL,
@@ -608,10 +610,21 @@ class Store {
608
610
  );
609
611
  CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
610
612
  `);
613
+ try {
614
+ this.db.query("ALTER TABLE loops ADD COLUMN machine_json TEXT").run();
615
+ } catch {}
611
616
  try {
612
617
  this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
613
618
  } catch {}
614
619
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
620
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
621
+ }
622
+ assertDaemonLeaseFence(opts = {}, now = nowIso()) {
623
+ if (!opts.daemonLeaseId)
624
+ return;
625
+ const row = this.db.query("SELECT id FROM daemon_lease WHERE id = ? AND expires_at > ?").get(opts.daemonLeaseId, now);
626
+ if (!row)
627
+ throw new Error("daemon lease lost");
615
628
  }
616
629
  createLoop(input, from = new Date) {
617
630
  const now = nowIso();
@@ -622,6 +635,7 @@ class Store {
622
635
  status: "active",
623
636
  schedule: input.schedule,
624
637
  target: input.target,
638
+ machine: input.machine,
625
639
  nextRunAt: initialNextRun(input.schedule, from),
626
640
  catchUp: input.catchUp ?? "latest",
627
641
  catchUpLimit: input.catchUpLimit ?? 50,
@@ -633,9 +647,9 @@ class Store {
633
647
  createdAt: now,
634
648
  updatedAt: now
635
649
  };
636
- this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, next_run_at, retry_scheduled_for,
650
+ this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, machine_json, next_run_at, retry_scheduled_for,
637
651
  catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
638
- VALUES ($id, $name, $description, $status, $schedule, $target, $nextRun, NULL, $catchUp, $catchUpLimit,
652
+ VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $catchUp, $catchUpLimit,
639
653
  $overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
640
654
  $id: loop.id,
641
655
  $name: loop.name,
@@ -643,6 +657,7 @@ class Store {
643
657
  $status: loop.status,
644
658
  $schedule: JSON.stringify(loop.schedule),
645
659
  $target: JSON.stringify(loop.target),
660
+ $machine: loop.machine ? JSON.stringify(loop.machine) : null,
646
661
  $nextRun: loop.nextRunAt ?? null,
647
662
  $catchUp: loop.catchUp,
648
663
  $catchUpLimit: loop.catchUpLimit,
@@ -682,21 +697,31 @@ class Store {
682
697
  ORDER BY next_run_at ASC`).all(now.toISOString());
683
698
  return rows.map(rowToLoop);
684
699
  }
685
- updateLoop(id, patch) {
700
+ updateLoop(id, patch, opts = {}) {
686
701
  const current = this.getLoop(id);
687
702
  if (!current)
688
703
  throw new Error(`loop not found: ${id}`);
689
- const merged = { ...current, ...patch, updatedAt: nowIso() };
704
+ const updated = (opts.now ?? new Date).toISOString();
705
+ const merged = { ...current, ...patch, updatedAt: updated };
690
706
  this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
691
- expires_at=$expiresAt, updated_at=$updated WHERE id=$id`).run({
707
+ expires_at=$expiresAt, updated_at=$updated
708
+ WHERE id=$id
709
+ AND ($daemonLeaseId IS NULL OR EXISTS (
710
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
711
+ ))`).run({
692
712
  $id: id,
693
713
  $status: merged.status,
694
714
  $nextRun: merged.nextRunAt ?? null,
695
715
  $retrySlot: merged.retryScheduledFor ?? null,
696
716
  $expiresAt: merged.expiresAt ?? null,
697
- $updated: merged.updatedAt
717
+ $updated: merged.updatedAt,
718
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
719
+ $now: updated
698
720
  });
699
- return merged;
721
+ const after = this.getLoop(id);
722
+ if (!after)
723
+ throw new Error(`loop not found after update: ${id}`);
724
+ return after;
700
725
  }
701
726
  deleteLoop(idOrName) {
702
727
  const loop = this.requireLoop(idOrName);
@@ -760,11 +785,14 @@ class Store {
760
785
  const now = nowIso();
761
786
  if (input.idempotencyKey) {
762
787
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
763
- if (existing)
788
+ if (existing) {
789
+ this.assertDaemonLeaseFence(input);
764
790
  return rowToWorkflowRun(existing);
791
+ }
765
792
  }
766
793
  this.db.exec("BEGIN IMMEDIATE");
767
794
  try {
795
+ this.assertDaemonLeaseFence(input, now);
768
796
  if (input.idempotencyKey) {
769
797
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
770
798
  if (existing) {
@@ -863,31 +891,60 @@ class Store {
863
891
  const run = this.getWorkflowRun(workflowRunId);
864
892
  return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
865
893
  }
866
- startWorkflowStepRun(workflowRunId, stepId) {
867
- const now = nowIso();
868
- const res = this.db.query(`UPDATE workflow_step_runs
869
- SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
870
- pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
871
- WHERE workflow_run_id=$workflowRunId
872
- AND step_id=$stepId
873
- AND status IN ('pending', 'failed', 'timed_out')
874
- AND EXISTS (
875
- SELECT 1 FROM workflow_runs
876
- WHERE id=$workflowRunId AND status='running'
877
- )`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
878
- const run = this.getWorkflowStepRun(workflowRunId, stepId);
879
- if (!run)
880
- throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
881
- if (res.changes !== 1) {
882
- throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
894
+ startWorkflowStepRun(workflowRunId, stepId, opts = {}) {
895
+ const now = (opts.now ?? new Date).toISOString();
896
+ this.db.exec("BEGIN IMMEDIATE");
897
+ try {
898
+ const res = this.db.query(`UPDATE workflow_step_runs
899
+ SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
900
+ pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
901
+ WHERE workflow_run_id=$workflowRunId
902
+ AND step_id=$stepId
903
+ AND status IN ('pending', 'failed', 'timed_out')
904
+ AND EXISTS (
905
+ SELECT 1 FROM workflow_runs
906
+ WHERE id=$workflowRunId AND status='running'
907
+ )
908
+ AND ($daemonLeaseId IS NULL OR EXISTS (
909
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
910
+ ))`).run({
911
+ $workflowRunId: workflowRunId,
912
+ $stepId: stepId,
913
+ $started: now,
914
+ $updated: now,
915
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
916
+ $now: now
917
+ });
918
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
919
+ if (!run)
920
+ throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
921
+ if (res.changes !== 1) {
922
+ throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
923
+ }
924
+ this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
925
+ this.db.exec("COMMIT");
926
+ return run;
927
+ } catch (error) {
928
+ try {
929
+ this.db.exec("ROLLBACK");
930
+ } catch {}
931
+ throw error;
883
932
  }
884
- this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
885
- return run;
886
933
  }
887
- markWorkflowStepPid(workflowRunId, stepId, pid) {
888
- const now = nowIso();
934
+ markWorkflowStepPid(workflowRunId, stepId, pid, opts = {}) {
935
+ const now = (opts.now ?? new Date).toISOString();
889
936
  this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
890
- WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $pid: pid, $updated: now });
937
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
938
+ AND ($daemonLeaseId IS NULL OR EXISTS (
939
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
940
+ ))`).run({
941
+ $workflowRunId: workflowRunId,
942
+ $stepId: stepId,
943
+ $pid: pid,
944
+ $updated: now,
945
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
946
+ $now: now
947
+ });
891
948
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
892
949
  if (!run)
893
950
  throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
@@ -915,60 +972,110 @@ class Store {
915
972
  recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
916
973
  };
917
974
  }
918
- finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
975
+ finalizeWorkflowStepRun(workflowRunId, stepId, patch, opts = {}) {
919
976
  const finishedAt = patch.finishedAt ?? nowIso();
920
- const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
921
- pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
922
- WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({
923
- $workflowRunId: workflowRunId,
924
- $stepId: stepId,
925
- $status: patch.status,
926
- $finished: finishedAt,
927
- $exitCode: patch.exitCode ?? null,
928
- $durationMs: patch.durationMs ?? null,
929
- $stdout: patch.stdout ?? null,
930
- $stderr: patch.stderr ?? null,
931
- $error: patch.error ?? null,
932
- $updated: finishedAt
933
- });
934
- if (res.changes === 1) {
935
- this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
936
- exitCode: patch.exitCode,
937
- error: patch.error
977
+ this.db.exec("BEGIN IMMEDIATE");
978
+ try {
979
+ const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
980
+ pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
981
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
982
+ AND ($daemonLeaseId IS NULL OR EXISTS (
983
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
984
+ ))`).run({
985
+ $workflowRunId: workflowRunId,
986
+ $stepId: stepId,
987
+ $status: patch.status,
988
+ $finished: finishedAt,
989
+ $exitCode: patch.exitCode ?? null,
990
+ $durationMs: patch.durationMs ?? null,
991
+ $stdout: patch.stdout ?? null,
992
+ $stderr: patch.stderr ?? null,
993
+ $error: patch.error ?? null,
994
+ $updated: finishedAt,
995
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
996
+ $now: (opts.now ?? new Date(finishedAt)).toISOString()
938
997
  });
998
+ if (res.changes === 1) {
999
+ this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
1000
+ exitCode: patch.exitCode,
1001
+ error: patch.error
1002
+ });
1003
+ }
1004
+ this.db.exec("COMMIT");
1005
+ } catch (error) {
1006
+ try {
1007
+ this.db.exec("ROLLBACK");
1008
+ } catch {}
1009
+ throw error;
939
1010
  }
940
1011
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
941
1012
  if (!run)
942
1013
  throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
943
1014
  return run;
944
1015
  }
945
- skipWorkflowStepRun(workflowRunId, stepId, reason) {
946
- const now = nowIso();
947
- const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
948
- WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $finished: now, $error: reason, $updated: now });
949
- if (res.changes === 1)
950
- this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
1016
+ skipWorkflowStepRun(workflowRunId, stepId, reason, opts = {}) {
1017
+ const now = (opts.now ?? new Date).toISOString();
1018
+ this.db.exec("BEGIN IMMEDIATE");
1019
+ try {
1020
+ const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
1021
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')
1022
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1023
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1024
+ ))`).run({
1025
+ $workflowRunId: workflowRunId,
1026
+ $stepId: stepId,
1027
+ $finished: now,
1028
+ $error: reason,
1029
+ $updated: now,
1030
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1031
+ $now: now
1032
+ });
1033
+ if (res.changes === 1)
1034
+ this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
1035
+ this.db.exec("COMMIT");
1036
+ } catch (error) {
1037
+ try {
1038
+ this.db.exec("ROLLBACK");
1039
+ } catch {}
1040
+ throw error;
1041
+ }
951
1042
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
952
1043
  if (!run)
953
1044
  throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
954
1045
  return run;
955
1046
  }
956
- finalizeWorkflowRun(workflowRunId, status, patch = {}) {
1047
+ finalizeWorkflowRun(workflowRunId, status, patch = {}, opts = {}) {
957
1048
  const finishedAt = patch.finishedAt ?? nowIso();
958
- const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
959
- WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({
960
- $id: workflowRunId,
961
- $status: status,
962
- $finished: finishedAt,
963
- $durationMs: patch.durationMs ?? null,
964
- $error: patch.error ?? null,
965
- $updated: finishedAt
966
- });
1049
+ let changed = false;
1050
+ this.db.exec("BEGIN IMMEDIATE");
1051
+ try {
1052
+ const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
1053
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
1054
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1055
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1056
+ ))`).run({
1057
+ $id: workflowRunId,
1058
+ $status: status,
1059
+ $finished: finishedAt,
1060
+ $durationMs: patch.durationMs ?? null,
1061
+ $error: patch.error ?? null,
1062
+ $updated: finishedAt,
1063
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1064
+ $now: (opts.now ?? new Date(finishedAt)).toISOString()
1065
+ });
1066
+ changed = res.changes === 1;
1067
+ if (changed)
1068
+ this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
1069
+ this.db.exec("COMMIT");
1070
+ } catch (error) {
1071
+ try {
1072
+ this.db.exec("ROLLBACK");
1073
+ } catch {}
1074
+ throw error;
1075
+ }
967
1076
  const run = this.getWorkflowRun(workflowRunId);
968
1077
  if (!run)
969
1078
  throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
970
- if (res.changes === 1)
971
- this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
972
1079
  return run;
973
1080
  }
974
1081
  cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
@@ -1022,10 +1129,24 @@ class Store {
1022
1129
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
1023
1130
  return (row?.count ?? 0) > 0;
1024
1131
  }
1025
- markRunPid(id, pid, claimedBy) {
1026
- const now = nowIso();
1132
+ markRunPid(id, pid, claimedBy, opts = {}) {
1133
+ const now = (opts.now ?? new Date).toISOString();
1027
1134
  const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
1028
- WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $pid: pid, $updated: now, $claimedBy: claimedBy }) : this.db.query("UPDATE loop_runs SET pid=$pid, updated_at=$updated WHERE id=$id AND status='running'").run({ $id: id, $pid: pid, $updated: now });
1135
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy
1136
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1137
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1138
+ ))`).run({
1139
+ $id: id,
1140
+ $pid: pid,
1141
+ $updated: now,
1142
+ $claimedBy: claimedBy,
1143
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1144
+ $now: now
1145
+ }) : this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
1146
+ WHERE id=$id AND status='running'
1147
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1148
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1149
+ ))`).run({ $id: id, $pid: pid, $updated: now, $daemonLeaseId: opts.daemonLeaseId ?? null, $now: now });
1029
1150
  if (res.changes !== 1)
1030
1151
  return;
1031
1152
  return this.getRun(id);
@@ -1040,7 +1161,7 @@ class Store {
1040
1161
  AND wsr.pid IS NOT NULL`).all(loopRunId);
1041
1162
  return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
1042
1163
  }
1043
- createSkippedRun(loop, scheduledFor, reason) {
1164
+ createSkippedRun(loop, scheduledFor, reason, opts = {}) {
1044
1165
  const now = nowIso();
1045
1166
  const run = {
1046
1167
  id: genId(),
@@ -1054,21 +1175,31 @@ class Store {
1054
1175
  createdAt: now,
1055
1176
  updatedAt: now
1056
1177
  };
1057
- this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
1058
- claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
1059
- VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
1060
- NULL, NULL, $error, $created, $updated)`).run({
1061
- $id: run.id,
1062
- $loopId: run.loopId,
1063
- $loopName: run.loopName,
1064
- $scheduledFor: run.scheduledFor,
1065
- $attempt: run.attempt,
1066
- $status: run.status,
1067
- $finished: run.finishedAt ?? null,
1068
- $error: run.error ?? null,
1069
- $created: run.createdAt,
1070
- $updated: run.updatedAt
1071
- });
1178
+ this.db.exec("BEGIN IMMEDIATE");
1179
+ try {
1180
+ this.assertDaemonLeaseFence(opts, now);
1181
+ this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
1182
+ claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
1183
+ VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
1184
+ NULL, NULL, $error, $created, $updated)`).run({
1185
+ $id: run.id,
1186
+ $loopId: run.loopId,
1187
+ $loopName: run.loopName,
1188
+ $scheduledFor: run.scheduledFor,
1189
+ $attempt: run.attempt,
1190
+ $status: run.status,
1191
+ $finished: run.finishedAt ?? null,
1192
+ $error: run.error ?? null,
1193
+ $created: run.createdAt,
1194
+ $updated: run.updatedAt
1195
+ });
1196
+ this.db.exec("COMMIT");
1197
+ } catch (error) {
1198
+ try {
1199
+ this.db.exec("ROLLBACK");
1200
+ } catch {}
1201
+ throw error;
1202
+ }
1072
1203
  return this.getRunBySlot(loop.id, scheduledFor) ?? run;
1073
1204
  }
1074
1205
  getRun(id) {
@@ -1079,11 +1210,20 @@ class Store {
1079
1210
  const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
1080
1211
  return row ? rowToRun(row) : undefined;
1081
1212
  }
1082
- claimRun(loop, scheduledFor, runnerId, now = new Date) {
1213
+ nextRetryableRun(loopId, maxAttempts, afterScheduledFor) {
1214
+ const row = afterScheduledFor ? this.db.query(`SELECT * FROM loop_runs
1215
+ WHERE loop_id = ? AND scheduled_for > ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
1216
+ ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, afterScheduledFor, maxAttempts) : this.db.query(`SELECT * FROM loop_runs
1217
+ WHERE loop_id = ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
1218
+ ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, maxAttempts);
1219
+ return row ? rowToRun(row) : undefined;
1220
+ }
1221
+ claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
1083
1222
  const startedAt = now.toISOString();
1084
1223
  const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1085
1224
  this.db.exec("BEGIN IMMEDIATE");
1086
1225
  try {
1226
+ this.assertDaemonLeaseFence(opts, startedAt);
1087
1227
  const existing = this.getRunBySlot(loop.id, scheduledFor);
1088
1228
  if (existing) {
1089
1229
  if (existing.status === "running") {
@@ -1178,11 +1318,15 @@ class Store {
1178
1318
  $error: patch.error ?? null,
1179
1319
  $updated: finishedAt,
1180
1320
  $claimedBy: opts.claimedBy ?? null,
1181
- $now: (opts.now ?? new Date).toISOString()
1321
+ $now: (opts.now ?? new Date).toISOString(),
1322
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1182
1323
  };
1183
1324
  const res = opts.claimedBy ? this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
1184
1325
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
1185
- WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
1326
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
1327
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1328
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1329
+ ))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
1186
1330
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
1187
1331
  const run = this.getRun(id);
1188
1332
  if (!run)
@@ -1191,10 +1335,20 @@ class Store {
1191
1335
  return run;
1192
1336
  return run;
1193
1337
  }
1194
- heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
1338
+ heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
1195
1339
  const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
1196
1340
  const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
1197
- WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $claimedBy: claimedBy, $expires: expiresAt, $updated: now.toISOString() });
1341
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
1342
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1343
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1344
+ ))`).run({
1345
+ $id: id,
1346
+ $claimedBy: claimedBy,
1347
+ $expires: expiresAt,
1348
+ $updated: now.toISOString(),
1349
+ $now: now.toISOString(),
1350
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1351
+ });
1198
1352
  if (res.changes !== 1)
1199
1353
  return;
1200
1354
  return this.getRun(id);
@@ -1213,7 +1367,7 @@ class Store {
1213
1367
  }
1214
1368
  return rows.map(rowToRun);
1215
1369
  }
1216
- recoverExpiredRunLeases(now = new Date) {
1370
+ recoverExpiredRunLeases(now = new Date, opts = {}) {
1217
1371
  const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
1218
1372
  const recovered = [];
1219
1373
  for (const row of rows) {
@@ -1222,20 +1376,63 @@ class Store {
1222
1376
  if (this.hasLiveWorkflowStepProcesses(row.id))
1223
1377
  continue;
1224
1378
  const finished = now.toISOString();
1225
- this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
1226
- error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: finished, $updated: finished });
1227
- const workflowRows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')").all(row.id);
1228
- for (const workflowRow of workflowRows) {
1229
- this.db.query(`UPDATE workflow_runs
1230
- SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
1231
- WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRow.id, $finished: finished, $updated: finished });
1232
- this.db.query(`UPDATE workflow_step_runs
1233
- SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
1234
- WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRow.id, $finished: finished, $updated: finished });
1235
- this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
1236
- error: "parent loop run lease expired before completion",
1237
- loopRunId: row.id
1379
+ this.db.exec("BEGIN IMMEDIATE");
1380
+ try {
1381
+ const res = this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
1382
+ error='run lease expired before completion', updated_at=$updated
1383
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
1384
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1385
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1386
+ ))`).run({
1387
+ $id: row.id,
1388
+ $finished: finished,
1389
+ $updated: finished,
1390
+ $now: finished,
1391
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1238
1392
  });
1393
+ if (res.changes !== 1) {
1394
+ this.db.exec("COMMIT");
1395
+ continue;
1396
+ }
1397
+ const workflowRows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')").all(row.id);
1398
+ for (const workflowRow of workflowRows) {
1399
+ const workflowRes = this.db.query(`UPDATE workflow_runs
1400
+ SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
1401
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
1402
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1403
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1404
+ ))`).run({
1405
+ $id: workflowRow.id,
1406
+ $finished: finished,
1407
+ $updated: finished,
1408
+ $now: finished,
1409
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1410
+ });
1411
+ if (workflowRes.changes !== 1)
1412
+ continue;
1413
+ this.db.query(`UPDATE workflow_step_runs
1414
+ SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
1415
+ WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')
1416
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1417
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1418
+ ))`).run({
1419
+ $workflowRunId: workflowRow.id,
1420
+ $finished: finished,
1421
+ $updated: finished,
1422
+ $now: finished,
1423
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1424
+ });
1425
+ this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
1426
+ error: "parent loop run lease expired before completion",
1427
+ loopRunId: row.id
1428
+ });
1429
+ }
1430
+ this.db.exec("COMMIT");
1431
+ } catch (error) {
1432
+ try {
1433
+ this.db.exec("ROLLBACK");
1434
+ } catch {}
1435
+ throw error;
1239
1436
  }
1240
1437
  const run = this.getRun(row.id);
1241
1438
  if (run)
@@ -1243,11 +1440,14 @@ class Store {
1243
1440
  }
1244
1441
  return recovered;
1245
1442
  }
1246
- expireLoops(now = new Date) {
1443
+ expireLoops(now = new Date, opts = {}) {
1247
1444
  const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
1248
1445
  const expired = [];
1249
- for (const row of rows)
1250
- expired.push(this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }));
1446
+ for (const row of rows) {
1447
+ const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
1448
+ if (updated.status === "expired")
1449
+ expired.push(updated);
1450
+ }
1251
1451
  return expired;
1252
1452
  }
1253
1453
  countLoops(status) {
@@ -1290,7 +1490,7 @@ class Store {
1290
1490
  }
1291
1491
  heartbeatDaemonLease(id, ttlMs, now = new Date) {
1292
1492
  const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
1293
- const res = this.db.query(`UPDATE daemon_lease SET heartbeat_at=$heartbeat, expires_at=$expires, updated_at=$updated WHERE id=$id`).run({ $id: id, $heartbeat: now.toISOString(), $expires: expiresAt, $updated: now.toISOString() });
1493
+ const res = this.db.query(`UPDATE daemon_lease SET heartbeat_at=$heartbeat, expires_at=$expires, updated_at=$updated WHERE id=$id AND expires_at > $now`).run({ $id: id, $heartbeat: now.toISOString(), $expires: expiresAt, $updated: now.toISOString(), $now: now.toISOString() });
1294
1494
  if (res.changes !== 1)
1295
1495
  return;
1296
1496
  return this.getDaemonLease();
@@ -1308,8 +1508,9 @@ class Store {
1308
1508
  }
1309
1509
 
1310
1510
  // src/lib/executor.ts
1311
- import { spawn } from "child_process";
1511
+ import { spawn, spawnSync as spawnSync2 } from "child_process";
1312
1512
  import { once } from "events";
1513
+ import { resolveMachineCommand } from "@hasna/machines/consumer";
1313
1514
 
1314
1515
  // src/lib/accounts.ts
1315
1516
  import { spawnSync } from "child_process";
@@ -1466,6 +1667,59 @@ function commandNotFoundMessage(command, env = process.env) {
1466
1667
  return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
1467
1668
  }
1468
1669
 
1670
+ // src/lib/machines.ts
1671
+ import {
1672
+ discoverMachineTopology,
1673
+ resolveMachineRoute
1674
+ } from "@hasna/machines/consumer";
1675
+ function compact(value) {
1676
+ const text = value?.trim();
1677
+ return text ? text : undefined;
1678
+ }
1679
+ function entryToSummary(entry, topology) {
1680
+ return {
1681
+ id: entry.machine_id,
1682
+ hostname: compact(entry.hostname),
1683
+ platform: compact(entry.platform),
1684
+ user: compact(entry.user),
1685
+ workspacePath: compact(entry.workspace_path),
1686
+ route: entry.ssh.route,
1687
+ local: entry.machine_id === topology.local_machine_id || entry.ssh.route === "local",
1688
+ heartbeatStatus: entry.heartbeat_status,
1689
+ tailscaleOnline: entry.tailscale.online,
1690
+ tags: entry.tags
1691
+ };
1692
+ }
1693
+ function machineFromRoute(route, topology) {
1694
+ if (!route.ok || !route.machine_id) {
1695
+ throw new Error(`OpenMachines route not found for machine: ${route.requested_machine_id}`);
1696
+ }
1697
+ const entry = topology.machines.find((machine) => machine.machine_id === route.machine_id);
1698
+ return {
1699
+ id: route.machine_id,
1700
+ requestedId: route.requested_machine_id !== route.machine_id ? route.requested_machine_id : undefined,
1701
+ route: route.route,
1702
+ local: route.local,
1703
+ confidence: route.confidence,
1704
+ workspacePath: compact(entry?.workspace_path),
1705
+ resolvedAt: route.generated_at,
1706
+ packageVersion: route.package.version,
1707
+ warnings: route.warnings.length ? route.warnings : undefined
1708
+ };
1709
+ }
1710
+ function listOpenMachines() {
1711
+ const topology = discoverMachineTopology();
1712
+ return topology.machines.map((entry) => entryToSummary(entry, topology));
1713
+ }
1714
+ function resolveLoopMachine(machineId) {
1715
+ const topology = discoverMachineTopology();
1716
+ const route = resolveMachineRoute(machineId, { topology });
1717
+ return machineFromRoute(route, topology);
1718
+ }
1719
+ function refreshLoopMachine(machine) {
1720
+ return resolveLoopMachine(machine.id);
1721
+ }
1722
+
1469
1723
  // src/lib/executor.ts
1470
1724
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
1471
1725
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
@@ -1486,6 +1740,23 @@ var AUTH_ENV_KEYS = [
1486
1740
  "XDG_STATE_HOME",
1487
1741
  "XDG_CACHE_HOME"
1488
1742
  ];
1743
+ var TRANSPORT_ENV_KEYS = new Set([
1744
+ "BUN_INSTALL",
1745
+ "HOME",
1746
+ "LANG",
1747
+ "LANGUAGE",
1748
+ "LOGNAME",
1749
+ "PATH",
1750
+ "SHELL",
1751
+ "SSH_AGENT_PID",
1752
+ "SSH_AUTH_SOCK",
1753
+ "TERM",
1754
+ "TMP",
1755
+ "TMPDIR",
1756
+ "TEMP",
1757
+ "USER",
1758
+ "XDG_RUNTIME_DIR"
1759
+ ]);
1489
1760
  function appendBounded(current, chunk, maxBytes) {
1490
1761
  const next = current + chunk.toString("utf8");
1491
1762
  if (Buffer.byteLength(next, "utf8") <= maxBytes)
@@ -1512,6 +1783,29 @@ function killProcessGroup(pid) {
1512
1783
  }
1513
1784
  }, 2000).unref();
1514
1785
  }
1786
+ function shellQuote(value) {
1787
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1788
+ }
1789
+ function metadataEnv(metadata) {
1790
+ const env = {};
1791
+ if (metadata.loopId)
1792
+ env.LOOPS_LOOP_ID = metadata.loopId;
1793
+ if (metadata.loopName)
1794
+ env.LOOPS_LOOP_NAME = metadata.loopName;
1795
+ if (metadata.runId)
1796
+ env.LOOPS_RUN_ID = metadata.runId;
1797
+ if (metadata.scheduledFor)
1798
+ env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
1799
+ if (metadata.workflowId)
1800
+ env.LOOPS_WORKFLOW_ID = metadata.workflowId;
1801
+ if (metadata.workflowName)
1802
+ env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
1803
+ if (metadata.workflowRunId)
1804
+ env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1805
+ if (metadata.workflowStepId)
1806
+ env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1807
+ return env;
1808
+ }
1515
1809
  function providerCommand(provider) {
1516
1810
  switch (provider) {
1517
1811
  case "claude":
@@ -1540,7 +1834,7 @@ function agentArgs(target) {
1540
1834
  args.push("--model", target.model);
1541
1835
  if (target.agent)
1542
1836
  args.push("--agent", target.agent);
1543
- args.push(...target.extraArgs ?? [], target.prompt);
1837
+ args.push(...target.extraArgs ?? []);
1544
1838
  return args;
1545
1839
  case "cursor":
1546
1840
  args.push("-p");
@@ -1548,7 +1842,7 @@ function agentArgs(target) {
1548
1842
  args.push("--model", target.model);
1549
1843
  if (target.agent)
1550
1844
  args.push("--agent", target.agent);
1551
- args.push(...target.extraArgs ?? [], target.prompt);
1845
+ args.push(...target.extraArgs ?? []);
1552
1846
  return args;
1553
1847
  case "codewith":
1554
1848
  args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
@@ -1560,7 +1854,7 @@ function agentArgs(target) {
1560
1854
  args.push("--model", target.model);
1561
1855
  if (target.agent)
1562
1856
  args.push("--agent", target.agent);
1563
- args.push(...target.extraArgs ?? [], target.prompt);
1857
+ args.push(...target.extraArgs ?? []);
1564
1858
  return args;
1565
1859
  case "codex":
1566
1860
  args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
@@ -1570,7 +1864,7 @@ function agentArgs(target) {
1570
1864
  args.push("--cd", target.cwd);
1571
1865
  if (target.model)
1572
1866
  args.push("--model", target.model);
1573
- args.push(...target.extraArgs ?? [], target.prompt);
1867
+ args.push(...target.extraArgs ?? []);
1574
1868
  return args;
1575
1869
  case "aicopilot":
1576
1870
  args.push("run", "--format", "json");
@@ -1582,7 +1876,7 @@ function agentArgs(target) {
1582
1876
  args.push("--model", target.model);
1583
1877
  if (target.agent)
1584
1878
  args.push("--agent", target.agent);
1585
- args.push(...target.extraArgs ?? [], target.prompt);
1879
+ args.push(...target.extraArgs ?? []);
1586
1880
  return args;
1587
1881
  case "opencode":
1588
1882
  args.push("run", "--format", "json");
@@ -1594,7 +1888,7 @@ function agentArgs(target) {
1594
1888
  args.push("--model", target.model);
1595
1889
  if (target.agent)
1596
1890
  args.push("--agent", target.agent);
1597
- args.push(...target.extraArgs ?? [], target.prompt);
1891
+ args.push(...target.extraArgs ?? []);
1598
1892
  return args;
1599
1893
  }
1600
1894
  }
@@ -1619,7 +1913,8 @@ function commandSpec(target) {
1619
1913
  cwd: agentTarget.cwd,
1620
1914
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1621
1915
  account: agentTarget.account,
1622
- accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
1916
+ accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
1917
+ stdin: agentTarget.prompt
1623
1918
  };
1624
1919
  }
1625
1920
  function executionEnv(spec, metadata, opts) {
@@ -1632,26 +1927,213 @@ function executionEnv(spec, metadata, opts) {
1632
1927
  }
1633
1928
  Object.assign(env, spec.env ?? {});
1634
1929
  env.PATH = normalizeExecutionPath(env);
1635
- if (metadata.loopId)
1636
- env.LOOPS_LOOP_ID = metadata.loopId;
1637
- if (metadata.loopName)
1638
- env.LOOPS_LOOP_NAME = metadata.loopName;
1639
- if (metadata.runId)
1640
- env.LOOPS_RUN_ID = metadata.runId;
1641
- if (metadata.scheduledFor)
1642
- env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
1643
- if (metadata.workflowId)
1644
- env.LOOPS_WORKFLOW_ID = metadata.workflowId;
1645
- if (metadata.workflowName)
1646
- env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
1647
- if (metadata.workflowRunId)
1648
- env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1649
- if (metadata.workflowStepId)
1650
- env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1930
+ Object.assign(env, metadataEnv(metadata));
1651
1931
  return env;
1652
1932
  }
1933
+ function resolvedMachine(opts) {
1934
+ if (!opts.machine)
1935
+ return;
1936
+ return (opts.machineResolver ?? refreshLoopMachine)(opts.machine);
1937
+ }
1938
+ function commandForShell(spec) {
1939
+ if (!spec.args.length)
1940
+ return spec.command;
1941
+ return [spec.command, ...spec.args.map(shellQuote)].join(" ");
1942
+ }
1943
+ function hereDoc(value) {
1944
+ let delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
1945
+ while (value.split(/\r?\n/).includes(delimiter2)) {
1946
+ delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
1947
+ }
1948
+ return [`cat > "$__OPENLOOPS_STDIN" <<'${delimiter2}'`, value, delimiter2];
1949
+ }
1950
+ function remoteBootstrapLines(spec, metadata) {
1951
+ const lines = [
1952
+ "set -e",
1953
+ 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$HOME/.cargo/bin:$HOME/.npm-global/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin${PATH:+:$PATH}"'
1954
+ ];
1955
+ if (spec.cwd)
1956
+ lines.push(`cd ${shellQuote(spec.cwd)}`);
1957
+ if (spec.account) {
1958
+ if (!spec.accountTool)
1959
+ throw new Error("account.tool is required when no provider tool can be inferred");
1960
+ lines.push("if ! command -v accounts >/dev/null 2>&1; then echo 'accounts CLI is not available on remote machine' >&2; exit 127; fi", `unset ${AUTH_ENV_KEYS.join(" ")}`, `eval "$(accounts env ${shellQuote(spec.account.profile)} --tool ${shellQuote(spec.accountTool)})"`, `export LOOPS_ACCOUNT_PROFILE=${shellQuote(spec.account.profile)}`, `export LOOPS_ACCOUNT_TOOL=${shellQuote(spec.accountTool)}`);
1961
+ }
1962
+ for (const [key, value] of Object.entries({ ...metadataEnv(metadata), ...spec.env ?? {} })) {
1963
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
1964
+ continue;
1965
+ lines.push(`export ${key}=${shellQuote(value)}`);
1966
+ }
1967
+ return lines;
1968
+ }
1969
+ function remoteScript(spec, metadata) {
1970
+ const lines = remoteBootstrapLines(spec, metadata);
1971
+ let stdinRedirect = "";
1972
+ if (spec.stdin !== undefined) {
1973
+ lines.push('__OPENLOOPS_STDIN="$(mktemp -t openloops-stdin.XXXXXX)"', `trap 'rm -f "$__OPENLOOPS_STDIN"' EXIT`);
1974
+ lines.push(...hereDoc(spec.stdin));
1975
+ stdinRedirect = ' < "$__OPENLOOPS_STDIN"';
1976
+ }
1977
+ const invocation = spec.shell ? `sh -lc ${shellQuote(commandForShell(spec))}${stdinRedirect}` : `${[spec.command, ...spec.args].map(shellQuote).join(" ")}${stdinRedirect}`;
1978
+ lines.push(invocation);
1979
+ return `${lines.join(`
1980
+ `)}
1981
+ `;
1982
+ }
1983
+ function remotePreflightScript(spec, metadata) {
1984
+ return [
1985
+ ...remoteBootstrapLines(spec, metadata),
1986
+ "command -v bash >/dev/null 2>&1",
1987
+ `command -v ${shellQuote(spec.shell ? "sh" : spec.command)} >/dev/null 2>&1`
1988
+ ].join(`
1989
+ `);
1990
+ }
1991
+ function transportEnv(opts) {
1992
+ const source = opts.env ?? process.env;
1993
+ const env = {};
1994
+ for (const [key, value] of Object.entries(source)) {
1995
+ if (value === undefined)
1996
+ continue;
1997
+ if (TRANSPORT_ENV_KEYS.has(key) || key.startsWith("LC_"))
1998
+ env[key] = value;
1999
+ }
2000
+ env.PATH = normalizeExecutionPath(env);
2001
+ return env;
2002
+ }
2003
+ function preflightRemoteSpec(spec, machine, metadata, opts) {
2004
+ const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2005
+ const result = spawnSync2(plan.command, plan.args, {
2006
+ encoding: "utf8",
2007
+ env: transportEnv(opts),
2008
+ input: remotePreflightScript(spec, metadata),
2009
+ stdio: ["pipe", "pipe", "pipe"],
2010
+ timeout: 15000
2011
+ });
2012
+ if (result.error)
2013
+ throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
2014
+ if ((result.status ?? 1) !== 0) {
2015
+ const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
2016
+ throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
2017
+ }
2018
+ }
2019
+ async function executeRemoteSpec(spec, machine, metadata, opts) {
2020
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
2021
+ const startedAt = nowIso();
2022
+ let stdout = "";
2023
+ let stderr = "";
2024
+ let timedOut = false;
2025
+ let exitCode;
2026
+ let error;
2027
+ let plan;
2028
+ let script;
2029
+ try {
2030
+ plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2031
+ script = remoteScript(spec, metadata);
2032
+ } catch (err) {
2033
+ return {
2034
+ status: "failed",
2035
+ stdout: "",
2036
+ stderr: "",
2037
+ error: err instanceof Error ? err.message : String(err),
2038
+ startedAt,
2039
+ finishedAt: nowIso(),
2040
+ durationMs: 0
2041
+ };
2042
+ }
2043
+ const child = spawn(plan.command, plan.args, {
2044
+ env: transportEnv(opts),
2045
+ detached: true,
2046
+ stdio: ["pipe", "pipe", "pipe"]
2047
+ });
2048
+ if (child.pid)
2049
+ opts.onSpawn?.(child.pid);
2050
+ child.stdin?.on("error", (err) => {
2051
+ if (err.code !== "EPIPE")
2052
+ error = err.message;
2053
+ });
2054
+ child.stdin?.end(script);
2055
+ const abortHandler = () => {
2056
+ error = "cancelled";
2057
+ if (child.pid)
2058
+ killProcessGroup(child.pid);
2059
+ };
2060
+ if (opts.signal?.aborted)
2061
+ abortHandler();
2062
+ opts.signal?.addEventListener("abort", abortHandler, { once: true });
2063
+ child.stdout?.on("data", (chunk) => {
2064
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
2065
+ });
2066
+ child.stderr?.on("data", (chunk) => {
2067
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
2068
+ });
2069
+ const timer = setTimeout(() => {
2070
+ timedOut = true;
2071
+ if (child.pid)
2072
+ killProcessGroup(child.pid);
2073
+ }, spec.timeoutMs);
2074
+ timer.unref();
2075
+ try {
2076
+ const [code, signal] = await once(child, "exit");
2077
+ if (typeof code === "number")
2078
+ exitCode = code;
2079
+ if (signal)
2080
+ error = `terminated by ${signal}`;
2081
+ } catch (err) {
2082
+ error = err instanceof Error ? err.message : String(err);
2083
+ } finally {
2084
+ clearTimeout(timer);
2085
+ opts.signal?.removeEventListener("abort", abortHandler);
2086
+ }
2087
+ const finishedAt = nowIso();
2088
+ const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
2089
+ if (timedOut) {
2090
+ return {
2091
+ status: "timed_out",
2092
+ exitCode,
2093
+ stdout,
2094
+ stderr,
2095
+ error: `timed out after ${spec.timeoutMs}ms`,
2096
+ pid: child.pid,
2097
+ startedAt,
2098
+ finishedAt,
2099
+ durationMs
2100
+ };
2101
+ }
2102
+ if (error || exitCode !== 0) {
2103
+ return {
2104
+ status: "failed",
2105
+ exitCode,
2106
+ stdout,
2107
+ stderr,
2108
+ error: error ?? `remote process on ${machine.id} exited with code ${exitCode ?? "unknown"}`,
2109
+ pid: child.pid,
2110
+ startedAt,
2111
+ finishedAt,
2112
+ durationMs
2113
+ };
2114
+ }
2115
+ return {
2116
+ status: "succeeded",
2117
+ exitCode,
2118
+ stdout,
2119
+ stderr,
2120
+ pid: child.pid,
2121
+ startedAt,
2122
+ finishedAt,
2123
+ durationMs
2124
+ };
2125
+ }
1653
2126
  function preflightTarget(target, metadata = {}, opts = {}) {
1654
2127
  const spec = commandSpec(target);
2128
+ const machine = resolvedMachine(opts);
2129
+ if (machine && !machine.local) {
2130
+ preflightRemoteSpec(spec, machine, metadata, opts);
2131
+ return {
2132
+ command: spec.command,
2133
+ accountProfile: spec.account?.profile,
2134
+ accountTool: spec.accountTool
2135
+ };
2136
+ }
1655
2137
  const env = executionEnv(spec, metadata, opts);
1656
2138
  if (!spec.shell && !executableExists(spec.command, env)) {
1657
2139
  throw new Error(commandNotFoundMessage(spec.command, env));
@@ -1664,6 +2146,9 @@ function preflightTarget(target, metadata = {}, opts = {}) {
1664
2146
  }
1665
2147
  async function executeTarget(target, metadata = {}, opts = {}) {
1666
2148
  const spec = commandSpec(target);
2149
+ const machine = resolvedMachine(opts);
2150
+ if (machine && !machine.local)
2151
+ return executeRemoteSpec(spec, machine, metadata, opts);
1667
2152
  const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
1668
2153
  const startedAt = nowIso();
1669
2154
  let stdout = "";
@@ -1688,10 +2173,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1688
2173
  env,
1689
2174
  shell: spec.shell ?? false,
1690
2175
  detached: true,
1691
- stdio: ["ignore", "pipe", "pipe"]
2176
+ stdio: spec.stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
1692
2177
  });
1693
2178
  if (child.pid)
1694
2179
  opts.onSpawn?.(child.pid);
2180
+ if (spec.stdin !== undefined && child.stdin) {
2181
+ child.stdin.on("error", (err) => {
2182
+ if (err.code !== "EPIPE")
2183
+ error = err.message;
2184
+ });
2185
+ child.stdin.end(spec.stdin);
2186
+ }
1695
2187
  const abortHandler = () => {
1696
2188
  error = "cancelled";
1697
2189
  if (child.pid)
@@ -1700,10 +2192,10 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1700
2192
  if (opts.signal?.aborted)
1701
2193
  abortHandler();
1702
2194
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
1703
- child.stdout.on("data", (chunk) => {
2195
+ child.stdout?.on("data", (chunk) => {
1704
2196
  stdout = appendBounded(stdout, chunk, maxOutputBytes);
1705
2197
  });
1706
- child.stderr.on("data", (chunk) => {
2198
+ child.stderr?.on("data", (chunk) => {
1707
2199
  stderr = appendBounded(stderr, chunk, maxOutputBytes);
1708
2200
  });
1709
2201
  const timer = setTimeout(() => {
@@ -1772,7 +2264,7 @@ async function executeLoop(loop, run, opts = {}) {
1772
2264
  loopName: loop.name,
1773
2265
  runId: run.id,
1774
2266
  scheduledFor: run.scheduledFor
1775
- }, opts);
2267
+ }, { ...opts, machine: opts.machine ?? loop.machine });
1776
2268
  }
1777
2269
 
1778
2270
  // src/lib/workflow-runner.ts
@@ -1802,7 +2294,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1802
2294
  loop: opts.loop,
1803
2295
  loopRun: opts.loopRun,
1804
2296
  scheduledFor: opts.scheduledFor,
1805
- idempotencyKey: opts.idempotencyKey
2297
+ idempotencyKey: opts.idempotencyKey,
2298
+ daemonLeaseId: opts.daemonLeaseId
1806
2299
  });
1807
2300
  const startedAt = run.startedAt ?? nowIso();
1808
2301
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
@@ -1836,12 +2329,14 @@ async function executeWorkflow(store, workflow, opts = {}) {
1836
2329
  return !dependencyStep?.continueOnFailure;
1837
2330
  });
1838
2331
  if (blockedBy) {
1839
- store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`);
2332
+ opts.beforePersist?.();
2333
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
1840
2334
  blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
1841
2335
  terminalStatus = "failed";
1842
2336
  continue;
1843
2337
  }
1844
- const startedStep = store.startWorkflowStepRun(run.id, step.id);
2338
+ opts.beforePersist?.();
2339
+ const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
1845
2340
  if (startedStep.status !== "running") {
1846
2341
  terminalStatus = "failed";
1847
2342
  blockingError = `step ${step.id} could not start because workflow is no longer running`;
@@ -1871,9 +2366,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
1871
2366
  try {
1872
2367
  result = await executeTarget(targetWithStepAccount(step), metadata, {
1873
2368
  ...opts,
2369
+ machine: opts.machine ?? opts.loop?.machine,
1874
2370
  signal: controller.signal,
1875
2371
  onSpawn: (pid) => {
1876
- store.markWorkflowStepPid(run.id, step.id, pid);
2372
+ opts.beforePersist?.();
2373
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
1877
2374
  opts.onSpawn?.(pid);
1878
2375
  }
1879
2376
  });
@@ -1901,6 +2398,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
1901
2398
  blockingError = "workflow run was cancelled";
1902
2399
  break;
1903
2400
  }
2401
+ opts.beforePersist?.();
1904
2402
  store.finalizeWorkflowStepRun(run.id, step.id, {
1905
2403
  status: result.status,
1906
2404
  finishedAt: result.finishedAt,
@@ -1909,6 +2407,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1909
2407
  stderr: result.stderr,
1910
2408
  exitCode: result.exitCode,
1911
2409
  error: result.error
2410
+ }, {
2411
+ daemonLeaseId: opts.daemonLeaseId
1912
2412
  });
1913
2413
  if (result.status !== "succeeded" && !step.continueOnFailure) {
1914
2414
  terminalStatus = result.status;
@@ -1920,7 +2420,9 @@ async function executeWorkflow(store, workflow, opts = {}) {
1920
2420
  for (const step of ordered) {
1921
2421
  const existing = store.getWorkflowStepRun(run.id, step.id);
1922
2422
  if (existing?.status === "pending" || existing?.status === "running") {
1923
- store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run");
2423
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
2424
+ daemonLeaseId: opts.daemonLeaseId
2425
+ });
1924
2426
  }
1925
2427
  }
1926
2428
  }
@@ -1930,10 +2432,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
1930
2432
  const steps2 = store.listWorkflowStepRuns(run.id);
1931
2433
  return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
1932
2434
  }
2435
+ opts.beforePersist?.();
1933
2436
  const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
1934
2437
  finishedAt,
1935
2438
  durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
1936
2439
  error: blockingError
2440
+ }, {
2441
+ daemonLeaseId: opts.daemonLeaseId
1937
2442
  });
1938
2443
  const steps = store.listWorkflowStepRuns(run.id);
1939
2444
  return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
@@ -1981,52 +2486,81 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
1981
2486
 
1982
2487
  // src/lib/scheduler.ts
1983
2488
  function manualRunScheduledFor(loop, now = new Date) {
1984
- if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
2489
+ if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
1985
2490
  return loop.retryScheduledFor ?? loop.nextRunAt;
1986
2491
  }
1987
2492
  return now.toISOString();
1988
2493
  }
1989
2494
  function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
2495
+ if (loop.status !== "active")
2496
+ return false;
1990
2497
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
1991
2498
  return false;
1992
2499
  return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
1993
2500
  }
2501
+ function manualRunSource(loop, scheduledFor, now = new Date) {
2502
+ if (loop.status !== "active")
2503
+ return "ad_hoc";
2504
+ if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
2505
+ return "ad_hoc";
2506
+ if (loop.retryScheduledFor && scheduledFor === loop.retryScheduledFor)
2507
+ return "retry_slot";
2508
+ return "due_slot";
2509
+ }
1994
2510
  function nextAfterRetry(loop, now) {
1995
2511
  return new Date(now.getTime() + loop.retryDelayMs).toISOString();
1996
2512
  }
1997
- function advanceLoop(store, loop, run, finishedAt, succeeded) {
2513
+ function isDaemonLeaseLost(error) {
2514
+ return error instanceof Error && error.message === "daemon lease lost";
2515
+ }
2516
+ function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
1998
2517
  if (run.status === "running")
1999
2518
  return;
2000
2519
  const current = store.getLoop(loop.id);
2001
2520
  if (!current || current.status !== "active")
2002
2521
  return;
2003
- const shouldRetry = !succeeded && run.attempt < loop.maxAttempts;
2522
+ if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
2523
+ return;
2524
+ const shouldRetry = !succeeded && run.attempt < current.maxAttempts;
2004
2525
  if (shouldRetry) {
2005
- store.updateLoop(loop.id, {
2526
+ store.updateLoop(current.id, {
2006
2527
  status: "active",
2007
- nextRunAt: nextAfterRetry(loop, finishedAt),
2528
+ nextRunAt: nextAfterRetry(current, finishedAt),
2008
2529
  retryScheduledFor: run.scheduledFor
2009
- });
2530
+ }, { daemonLeaseId: opts.daemonLeaseId });
2010
2531
  return;
2011
2532
  }
2012
- const nextRunAt = computeNextAfter(loop.schedule, new Date(run.scheduledFor), finishedAt);
2013
- store.updateLoop(loop.id, {
2533
+ const deferredRetry = store.nextRetryableRun(current.id, current.maxAttempts, run.scheduledFor);
2534
+ if (deferredRetry) {
2535
+ store.updateLoop(current.id, {
2536
+ status: "active",
2537
+ nextRunAt: nextAfterRetry(current, finishedAt),
2538
+ retryScheduledFor: deferredRetry.scheduledFor
2539
+ }, { daemonLeaseId: opts.daemonLeaseId });
2540
+ return;
2541
+ }
2542
+ const nextRunAt = computeNextAfter(current.schedule, new Date(run.scheduledFor), finishedAt);
2543
+ store.updateLoop(current.id, {
2014
2544
  status: nextRunAt ? "active" : "stopped",
2015
2545
  nextRunAt,
2016
2546
  retryScheduledFor: undefined
2017
- });
2547
+ }, { daemonLeaseId: opts.daemonLeaseId });
2018
2548
  }
2019
2549
  async function executeClaimedRun(deps) {
2020
2550
  let heartbeat;
2021
2551
  const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
2022
2552
  heartbeat = setInterval(() => {
2023
- deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
2553
+ deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs, new Date, {
2554
+ daemonLeaseId: deps.daemonLeaseId
2555
+ });
2024
2556
  }, heartbeatEveryMs);
2025
2557
  heartbeat.unref();
2026
2558
  try {
2027
2559
  const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
2028
- onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
2560
+ daemonLeaseId: deps.daemonLeaseId,
2561
+ onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId, { daemonLeaseId: deps.daemonLeaseId })
2029
2562
  })))(deps.loop, deps.run);
2563
+ deps.beforeFinalize?.(deps.loop, deps.run);
2030
2564
  return deps.store.finalizeRun(deps.run.id, {
2031
2565
  status: result.status,
2032
2566
  finishedAt: result.finishedAt,
@@ -2038,10 +2572,16 @@ async function executeClaimedRun(deps) {
2038
2572
  pid: result.pid
2039
2573
  }, {
2040
2574
  claimedBy: deps.runnerId,
2575
+ daemonLeaseId: deps.daemonLeaseId,
2041
2576
  now: deps.now?.() ?? new Date(result.finishedAt)
2042
2577
  });
2043
2578
  } catch (err) {
2044
2579
  deps.onError?.(deps.loop, err);
2580
+ try {
2581
+ deps.beforeFinalize?.(deps.loop, deps.run);
2582
+ } catch {
2583
+ return deps.store.getRun(deps.run.id) ?? deps.run;
2584
+ }
2045
2585
  const finishedAt = new Date;
2046
2586
  return deps.store.finalizeRun(deps.run.id, {
2047
2587
  status: "failed",
@@ -2052,6 +2592,7 @@ async function executeClaimedRun(deps) {
2052
2592
  error: err instanceof Error ? err.message : String(err)
2053
2593
  }, {
2054
2594
  claimedBy: deps.runnerId,
2595
+ daemonLeaseId: deps.daemonLeaseId,
2055
2596
  now: deps.now?.() ?? finishedAt
2056
2597
  });
2057
2598
  } finally {
@@ -2061,15 +2602,33 @@ async function executeClaimedRun(deps) {
2061
2602
  }
2062
2603
  async function runSlot(deps, loop, scheduledFor) {
2063
2604
  const now = deps.now?.() ?? new Date;
2605
+ deps.beforeRun?.(loop, scheduledFor);
2064
2606
  if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
2065
- const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
2066
- advanceLoop(deps.store, loop, skipped, now, true);
2607
+ let skipped;
2608
+ try {
2609
+ skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
2610
+ daemonLeaseId: deps.daemonLeaseId
2611
+ });
2612
+ } catch (error) {
2613
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
2614
+ return;
2615
+ throw error;
2616
+ }
2617
+ advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
2067
2618
  deps.onRun?.(skipped);
2068
2619
  return skipped;
2069
2620
  }
2070
- const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
2621
+ let claim;
2622
+ try {
2623
+ claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
2624
+ } catch (error) {
2625
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
2626
+ return;
2627
+ throw error;
2628
+ }
2071
2629
  if (!claim)
2072
2630
  return;
2631
+ deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
2073
2632
  deps.onRun?.(claim.run);
2074
2633
  const finalRun = await executeClaimedRun({
2075
2634
  store: deps.store,
@@ -2078,21 +2637,42 @@ async function runSlot(deps, loop, scheduledFor) {
2078
2637
  run: claim.run,
2079
2638
  now: deps.now,
2080
2639
  execute: deps.execute,
2640
+ beforeFinalize: deps.beforeFinalize,
2641
+ daemonLeaseId: deps.daemonLeaseId,
2081
2642
  onError: deps.onError
2082
2643
  });
2083
- advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
2644
+ advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded", { daemonLeaseId: deps.daemonLeaseId });
2084
2645
  deps.onRun?.(finalRun);
2085
2646
  return finalRun;
2086
2647
  }
2087
2648
  async function tick(deps) {
2088
2649
  const now = deps.now?.() ?? new Date;
2089
- const recovered = deps.store.recoverExpiredRunLeases(now);
2650
+ const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
2651
+ const recoveredByLoop = new Map;
2090
2652
  for (const run of recovered) {
2091
- const loop = deps.store.getLoop(run.loopId);
2092
- if (loop)
2093
- advanceLoop(deps.store, loop, run, new Date(run.finishedAt ?? now), false);
2653
+ recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
2654
+ }
2655
+ for (const runs of recoveredByLoop.values()) {
2656
+ const loop = deps.store.getLoop(runs[0].loopId);
2657
+ if (!loop)
2658
+ continue;
2659
+ const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
2660
+ if (retryable) {
2661
+ advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
2662
+ daemonLeaseId: deps.daemonLeaseId
2663
+ });
2664
+ continue;
2665
+ }
2666
+ for (const run of runs) {
2667
+ const current = deps.store.getLoop(run.loopId);
2668
+ if (current) {
2669
+ advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
2670
+ daemonLeaseId: deps.daemonLeaseId
2671
+ });
2672
+ }
2673
+ }
2094
2674
  }
2095
- const expired = deps.store.expireLoops(now);
2675
+ const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
2096
2676
  const claimed = [];
2097
2677
  const completed = [];
2098
2678
  const skipped = [];
@@ -2158,9 +2738,17 @@ class LoopsClient {
2158
2738
  async runNow(idOrName) {
2159
2739
  const loop = this.get(idOrName);
2160
2740
  const now = new Date;
2161
- const scheduledFor = manualRunScheduledFor(loop, now);
2162
- const shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
2163
- const claim = this.store.claimRun(loop, scheduledFor, this.runnerId, now);
2741
+ let scheduledFor = manualRunScheduledFor(loop, now);
2742
+ let shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
2743
+ let claim = this.store.claimRun(loop, scheduledFor, this.runnerId, now);
2744
+ if (!claim && shouldAdvance) {
2745
+ const existing = this.store.getRunBySlot(loop.id, scheduledFor);
2746
+ if (existing && existing.status !== "running") {
2747
+ scheduledFor = now.toISOString();
2748
+ shouldAdvance = false;
2749
+ claim = this.store.claimRun(loop, scheduledFor, this.runnerId, now);
2750
+ }
2751
+ }
2164
2752
  if (!claim)
2165
2753
  throw new Error(`could not claim manual run for ${idOrName}`);
2166
2754
  const run = await executeClaimedRun({ store: this.store, runnerId: this.runnerId, loop: claim.loop, run: claim.run });