@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.
@@ -347,6 +347,7 @@ function rowToLoop(row) {
347
347
  status: row.status,
348
348
  schedule: JSON.parse(row.schedule_json),
349
349
  target: JSON.parse(row.target_json),
350
+ machine: row.machine_json ? JSON.parse(row.machine_json) : undefined,
350
351
  nextRunAt: row.next_run_at ?? undefined,
351
352
  retryScheduledFor: row.retry_scheduled_for ?? undefined,
352
353
  catchUp: row.catch_up,
@@ -489,6 +490,7 @@ class Store {
489
490
  status TEXT NOT NULL,
490
491
  schedule_json TEXT NOT NULL,
491
492
  target_json TEXT NOT NULL,
493
+ machine_json TEXT,
492
494
  next_run_at TEXT,
493
495
  retry_scheduled_for TEXT,
494
496
  catch_up TEXT NOT NULL,
@@ -610,10 +612,21 @@ class Store {
610
612
  );
611
613
  CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
612
614
  `);
615
+ try {
616
+ this.db.query("ALTER TABLE loops ADD COLUMN machine_json TEXT").run();
617
+ } catch {}
613
618
  try {
614
619
  this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
615
620
  } catch {}
616
621
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
622
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
623
+ }
624
+ assertDaemonLeaseFence(opts = {}, now = nowIso()) {
625
+ if (!opts.daemonLeaseId)
626
+ return;
627
+ const row = this.db.query("SELECT id FROM daemon_lease WHERE id = ? AND expires_at > ?").get(opts.daemonLeaseId, now);
628
+ if (!row)
629
+ throw new Error("daemon lease lost");
617
630
  }
618
631
  createLoop(input, from = new Date) {
619
632
  const now = nowIso();
@@ -624,6 +637,7 @@ class Store {
624
637
  status: "active",
625
638
  schedule: input.schedule,
626
639
  target: input.target,
640
+ machine: input.machine,
627
641
  nextRunAt: initialNextRun(input.schedule, from),
628
642
  catchUp: input.catchUp ?? "latest",
629
643
  catchUpLimit: input.catchUpLimit ?? 50,
@@ -635,9 +649,9 @@ class Store {
635
649
  createdAt: now,
636
650
  updatedAt: now
637
651
  };
638
- this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, next_run_at, retry_scheduled_for,
652
+ this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, machine_json, next_run_at, retry_scheduled_for,
639
653
  catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
640
- VALUES ($id, $name, $description, $status, $schedule, $target, $nextRun, NULL, $catchUp, $catchUpLimit,
654
+ VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $catchUp, $catchUpLimit,
641
655
  $overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
642
656
  $id: loop.id,
643
657
  $name: loop.name,
@@ -645,6 +659,7 @@ class Store {
645
659
  $status: loop.status,
646
660
  $schedule: JSON.stringify(loop.schedule),
647
661
  $target: JSON.stringify(loop.target),
662
+ $machine: loop.machine ? JSON.stringify(loop.machine) : null,
648
663
  $nextRun: loop.nextRunAt ?? null,
649
664
  $catchUp: loop.catchUp,
650
665
  $catchUpLimit: loop.catchUpLimit,
@@ -684,21 +699,31 @@ class Store {
684
699
  ORDER BY next_run_at ASC`).all(now.toISOString());
685
700
  return rows.map(rowToLoop);
686
701
  }
687
- updateLoop(id, patch) {
702
+ updateLoop(id, patch, opts = {}) {
688
703
  const current = this.getLoop(id);
689
704
  if (!current)
690
705
  throw new Error(`loop not found: ${id}`);
691
- const merged = { ...current, ...patch, updatedAt: nowIso() };
706
+ const updated = (opts.now ?? new Date).toISOString();
707
+ const merged = { ...current, ...patch, updatedAt: updated };
692
708
  this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
693
- expires_at=$expiresAt, updated_at=$updated WHERE id=$id`).run({
709
+ expires_at=$expiresAt, updated_at=$updated
710
+ WHERE id=$id
711
+ AND ($daemonLeaseId IS NULL OR EXISTS (
712
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
713
+ ))`).run({
694
714
  $id: id,
695
715
  $status: merged.status,
696
716
  $nextRun: merged.nextRunAt ?? null,
697
717
  $retrySlot: merged.retryScheduledFor ?? null,
698
718
  $expiresAt: merged.expiresAt ?? null,
699
- $updated: merged.updatedAt
719
+ $updated: merged.updatedAt,
720
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
721
+ $now: updated
700
722
  });
701
- return merged;
723
+ const after = this.getLoop(id);
724
+ if (!after)
725
+ throw new Error(`loop not found after update: ${id}`);
726
+ return after;
702
727
  }
703
728
  deleteLoop(idOrName) {
704
729
  const loop = this.requireLoop(idOrName);
@@ -762,11 +787,14 @@ class Store {
762
787
  const now = nowIso();
763
788
  if (input.idempotencyKey) {
764
789
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
765
- if (existing)
790
+ if (existing) {
791
+ this.assertDaemonLeaseFence(input);
766
792
  return rowToWorkflowRun(existing);
793
+ }
767
794
  }
768
795
  this.db.exec("BEGIN IMMEDIATE");
769
796
  try {
797
+ this.assertDaemonLeaseFence(input, now);
770
798
  if (input.idempotencyKey) {
771
799
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
772
800
  if (existing) {
@@ -865,31 +893,60 @@ class Store {
865
893
  const run = this.getWorkflowRun(workflowRunId);
866
894
  return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
867
895
  }
868
- startWorkflowStepRun(workflowRunId, stepId) {
869
- const now = nowIso();
870
- const res = this.db.query(`UPDATE workflow_step_runs
871
- SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
872
- pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
873
- WHERE workflow_run_id=$workflowRunId
874
- AND step_id=$stepId
875
- AND status IN ('pending', 'failed', 'timed_out')
876
- AND EXISTS (
877
- SELECT 1 FROM workflow_runs
878
- WHERE id=$workflowRunId AND status='running'
879
- )`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
880
- const run = this.getWorkflowStepRun(workflowRunId, stepId);
881
- if (!run)
882
- throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
883
- if (res.changes !== 1) {
884
- throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
896
+ startWorkflowStepRun(workflowRunId, stepId, opts = {}) {
897
+ const now = (opts.now ?? new Date).toISOString();
898
+ this.db.exec("BEGIN IMMEDIATE");
899
+ try {
900
+ const res = this.db.query(`UPDATE workflow_step_runs
901
+ SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
902
+ pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
903
+ WHERE workflow_run_id=$workflowRunId
904
+ AND step_id=$stepId
905
+ AND status IN ('pending', 'failed', 'timed_out')
906
+ AND EXISTS (
907
+ SELECT 1 FROM workflow_runs
908
+ WHERE id=$workflowRunId AND status='running'
909
+ )
910
+ AND ($daemonLeaseId IS NULL OR EXISTS (
911
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
912
+ ))`).run({
913
+ $workflowRunId: workflowRunId,
914
+ $stepId: stepId,
915
+ $started: now,
916
+ $updated: now,
917
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
918
+ $now: now
919
+ });
920
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
921
+ if (!run)
922
+ throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
923
+ if (res.changes !== 1) {
924
+ throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
925
+ }
926
+ this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
927
+ this.db.exec("COMMIT");
928
+ return run;
929
+ } catch (error) {
930
+ try {
931
+ this.db.exec("ROLLBACK");
932
+ } catch {}
933
+ throw error;
885
934
  }
886
- this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
887
- return run;
888
935
  }
889
- markWorkflowStepPid(workflowRunId, stepId, pid) {
890
- const now = nowIso();
936
+ markWorkflowStepPid(workflowRunId, stepId, pid, opts = {}) {
937
+ const now = (opts.now ?? new Date).toISOString();
891
938
  this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
892
- WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $pid: pid, $updated: now });
939
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
940
+ AND ($daemonLeaseId IS NULL OR EXISTS (
941
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
942
+ ))`).run({
943
+ $workflowRunId: workflowRunId,
944
+ $stepId: stepId,
945
+ $pid: pid,
946
+ $updated: now,
947
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
948
+ $now: now
949
+ });
893
950
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
894
951
  if (!run)
895
952
  throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
@@ -917,60 +974,110 @@ class Store {
917
974
  recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
918
975
  };
919
976
  }
920
- finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
977
+ finalizeWorkflowStepRun(workflowRunId, stepId, patch, opts = {}) {
921
978
  const finishedAt = patch.finishedAt ?? nowIso();
922
- const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
923
- pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
924
- WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({
925
- $workflowRunId: workflowRunId,
926
- $stepId: stepId,
927
- $status: patch.status,
928
- $finished: finishedAt,
929
- $exitCode: patch.exitCode ?? null,
930
- $durationMs: patch.durationMs ?? null,
931
- $stdout: patch.stdout ?? null,
932
- $stderr: patch.stderr ?? null,
933
- $error: patch.error ?? null,
934
- $updated: finishedAt
935
- });
936
- if (res.changes === 1) {
937
- this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
938
- exitCode: patch.exitCode,
939
- error: patch.error
979
+ this.db.exec("BEGIN IMMEDIATE");
980
+ try {
981
+ const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
982
+ pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
983
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
984
+ AND ($daemonLeaseId IS NULL OR EXISTS (
985
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
986
+ ))`).run({
987
+ $workflowRunId: workflowRunId,
988
+ $stepId: stepId,
989
+ $status: patch.status,
990
+ $finished: finishedAt,
991
+ $exitCode: patch.exitCode ?? null,
992
+ $durationMs: patch.durationMs ?? null,
993
+ $stdout: patch.stdout ?? null,
994
+ $stderr: patch.stderr ?? null,
995
+ $error: patch.error ?? null,
996
+ $updated: finishedAt,
997
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
998
+ $now: (opts.now ?? new Date(finishedAt)).toISOString()
940
999
  });
1000
+ if (res.changes === 1) {
1001
+ this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
1002
+ exitCode: patch.exitCode,
1003
+ error: patch.error
1004
+ });
1005
+ }
1006
+ this.db.exec("COMMIT");
1007
+ } catch (error) {
1008
+ try {
1009
+ this.db.exec("ROLLBACK");
1010
+ } catch {}
1011
+ throw error;
941
1012
  }
942
1013
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
943
1014
  if (!run)
944
1015
  throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
945
1016
  return run;
946
1017
  }
947
- skipWorkflowStepRun(workflowRunId, stepId, reason) {
948
- const now = nowIso();
949
- const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
950
- 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 });
951
- if (res.changes === 1)
952
- this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
1018
+ skipWorkflowStepRun(workflowRunId, stepId, reason, opts = {}) {
1019
+ const now = (opts.now ?? new Date).toISOString();
1020
+ this.db.exec("BEGIN IMMEDIATE");
1021
+ try {
1022
+ const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
1023
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')
1024
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1025
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1026
+ ))`).run({
1027
+ $workflowRunId: workflowRunId,
1028
+ $stepId: stepId,
1029
+ $finished: now,
1030
+ $error: reason,
1031
+ $updated: now,
1032
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1033
+ $now: now
1034
+ });
1035
+ if (res.changes === 1)
1036
+ this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
1037
+ this.db.exec("COMMIT");
1038
+ } catch (error) {
1039
+ try {
1040
+ this.db.exec("ROLLBACK");
1041
+ } catch {}
1042
+ throw error;
1043
+ }
953
1044
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
954
1045
  if (!run)
955
1046
  throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
956
1047
  return run;
957
1048
  }
958
- finalizeWorkflowRun(workflowRunId, status, patch = {}) {
1049
+ finalizeWorkflowRun(workflowRunId, status, patch = {}, opts = {}) {
959
1050
  const finishedAt = patch.finishedAt ?? nowIso();
960
- const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
961
- WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({
962
- $id: workflowRunId,
963
- $status: status,
964
- $finished: finishedAt,
965
- $durationMs: patch.durationMs ?? null,
966
- $error: patch.error ?? null,
967
- $updated: finishedAt
968
- });
1051
+ let changed = false;
1052
+ this.db.exec("BEGIN IMMEDIATE");
1053
+ try {
1054
+ const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
1055
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
1056
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1057
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1058
+ ))`).run({
1059
+ $id: workflowRunId,
1060
+ $status: status,
1061
+ $finished: finishedAt,
1062
+ $durationMs: patch.durationMs ?? null,
1063
+ $error: patch.error ?? null,
1064
+ $updated: finishedAt,
1065
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1066
+ $now: (opts.now ?? new Date(finishedAt)).toISOString()
1067
+ });
1068
+ changed = res.changes === 1;
1069
+ if (changed)
1070
+ this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
1071
+ this.db.exec("COMMIT");
1072
+ } catch (error) {
1073
+ try {
1074
+ this.db.exec("ROLLBACK");
1075
+ } catch {}
1076
+ throw error;
1077
+ }
969
1078
  const run = this.getWorkflowRun(workflowRunId);
970
1079
  if (!run)
971
1080
  throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
972
- if (res.changes === 1)
973
- this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
974
1081
  return run;
975
1082
  }
976
1083
  cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
@@ -1024,10 +1131,24 @@ class Store {
1024
1131
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
1025
1132
  return (row?.count ?? 0) > 0;
1026
1133
  }
1027
- markRunPid(id, pid, claimedBy) {
1028
- const now = nowIso();
1134
+ markRunPid(id, pid, claimedBy, opts = {}) {
1135
+ const now = (opts.now ?? new Date).toISOString();
1029
1136
  const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
1030
- 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 });
1137
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy
1138
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1139
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1140
+ ))`).run({
1141
+ $id: id,
1142
+ $pid: pid,
1143
+ $updated: now,
1144
+ $claimedBy: claimedBy,
1145
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1146
+ $now: now
1147
+ }) : this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
1148
+ WHERE id=$id AND status='running'
1149
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1150
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1151
+ ))`).run({ $id: id, $pid: pid, $updated: now, $daemonLeaseId: opts.daemonLeaseId ?? null, $now: now });
1031
1152
  if (res.changes !== 1)
1032
1153
  return;
1033
1154
  return this.getRun(id);
@@ -1042,7 +1163,7 @@ class Store {
1042
1163
  AND wsr.pid IS NOT NULL`).all(loopRunId);
1043
1164
  return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
1044
1165
  }
1045
- createSkippedRun(loop, scheduledFor, reason) {
1166
+ createSkippedRun(loop, scheduledFor, reason, opts = {}) {
1046
1167
  const now = nowIso();
1047
1168
  const run = {
1048
1169
  id: genId(),
@@ -1056,21 +1177,31 @@ class Store {
1056
1177
  createdAt: now,
1057
1178
  updatedAt: now
1058
1179
  };
1059
- this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
1060
- claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
1061
- VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
1062
- NULL, NULL, $error, $created, $updated)`).run({
1063
- $id: run.id,
1064
- $loopId: run.loopId,
1065
- $loopName: run.loopName,
1066
- $scheduledFor: run.scheduledFor,
1067
- $attempt: run.attempt,
1068
- $status: run.status,
1069
- $finished: run.finishedAt ?? null,
1070
- $error: run.error ?? null,
1071
- $created: run.createdAt,
1072
- $updated: run.updatedAt
1073
- });
1180
+ this.db.exec("BEGIN IMMEDIATE");
1181
+ try {
1182
+ this.assertDaemonLeaseFence(opts, now);
1183
+ this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
1184
+ claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
1185
+ VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
1186
+ NULL, NULL, $error, $created, $updated)`).run({
1187
+ $id: run.id,
1188
+ $loopId: run.loopId,
1189
+ $loopName: run.loopName,
1190
+ $scheduledFor: run.scheduledFor,
1191
+ $attempt: run.attempt,
1192
+ $status: run.status,
1193
+ $finished: run.finishedAt ?? null,
1194
+ $error: run.error ?? null,
1195
+ $created: run.createdAt,
1196
+ $updated: run.updatedAt
1197
+ });
1198
+ this.db.exec("COMMIT");
1199
+ } catch (error) {
1200
+ try {
1201
+ this.db.exec("ROLLBACK");
1202
+ } catch {}
1203
+ throw error;
1204
+ }
1074
1205
  return this.getRunBySlot(loop.id, scheduledFor) ?? run;
1075
1206
  }
1076
1207
  getRun(id) {
@@ -1081,11 +1212,20 @@ class Store {
1081
1212
  const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
1082
1213
  return row ? rowToRun(row) : undefined;
1083
1214
  }
1084
- claimRun(loop, scheduledFor, runnerId, now = new Date) {
1215
+ nextRetryableRun(loopId, maxAttempts, afterScheduledFor) {
1216
+ const row = afterScheduledFor ? this.db.query(`SELECT * FROM loop_runs
1217
+ WHERE loop_id = ? AND scheduled_for > ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
1218
+ ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, afterScheduledFor, maxAttempts) : this.db.query(`SELECT * FROM loop_runs
1219
+ WHERE loop_id = ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
1220
+ ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, maxAttempts);
1221
+ return row ? rowToRun(row) : undefined;
1222
+ }
1223
+ claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
1085
1224
  const startedAt = now.toISOString();
1086
1225
  const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1087
1226
  this.db.exec("BEGIN IMMEDIATE");
1088
1227
  try {
1228
+ this.assertDaemonLeaseFence(opts, startedAt);
1089
1229
  const existing = this.getRunBySlot(loop.id, scheduledFor);
1090
1230
  if (existing) {
1091
1231
  if (existing.status === "running") {
@@ -1180,11 +1320,15 @@ class Store {
1180
1320
  $error: patch.error ?? null,
1181
1321
  $updated: finishedAt,
1182
1322
  $claimedBy: opts.claimedBy ?? null,
1183
- $now: (opts.now ?? new Date).toISOString()
1323
+ $now: (opts.now ?? new Date).toISOString(),
1324
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1184
1325
  };
1185
1326
  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,
1186
1327
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
1187
- 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,
1328
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
1329
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1330
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1331
+ ))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
1188
1332
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
1189
1333
  const run = this.getRun(id);
1190
1334
  if (!run)
@@ -1193,10 +1337,20 @@ class Store {
1193
1337
  return run;
1194
1338
  return run;
1195
1339
  }
1196
- heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
1340
+ heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
1197
1341
  const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
1198
1342
  const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
1199
- WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $claimedBy: claimedBy, $expires: expiresAt, $updated: now.toISOString() });
1343
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
1344
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1345
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1346
+ ))`).run({
1347
+ $id: id,
1348
+ $claimedBy: claimedBy,
1349
+ $expires: expiresAt,
1350
+ $updated: now.toISOString(),
1351
+ $now: now.toISOString(),
1352
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1353
+ });
1200
1354
  if (res.changes !== 1)
1201
1355
  return;
1202
1356
  return this.getRun(id);
@@ -1215,7 +1369,7 @@ class Store {
1215
1369
  }
1216
1370
  return rows.map(rowToRun);
1217
1371
  }
1218
- recoverExpiredRunLeases(now = new Date) {
1372
+ recoverExpiredRunLeases(now = new Date, opts = {}) {
1219
1373
  const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
1220
1374
  const recovered = [];
1221
1375
  for (const row of rows) {
@@ -1224,20 +1378,63 @@ class Store {
1224
1378
  if (this.hasLiveWorkflowStepProcesses(row.id))
1225
1379
  continue;
1226
1380
  const finished = now.toISOString();
1227
- this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
1228
- error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: finished, $updated: finished });
1229
- 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);
1230
- for (const workflowRow of workflowRows) {
1231
- this.db.query(`UPDATE workflow_runs
1232
- SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
1233
- WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRow.id, $finished: finished, $updated: finished });
1234
- this.db.query(`UPDATE workflow_step_runs
1235
- SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
1236
- WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRow.id, $finished: finished, $updated: finished });
1237
- this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
1238
- error: "parent loop run lease expired before completion",
1239
- loopRunId: row.id
1381
+ this.db.exec("BEGIN IMMEDIATE");
1382
+ try {
1383
+ const res = this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
1384
+ error='run lease expired before completion', updated_at=$updated
1385
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
1386
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1387
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1388
+ ))`).run({
1389
+ $id: row.id,
1390
+ $finished: finished,
1391
+ $updated: finished,
1392
+ $now: finished,
1393
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1240
1394
  });
1395
+ if (res.changes !== 1) {
1396
+ this.db.exec("COMMIT");
1397
+ continue;
1398
+ }
1399
+ 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);
1400
+ for (const workflowRow of workflowRows) {
1401
+ const workflowRes = this.db.query(`UPDATE workflow_runs
1402
+ SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
1403
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
1404
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1405
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1406
+ ))`).run({
1407
+ $id: workflowRow.id,
1408
+ $finished: finished,
1409
+ $updated: finished,
1410
+ $now: finished,
1411
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1412
+ });
1413
+ if (workflowRes.changes !== 1)
1414
+ continue;
1415
+ this.db.query(`UPDATE workflow_step_runs
1416
+ SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
1417
+ WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')
1418
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1419
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1420
+ ))`).run({
1421
+ $workflowRunId: workflowRow.id,
1422
+ $finished: finished,
1423
+ $updated: finished,
1424
+ $now: finished,
1425
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1426
+ });
1427
+ this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
1428
+ error: "parent loop run lease expired before completion",
1429
+ loopRunId: row.id
1430
+ });
1431
+ }
1432
+ this.db.exec("COMMIT");
1433
+ } catch (error) {
1434
+ try {
1435
+ this.db.exec("ROLLBACK");
1436
+ } catch {}
1437
+ throw error;
1241
1438
  }
1242
1439
  const run = this.getRun(row.id);
1243
1440
  if (run)
@@ -1245,11 +1442,14 @@ class Store {
1245
1442
  }
1246
1443
  return recovered;
1247
1444
  }
1248
- expireLoops(now = new Date) {
1445
+ expireLoops(now = new Date, opts = {}) {
1249
1446
  const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
1250
1447
  const expired = [];
1251
- for (const row of rows)
1252
- expired.push(this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }));
1448
+ for (const row of rows) {
1449
+ const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
1450
+ if (updated.status === "expired")
1451
+ expired.push(updated);
1452
+ }
1253
1453
  return expired;
1254
1454
  }
1255
1455
  countLoops(status) {
@@ -1292,7 +1492,7 @@ class Store {
1292
1492
  }
1293
1493
  heartbeatDaemonLease(id, ttlMs, now = new Date) {
1294
1494
  const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
1295
- 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() });
1495
+ 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() });
1296
1496
  if (res.changes !== 1)
1297
1497
  return;
1298
1498
  return this.getDaemonLease();
@@ -1318,8 +1518,9 @@ import { hostname as hostname2 } from "os";
1318
1518
  import { spawn as spawn2 } from "child_process";
1319
1519
 
1320
1520
  // src/lib/executor.ts
1321
- import { spawn } from "child_process";
1521
+ import { spawn, spawnSync as spawnSync2 } from "child_process";
1322
1522
  import { once } from "events";
1523
+ import { resolveMachineCommand } from "@hasna/machines/consumer";
1323
1524
 
1324
1525
  // src/lib/accounts.ts
1325
1526
  import { spawnSync } from "child_process";
@@ -1476,6 +1677,59 @@ function commandNotFoundMessage(command, env = process.env) {
1476
1677
  return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
1477
1678
  }
1478
1679
 
1680
+ // src/lib/machines.ts
1681
+ import {
1682
+ discoverMachineTopology,
1683
+ resolveMachineRoute
1684
+ } from "@hasna/machines/consumer";
1685
+ function compact(value) {
1686
+ const text = value?.trim();
1687
+ return text ? text : undefined;
1688
+ }
1689
+ function entryToSummary(entry, topology) {
1690
+ return {
1691
+ id: entry.machine_id,
1692
+ hostname: compact(entry.hostname),
1693
+ platform: compact(entry.platform),
1694
+ user: compact(entry.user),
1695
+ workspacePath: compact(entry.workspace_path),
1696
+ route: entry.ssh.route,
1697
+ local: entry.machine_id === topology.local_machine_id || entry.ssh.route === "local",
1698
+ heartbeatStatus: entry.heartbeat_status,
1699
+ tailscaleOnline: entry.tailscale.online,
1700
+ tags: entry.tags
1701
+ };
1702
+ }
1703
+ function machineFromRoute(route, topology) {
1704
+ if (!route.ok || !route.machine_id) {
1705
+ throw new Error(`OpenMachines route not found for machine: ${route.requested_machine_id}`);
1706
+ }
1707
+ const entry = topology.machines.find((machine) => machine.machine_id === route.machine_id);
1708
+ return {
1709
+ id: route.machine_id,
1710
+ requestedId: route.requested_machine_id !== route.machine_id ? route.requested_machine_id : undefined,
1711
+ route: route.route,
1712
+ local: route.local,
1713
+ confidence: route.confidence,
1714
+ workspacePath: compact(entry?.workspace_path),
1715
+ resolvedAt: route.generated_at,
1716
+ packageVersion: route.package.version,
1717
+ warnings: route.warnings.length ? route.warnings : undefined
1718
+ };
1719
+ }
1720
+ function listOpenMachines() {
1721
+ const topology = discoverMachineTopology();
1722
+ return topology.machines.map((entry) => entryToSummary(entry, topology));
1723
+ }
1724
+ function resolveLoopMachine(machineId) {
1725
+ const topology = discoverMachineTopology();
1726
+ const route = resolveMachineRoute(machineId, { topology });
1727
+ return machineFromRoute(route, topology);
1728
+ }
1729
+ function refreshLoopMachine(machine) {
1730
+ return resolveLoopMachine(machine.id);
1731
+ }
1732
+
1479
1733
  // src/lib/executor.ts
1480
1734
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
1481
1735
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
@@ -1496,6 +1750,23 @@ var AUTH_ENV_KEYS = [
1496
1750
  "XDG_STATE_HOME",
1497
1751
  "XDG_CACHE_HOME"
1498
1752
  ];
1753
+ var TRANSPORT_ENV_KEYS = new Set([
1754
+ "BUN_INSTALL",
1755
+ "HOME",
1756
+ "LANG",
1757
+ "LANGUAGE",
1758
+ "LOGNAME",
1759
+ "PATH",
1760
+ "SHELL",
1761
+ "SSH_AGENT_PID",
1762
+ "SSH_AUTH_SOCK",
1763
+ "TERM",
1764
+ "TMP",
1765
+ "TMPDIR",
1766
+ "TEMP",
1767
+ "USER",
1768
+ "XDG_RUNTIME_DIR"
1769
+ ]);
1499
1770
  function appendBounded(current, chunk, maxBytes) {
1500
1771
  const next = current + chunk.toString("utf8");
1501
1772
  if (Buffer.byteLength(next, "utf8") <= maxBytes)
@@ -1522,6 +1793,29 @@ function killProcessGroup(pid) {
1522
1793
  }
1523
1794
  }, 2000).unref();
1524
1795
  }
1796
+ function shellQuote(value) {
1797
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1798
+ }
1799
+ function metadataEnv(metadata) {
1800
+ const env = {};
1801
+ if (metadata.loopId)
1802
+ env.LOOPS_LOOP_ID = metadata.loopId;
1803
+ if (metadata.loopName)
1804
+ env.LOOPS_LOOP_NAME = metadata.loopName;
1805
+ if (metadata.runId)
1806
+ env.LOOPS_RUN_ID = metadata.runId;
1807
+ if (metadata.scheduledFor)
1808
+ env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
1809
+ if (metadata.workflowId)
1810
+ env.LOOPS_WORKFLOW_ID = metadata.workflowId;
1811
+ if (metadata.workflowName)
1812
+ env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
1813
+ if (metadata.workflowRunId)
1814
+ env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1815
+ if (metadata.workflowStepId)
1816
+ env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1817
+ return env;
1818
+ }
1525
1819
  function providerCommand(provider) {
1526
1820
  switch (provider) {
1527
1821
  case "claude":
@@ -1550,7 +1844,7 @@ function agentArgs(target) {
1550
1844
  args.push("--model", target.model);
1551
1845
  if (target.agent)
1552
1846
  args.push("--agent", target.agent);
1553
- args.push(...target.extraArgs ?? [], target.prompt);
1847
+ args.push(...target.extraArgs ?? []);
1554
1848
  return args;
1555
1849
  case "cursor":
1556
1850
  args.push("-p");
@@ -1558,7 +1852,7 @@ function agentArgs(target) {
1558
1852
  args.push("--model", target.model);
1559
1853
  if (target.agent)
1560
1854
  args.push("--agent", target.agent);
1561
- args.push(...target.extraArgs ?? [], target.prompt);
1855
+ args.push(...target.extraArgs ?? []);
1562
1856
  return args;
1563
1857
  case "codewith":
1564
1858
  args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
@@ -1570,7 +1864,7 @@ function agentArgs(target) {
1570
1864
  args.push("--model", target.model);
1571
1865
  if (target.agent)
1572
1866
  args.push("--agent", target.agent);
1573
- args.push(...target.extraArgs ?? [], target.prompt);
1867
+ args.push(...target.extraArgs ?? []);
1574
1868
  return args;
1575
1869
  case "codex":
1576
1870
  args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
@@ -1580,7 +1874,7 @@ function agentArgs(target) {
1580
1874
  args.push("--cd", target.cwd);
1581
1875
  if (target.model)
1582
1876
  args.push("--model", target.model);
1583
- args.push(...target.extraArgs ?? [], target.prompt);
1877
+ args.push(...target.extraArgs ?? []);
1584
1878
  return args;
1585
1879
  case "aicopilot":
1586
1880
  args.push("run", "--format", "json");
@@ -1592,7 +1886,7 @@ function agentArgs(target) {
1592
1886
  args.push("--model", target.model);
1593
1887
  if (target.agent)
1594
1888
  args.push("--agent", target.agent);
1595
- args.push(...target.extraArgs ?? [], target.prompt);
1889
+ args.push(...target.extraArgs ?? []);
1596
1890
  return args;
1597
1891
  case "opencode":
1598
1892
  args.push("run", "--format", "json");
@@ -1604,7 +1898,7 @@ function agentArgs(target) {
1604
1898
  args.push("--model", target.model);
1605
1899
  if (target.agent)
1606
1900
  args.push("--agent", target.agent);
1607
- args.push(...target.extraArgs ?? [], target.prompt);
1901
+ args.push(...target.extraArgs ?? []);
1608
1902
  return args;
1609
1903
  }
1610
1904
  }
@@ -1629,7 +1923,8 @@ function commandSpec(target) {
1629
1923
  cwd: agentTarget.cwd,
1630
1924
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1631
1925
  account: agentTarget.account,
1632
- accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
1926
+ accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
1927
+ stdin: agentTarget.prompt
1633
1928
  };
1634
1929
  }
1635
1930
  function executionEnv(spec, metadata, opts) {
@@ -1642,26 +1937,213 @@ function executionEnv(spec, metadata, opts) {
1642
1937
  }
1643
1938
  Object.assign(env, spec.env ?? {});
1644
1939
  env.PATH = normalizeExecutionPath(env);
1645
- if (metadata.loopId)
1646
- env.LOOPS_LOOP_ID = metadata.loopId;
1647
- if (metadata.loopName)
1648
- env.LOOPS_LOOP_NAME = metadata.loopName;
1649
- if (metadata.runId)
1650
- env.LOOPS_RUN_ID = metadata.runId;
1651
- if (metadata.scheduledFor)
1652
- env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
1653
- if (metadata.workflowId)
1654
- env.LOOPS_WORKFLOW_ID = metadata.workflowId;
1655
- if (metadata.workflowName)
1656
- env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
1657
- if (metadata.workflowRunId)
1658
- env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1659
- if (metadata.workflowStepId)
1660
- env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1940
+ Object.assign(env, metadataEnv(metadata));
1661
1941
  return env;
1662
1942
  }
1943
+ function resolvedMachine(opts) {
1944
+ if (!opts.machine)
1945
+ return;
1946
+ return (opts.machineResolver ?? refreshLoopMachine)(opts.machine);
1947
+ }
1948
+ function commandForShell(spec) {
1949
+ if (!spec.args.length)
1950
+ return spec.command;
1951
+ return [spec.command, ...spec.args.map(shellQuote)].join(" ");
1952
+ }
1953
+ function hereDoc(value) {
1954
+ let delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
1955
+ while (value.split(/\r?\n/).includes(delimiter2)) {
1956
+ delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
1957
+ }
1958
+ return [`cat > "$__OPENLOOPS_STDIN" <<'${delimiter2}'`, value, delimiter2];
1959
+ }
1960
+ function remoteBootstrapLines(spec, metadata) {
1961
+ const lines = [
1962
+ "set -e",
1963
+ '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}"'
1964
+ ];
1965
+ if (spec.cwd)
1966
+ lines.push(`cd ${shellQuote(spec.cwd)}`);
1967
+ if (spec.account) {
1968
+ if (!spec.accountTool)
1969
+ throw new Error("account.tool is required when no provider tool can be inferred");
1970
+ 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)}`);
1971
+ }
1972
+ for (const [key, value] of Object.entries({ ...metadataEnv(metadata), ...spec.env ?? {} })) {
1973
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
1974
+ continue;
1975
+ lines.push(`export ${key}=${shellQuote(value)}`);
1976
+ }
1977
+ return lines;
1978
+ }
1979
+ function remoteScript(spec, metadata) {
1980
+ const lines = remoteBootstrapLines(spec, metadata);
1981
+ let stdinRedirect = "";
1982
+ if (spec.stdin !== undefined) {
1983
+ lines.push('__OPENLOOPS_STDIN="$(mktemp -t openloops-stdin.XXXXXX)"', `trap 'rm -f "$__OPENLOOPS_STDIN"' EXIT`);
1984
+ lines.push(...hereDoc(spec.stdin));
1985
+ stdinRedirect = ' < "$__OPENLOOPS_STDIN"';
1986
+ }
1987
+ const invocation = spec.shell ? `sh -lc ${shellQuote(commandForShell(spec))}${stdinRedirect}` : `${[spec.command, ...spec.args].map(shellQuote).join(" ")}${stdinRedirect}`;
1988
+ lines.push(invocation);
1989
+ return `${lines.join(`
1990
+ `)}
1991
+ `;
1992
+ }
1993
+ function remotePreflightScript(spec, metadata) {
1994
+ return [
1995
+ ...remoteBootstrapLines(spec, metadata),
1996
+ "command -v bash >/dev/null 2>&1",
1997
+ `command -v ${shellQuote(spec.shell ? "sh" : spec.command)} >/dev/null 2>&1`
1998
+ ].join(`
1999
+ `);
2000
+ }
2001
+ function transportEnv(opts) {
2002
+ const source = opts.env ?? process.env;
2003
+ const env = {};
2004
+ for (const [key, value] of Object.entries(source)) {
2005
+ if (value === undefined)
2006
+ continue;
2007
+ if (TRANSPORT_ENV_KEYS.has(key) || key.startsWith("LC_"))
2008
+ env[key] = value;
2009
+ }
2010
+ env.PATH = normalizeExecutionPath(env);
2011
+ return env;
2012
+ }
2013
+ function preflightRemoteSpec(spec, machine, metadata, opts) {
2014
+ const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2015
+ const result = spawnSync2(plan.command, plan.args, {
2016
+ encoding: "utf8",
2017
+ env: transportEnv(opts),
2018
+ input: remotePreflightScript(spec, metadata),
2019
+ stdio: ["pipe", "pipe", "pipe"],
2020
+ timeout: 15000
2021
+ });
2022
+ if (result.error)
2023
+ throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
2024
+ if ((result.status ?? 1) !== 0) {
2025
+ const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
2026
+ throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
2027
+ }
2028
+ }
2029
+ async function executeRemoteSpec(spec, machine, metadata, opts) {
2030
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
2031
+ const startedAt = nowIso();
2032
+ let stdout = "";
2033
+ let stderr = "";
2034
+ let timedOut = false;
2035
+ let exitCode;
2036
+ let error;
2037
+ let plan;
2038
+ let script;
2039
+ try {
2040
+ plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2041
+ script = remoteScript(spec, metadata);
2042
+ } catch (err) {
2043
+ return {
2044
+ status: "failed",
2045
+ stdout: "",
2046
+ stderr: "",
2047
+ error: err instanceof Error ? err.message : String(err),
2048
+ startedAt,
2049
+ finishedAt: nowIso(),
2050
+ durationMs: 0
2051
+ };
2052
+ }
2053
+ const child = spawn(plan.command, plan.args, {
2054
+ env: transportEnv(opts),
2055
+ detached: true,
2056
+ stdio: ["pipe", "pipe", "pipe"]
2057
+ });
2058
+ if (child.pid)
2059
+ opts.onSpawn?.(child.pid);
2060
+ child.stdin?.on("error", (err) => {
2061
+ if (err.code !== "EPIPE")
2062
+ error = err.message;
2063
+ });
2064
+ child.stdin?.end(script);
2065
+ const abortHandler = () => {
2066
+ error = "cancelled";
2067
+ if (child.pid)
2068
+ killProcessGroup(child.pid);
2069
+ };
2070
+ if (opts.signal?.aborted)
2071
+ abortHandler();
2072
+ opts.signal?.addEventListener("abort", abortHandler, { once: true });
2073
+ child.stdout?.on("data", (chunk) => {
2074
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
2075
+ });
2076
+ child.stderr?.on("data", (chunk) => {
2077
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
2078
+ });
2079
+ const timer = setTimeout(() => {
2080
+ timedOut = true;
2081
+ if (child.pid)
2082
+ killProcessGroup(child.pid);
2083
+ }, spec.timeoutMs);
2084
+ timer.unref();
2085
+ try {
2086
+ const [code, signal] = await once(child, "exit");
2087
+ if (typeof code === "number")
2088
+ exitCode = code;
2089
+ if (signal)
2090
+ error = `terminated by ${signal}`;
2091
+ } catch (err) {
2092
+ error = err instanceof Error ? err.message : String(err);
2093
+ } finally {
2094
+ clearTimeout(timer);
2095
+ opts.signal?.removeEventListener("abort", abortHandler);
2096
+ }
2097
+ const finishedAt = nowIso();
2098
+ const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
2099
+ if (timedOut) {
2100
+ return {
2101
+ status: "timed_out",
2102
+ exitCode,
2103
+ stdout,
2104
+ stderr,
2105
+ error: `timed out after ${spec.timeoutMs}ms`,
2106
+ pid: child.pid,
2107
+ startedAt,
2108
+ finishedAt,
2109
+ durationMs
2110
+ };
2111
+ }
2112
+ if (error || exitCode !== 0) {
2113
+ return {
2114
+ status: "failed",
2115
+ exitCode,
2116
+ stdout,
2117
+ stderr,
2118
+ error: error ?? `remote process on ${machine.id} exited with code ${exitCode ?? "unknown"}`,
2119
+ pid: child.pid,
2120
+ startedAt,
2121
+ finishedAt,
2122
+ durationMs
2123
+ };
2124
+ }
2125
+ return {
2126
+ status: "succeeded",
2127
+ exitCode,
2128
+ stdout,
2129
+ stderr,
2130
+ pid: child.pid,
2131
+ startedAt,
2132
+ finishedAt,
2133
+ durationMs
2134
+ };
2135
+ }
1663
2136
  function preflightTarget(target, metadata = {}, opts = {}) {
1664
2137
  const spec = commandSpec(target);
2138
+ const machine = resolvedMachine(opts);
2139
+ if (machine && !machine.local) {
2140
+ preflightRemoteSpec(spec, machine, metadata, opts);
2141
+ return {
2142
+ command: spec.command,
2143
+ accountProfile: spec.account?.profile,
2144
+ accountTool: spec.accountTool
2145
+ };
2146
+ }
1665
2147
  const env = executionEnv(spec, metadata, opts);
1666
2148
  if (!spec.shell && !executableExists(spec.command, env)) {
1667
2149
  throw new Error(commandNotFoundMessage(spec.command, env));
@@ -1674,6 +2156,9 @@ function preflightTarget(target, metadata = {}, opts = {}) {
1674
2156
  }
1675
2157
  async function executeTarget(target, metadata = {}, opts = {}) {
1676
2158
  const spec = commandSpec(target);
2159
+ const machine = resolvedMachine(opts);
2160
+ if (machine && !machine.local)
2161
+ return executeRemoteSpec(spec, machine, metadata, opts);
1677
2162
  const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
1678
2163
  const startedAt = nowIso();
1679
2164
  let stdout = "";
@@ -1698,10 +2183,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1698
2183
  env,
1699
2184
  shell: spec.shell ?? false,
1700
2185
  detached: true,
1701
- stdio: ["ignore", "pipe", "pipe"]
2186
+ stdio: spec.stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
1702
2187
  });
1703
2188
  if (child.pid)
1704
2189
  opts.onSpawn?.(child.pid);
2190
+ if (spec.stdin !== undefined && child.stdin) {
2191
+ child.stdin.on("error", (err) => {
2192
+ if (err.code !== "EPIPE")
2193
+ error = err.message;
2194
+ });
2195
+ child.stdin.end(spec.stdin);
2196
+ }
1705
2197
  const abortHandler = () => {
1706
2198
  error = "cancelled";
1707
2199
  if (child.pid)
@@ -1710,10 +2202,10 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1710
2202
  if (opts.signal?.aborted)
1711
2203
  abortHandler();
1712
2204
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
1713
- child.stdout.on("data", (chunk) => {
2205
+ child.stdout?.on("data", (chunk) => {
1714
2206
  stdout = appendBounded(stdout, chunk, maxOutputBytes);
1715
2207
  });
1716
- child.stderr.on("data", (chunk) => {
2208
+ child.stderr?.on("data", (chunk) => {
1717
2209
  stderr = appendBounded(stderr, chunk, maxOutputBytes);
1718
2210
  });
1719
2211
  const timer = setTimeout(() => {
@@ -1782,7 +2274,7 @@ async function executeLoop(loop, run, opts = {}) {
1782
2274
  loopName: loop.name,
1783
2275
  runId: run.id,
1784
2276
  scheduledFor: run.scheduledFor
1785
- }, opts);
2277
+ }, { ...opts, machine: opts.machine ?? loop.machine });
1786
2278
  }
1787
2279
 
1788
2280
  // src/lib/workflow-runner.ts
@@ -1812,7 +2304,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1812
2304
  loop: opts.loop,
1813
2305
  loopRun: opts.loopRun,
1814
2306
  scheduledFor: opts.scheduledFor,
1815
- idempotencyKey: opts.idempotencyKey
2307
+ idempotencyKey: opts.idempotencyKey,
2308
+ daemonLeaseId: opts.daemonLeaseId
1816
2309
  });
1817
2310
  const startedAt = run.startedAt ?? nowIso();
1818
2311
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
@@ -1846,12 +2339,14 @@ async function executeWorkflow(store, workflow, opts = {}) {
1846
2339
  return !dependencyStep?.continueOnFailure;
1847
2340
  });
1848
2341
  if (blockedBy) {
1849
- store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`);
2342
+ opts.beforePersist?.();
2343
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
1850
2344
  blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
1851
2345
  terminalStatus = "failed";
1852
2346
  continue;
1853
2347
  }
1854
- const startedStep = store.startWorkflowStepRun(run.id, step.id);
2348
+ opts.beforePersist?.();
2349
+ const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
1855
2350
  if (startedStep.status !== "running") {
1856
2351
  terminalStatus = "failed";
1857
2352
  blockingError = `step ${step.id} could not start because workflow is no longer running`;
@@ -1881,9 +2376,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
1881
2376
  try {
1882
2377
  result = await executeTarget(targetWithStepAccount(step), metadata, {
1883
2378
  ...opts,
2379
+ machine: opts.machine ?? opts.loop?.machine,
1884
2380
  signal: controller.signal,
1885
2381
  onSpawn: (pid) => {
1886
- store.markWorkflowStepPid(run.id, step.id, pid);
2382
+ opts.beforePersist?.();
2383
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
1887
2384
  opts.onSpawn?.(pid);
1888
2385
  }
1889
2386
  });
@@ -1911,6 +2408,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
1911
2408
  blockingError = "workflow run was cancelled";
1912
2409
  break;
1913
2410
  }
2411
+ opts.beforePersist?.();
1914
2412
  store.finalizeWorkflowStepRun(run.id, step.id, {
1915
2413
  status: result.status,
1916
2414
  finishedAt: result.finishedAt,
@@ -1919,6 +2417,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1919
2417
  stderr: result.stderr,
1920
2418
  exitCode: result.exitCode,
1921
2419
  error: result.error
2420
+ }, {
2421
+ daemonLeaseId: opts.daemonLeaseId
1922
2422
  });
1923
2423
  if (result.status !== "succeeded" && !step.continueOnFailure) {
1924
2424
  terminalStatus = result.status;
@@ -1930,7 +2430,9 @@ async function executeWorkflow(store, workflow, opts = {}) {
1930
2430
  for (const step of ordered) {
1931
2431
  const existing = store.getWorkflowStepRun(run.id, step.id);
1932
2432
  if (existing?.status === "pending" || existing?.status === "running") {
1933
- store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run");
2433
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
2434
+ daemonLeaseId: opts.daemonLeaseId
2435
+ });
1934
2436
  }
1935
2437
  }
1936
2438
  }
@@ -1940,10 +2442,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
1940
2442
  const steps2 = store.listWorkflowStepRuns(run.id);
1941
2443
  return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
1942
2444
  }
2445
+ opts.beforePersist?.();
1943
2446
  const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
1944
2447
  finishedAt,
1945
2448
  durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
1946
2449
  error: blockingError
2450
+ }, {
2451
+ daemonLeaseId: opts.daemonLeaseId
1947
2452
  });
1948
2453
  const steps = store.listWorkflowStepRuns(run.id);
1949
2454
  return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
@@ -1991,52 +2496,81 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
1991
2496
 
1992
2497
  // src/lib/scheduler.ts
1993
2498
  function manualRunScheduledFor(loop, now = new Date) {
1994
- if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
2499
+ if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
1995
2500
  return loop.retryScheduledFor ?? loop.nextRunAt;
1996
2501
  }
1997
2502
  return now.toISOString();
1998
2503
  }
1999
2504
  function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
2505
+ if (loop.status !== "active")
2506
+ return false;
2000
2507
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
2001
2508
  return false;
2002
2509
  return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
2003
2510
  }
2511
+ function manualRunSource(loop, scheduledFor, now = new Date) {
2512
+ if (loop.status !== "active")
2513
+ return "ad_hoc";
2514
+ if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
2515
+ return "ad_hoc";
2516
+ if (loop.retryScheduledFor && scheduledFor === loop.retryScheduledFor)
2517
+ return "retry_slot";
2518
+ return "due_slot";
2519
+ }
2004
2520
  function nextAfterRetry(loop, now) {
2005
2521
  return new Date(now.getTime() + loop.retryDelayMs).toISOString();
2006
2522
  }
2007
- function advanceLoop(store, loop, run, finishedAt, succeeded) {
2523
+ function isDaemonLeaseLost(error) {
2524
+ return error instanceof Error && error.message === "daemon lease lost";
2525
+ }
2526
+ function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
2008
2527
  if (run.status === "running")
2009
2528
  return;
2010
2529
  const current = store.getLoop(loop.id);
2011
2530
  if (!current || current.status !== "active")
2012
2531
  return;
2013
- const shouldRetry = !succeeded && run.attempt < loop.maxAttempts;
2532
+ if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
2533
+ return;
2534
+ const shouldRetry = !succeeded && run.attempt < current.maxAttempts;
2014
2535
  if (shouldRetry) {
2015
- store.updateLoop(loop.id, {
2536
+ store.updateLoop(current.id, {
2016
2537
  status: "active",
2017
- nextRunAt: nextAfterRetry(loop, finishedAt),
2538
+ nextRunAt: nextAfterRetry(current, finishedAt),
2018
2539
  retryScheduledFor: run.scheduledFor
2019
- });
2540
+ }, { daemonLeaseId: opts.daemonLeaseId });
2541
+ return;
2542
+ }
2543
+ const deferredRetry = store.nextRetryableRun(current.id, current.maxAttempts, run.scheduledFor);
2544
+ if (deferredRetry) {
2545
+ store.updateLoop(current.id, {
2546
+ status: "active",
2547
+ nextRunAt: nextAfterRetry(current, finishedAt),
2548
+ retryScheduledFor: deferredRetry.scheduledFor
2549
+ }, { daemonLeaseId: opts.daemonLeaseId });
2020
2550
  return;
2021
2551
  }
2022
- const nextRunAt = computeNextAfter(loop.schedule, new Date(run.scheduledFor), finishedAt);
2023
- store.updateLoop(loop.id, {
2552
+ const nextRunAt = computeNextAfter(current.schedule, new Date(run.scheduledFor), finishedAt);
2553
+ store.updateLoop(current.id, {
2024
2554
  status: nextRunAt ? "active" : "stopped",
2025
2555
  nextRunAt,
2026
2556
  retryScheduledFor: undefined
2027
- });
2557
+ }, { daemonLeaseId: opts.daemonLeaseId });
2028
2558
  }
2029
2559
  async function executeClaimedRun(deps) {
2030
2560
  let heartbeat;
2031
2561
  const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
2032
2562
  heartbeat = setInterval(() => {
2033
- deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
2563
+ deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs, new Date, {
2564
+ daemonLeaseId: deps.daemonLeaseId
2565
+ });
2034
2566
  }, heartbeatEveryMs);
2035
2567
  heartbeat.unref();
2036
2568
  try {
2037
2569
  const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
2038
- onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
2570
+ daemonLeaseId: deps.daemonLeaseId,
2571
+ onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId, { daemonLeaseId: deps.daemonLeaseId })
2039
2572
  })))(deps.loop, deps.run);
2573
+ deps.beforeFinalize?.(deps.loop, deps.run);
2040
2574
  return deps.store.finalizeRun(deps.run.id, {
2041
2575
  status: result.status,
2042
2576
  finishedAt: result.finishedAt,
@@ -2048,10 +2582,16 @@ async function executeClaimedRun(deps) {
2048
2582
  pid: result.pid
2049
2583
  }, {
2050
2584
  claimedBy: deps.runnerId,
2585
+ daemonLeaseId: deps.daemonLeaseId,
2051
2586
  now: deps.now?.() ?? new Date(result.finishedAt)
2052
2587
  });
2053
2588
  } catch (err) {
2054
2589
  deps.onError?.(deps.loop, err);
2590
+ try {
2591
+ deps.beforeFinalize?.(deps.loop, deps.run);
2592
+ } catch {
2593
+ return deps.store.getRun(deps.run.id) ?? deps.run;
2594
+ }
2055
2595
  const finishedAt = new Date;
2056
2596
  return deps.store.finalizeRun(deps.run.id, {
2057
2597
  status: "failed",
@@ -2062,6 +2602,7 @@ async function executeClaimedRun(deps) {
2062
2602
  error: err instanceof Error ? err.message : String(err)
2063
2603
  }, {
2064
2604
  claimedBy: deps.runnerId,
2605
+ daemonLeaseId: deps.daemonLeaseId,
2065
2606
  now: deps.now?.() ?? finishedAt
2066
2607
  });
2067
2608
  } finally {
@@ -2071,15 +2612,33 @@ async function executeClaimedRun(deps) {
2071
2612
  }
2072
2613
  async function runSlot(deps, loop, scheduledFor) {
2073
2614
  const now = deps.now?.() ?? new Date;
2615
+ deps.beforeRun?.(loop, scheduledFor);
2074
2616
  if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
2075
- const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
2076
- advanceLoop(deps.store, loop, skipped, now, true);
2617
+ let skipped;
2618
+ try {
2619
+ skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
2620
+ daemonLeaseId: deps.daemonLeaseId
2621
+ });
2622
+ } catch (error) {
2623
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
2624
+ return;
2625
+ throw error;
2626
+ }
2627
+ advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
2077
2628
  deps.onRun?.(skipped);
2078
2629
  return skipped;
2079
2630
  }
2080
- const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
2631
+ let claim;
2632
+ try {
2633
+ claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
2634
+ } catch (error) {
2635
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
2636
+ return;
2637
+ throw error;
2638
+ }
2081
2639
  if (!claim)
2082
2640
  return;
2641
+ deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
2083
2642
  deps.onRun?.(claim.run);
2084
2643
  const finalRun = await executeClaimedRun({
2085
2644
  store: deps.store,
@@ -2088,21 +2647,42 @@ async function runSlot(deps, loop, scheduledFor) {
2088
2647
  run: claim.run,
2089
2648
  now: deps.now,
2090
2649
  execute: deps.execute,
2650
+ beforeFinalize: deps.beforeFinalize,
2651
+ daemonLeaseId: deps.daemonLeaseId,
2091
2652
  onError: deps.onError
2092
2653
  });
2093
- advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
2654
+ advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded", { daemonLeaseId: deps.daemonLeaseId });
2094
2655
  deps.onRun?.(finalRun);
2095
2656
  return finalRun;
2096
2657
  }
2097
2658
  async function tick(deps) {
2098
2659
  const now = deps.now?.() ?? new Date;
2099
- const recovered = deps.store.recoverExpiredRunLeases(now);
2660
+ const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
2661
+ const recoveredByLoop = new Map;
2100
2662
  for (const run of recovered) {
2101
- const loop = deps.store.getLoop(run.loopId);
2102
- if (loop)
2103
- advanceLoop(deps.store, loop, run, new Date(run.finishedAt ?? now), false);
2663
+ recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
2664
+ }
2665
+ for (const runs of recoveredByLoop.values()) {
2666
+ const loop = deps.store.getLoop(runs[0].loopId);
2667
+ if (!loop)
2668
+ continue;
2669
+ const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
2670
+ if (retryable) {
2671
+ advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
2672
+ daemonLeaseId: deps.daemonLeaseId
2673
+ });
2674
+ continue;
2675
+ }
2676
+ for (const run of runs) {
2677
+ const current = deps.store.getLoop(run.loopId);
2678
+ if (current) {
2679
+ advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
2680
+ daemonLeaseId: deps.daemonLeaseId
2681
+ });
2682
+ }
2683
+ }
2104
2684
  }
2105
- const expired = deps.store.expireLoops(now);
2685
+ const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
2106
2686
  const claimed = [];
2107
2687
  const completed = [];
2108
2688
  const skipped = [];
@@ -2320,8 +2900,10 @@ async function runDaemon(opts = {}) {
2320
2900
  const result = await tick({
2321
2901
  store,
2322
2902
  runnerId,
2903
+ daemonLeaseId: leaseId,
2904
+ beforeRun: () => ensureLease(),
2323
2905
  execute: async (loop, run) => {
2324
- const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs / 3));
2906
+ const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
2325
2907
  const timer = setInterval(() => {
2326
2908
  try {
2327
2909
  ensureLease();
@@ -2333,8 +2915,14 @@ async function runDaemon(opts = {}) {
2333
2915
  try {
2334
2916
  const result2 = await executeLoopTarget(store, loop, run, {
2335
2917
  signal: runAbort.signal,
2336
- onSpawn: (pid) => store.markRunPid(run.id, pid, runnerId)
2918
+ beforePersist: () => ensureLease(),
2919
+ daemonLeaseId: leaseId,
2920
+ onSpawn: (pid) => {
2921
+ ensureLease();
2922
+ store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
2923
+ }
2337
2924
  });
2925
+ ensureLease();
2338
2926
  if (leaseLost)
2339
2927
  throw new Error("daemon lease lost during run");
2340
2928
  return result2;
@@ -2342,6 +2930,7 @@ async function runDaemon(opts = {}) {
2342
2930
  clearInterval(timer);
2343
2931
  }
2344
2932
  },
2933
+ beforeFinalize: () => ensureLease(),
2345
2934
  onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
2346
2935
  });
2347
2936
  const changed = result.completed.length + result.skipped.length + result.recovered.length + result.expired.length;
@@ -2387,7 +2976,7 @@ async function startDaemon(opts) {
2387
2976
 
2388
2977
  // src/daemon/install.ts
2389
2978
  import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
2390
- import { spawnSync as spawnSync2 } from "child_process";
2979
+ import { spawnSync as spawnSync3 } from "child_process";
2391
2980
  import { dirname as dirname3 } from "path";
2392
2981
  function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
2393
2982
  const command = [execPath, cliEntry, ...args].join(" ");
@@ -2457,7 +3046,7 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
2457
3046
  function enableStartup(result) {
2458
3047
  const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
2459
3048
  return commands.map((command) => {
2460
- const run = spawnSync2("sh", ["-c", command], {
3049
+ const run = spawnSync3("sh", ["-c", command], {
2461
3050
  encoding: "utf8",
2462
3051
  stdio: ["ignore", "pipe", "pipe"]
2463
3052
  });
@@ -2472,7 +3061,7 @@ function enableStartup(result) {
2472
3061
 
2473
3062
  // src/daemon/index.ts
2474
3063
  var program = new Command;
2475
- program.name("loops-daemon").description("OpenLoops daemon helper").version("0.3.2");
3064
+ program.name("loops-daemon").description("OpenLoops daemon helper").version("0.3.4");
2476
3065
  program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs }));
2477
3066
  program.command("start").action(async () => {
2478
3067
  const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });