@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/cli/index.js CHANGED
@@ -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();
@@ -1315,11 +1515,14 @@ import { Command } from "commander";
1315
1515
 
1316
1516
  // src/lib/format.ts
1317
1517
  var TEXT_OUTPUT_LIMIT = 32 * 1024;
1318
- function redact(value, visible = 80) {
1518
+ var SENSITIVE_PAYLOAD_KEYS = new Set(["env", "error", "prompt", "reason", "stderr", "stdout"]);
1519
+ function redact(value, visible = 0) {
1319
1520
  if (!value)
1320
1521
  return value;
1321
1522
  if (value.length <= visible)
1322
1523
  return value;
1524
+ if (visible <= 0)
1525
+ return `[redacted ${value.length} chars]`;
1323
1526
  return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
1324
1527
  }
1325
1528
  function truncateTextOutput(value) {
@@ -1328,6 +1531,21 @@ function truncateTextOutput(value) {
1328
1531
  return `${value.slice(0, TEXT_OUTPUT_LIMIT)}
1329
1532
  [truncated ${value.length - TEXT_OUTPUT_LIMIT} chars]`;
1330
1533
  }
1534
+ function redactSensitivePayload(value, key) {
1535
+ if (key && SENSITIVE_PAYLOAD_KEYS.has(key)) {
1536
+ if (typeof value === "string")
1537
+ return redact(value);
1538
+ if (value === undefined || value === null)
1539
+ return value;
1540
+ return "[redacted]";
1541
+ }
1542
+ if (Array.isArray(value))
1543
+ return value.map((item) => redactSensitivePayload(item));
1544
+ if (value && typeof value === "object") {
1545
+ return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, redactSensitivePayload(entryValue, entryKey)]));
1546
+ }
1547
+ return value;
1548
+ }
1331
1549
  function textOutputBlocks(value, opts = {}) {
1332
1550
  const indent = opts.indent ?? "";
1333
1551
  const nested = `${indent} `;
@@ -1359,6 +1577,14 @@ function publicRun(run, showOutput = false) {
1359
1577
  stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
1360
1578
  };
1361
1579
  }
1580
+ function publicExecutorResult(result, showOutput = false) {
1581
+ return {
1582
+ ...result,
1583
+ stdout: showOutput ? result.stdout : result.stdout ? `[redacted ${result.stdout.length} chars]` : undefined,
1584
+ stderr: showOutput ? result.stderr : result.stderr ? `[redacted ${result.stderr.length} chars]` : undefined,
1585
+ error: redact(result.error)
1586
+ };
1587
+ }
1362
1588
  function publicWorkflow(workflow) {
1363
1589
  return {
1364
1590
  ...workflow,
@@ -1369,22 +1595,24 @@ function publicWorkflow(workflow) {
1369
1595
  };
1370
1596
  }
1371
1597
  function publicWorkflowRun(run) {
1372
- return { ...run };
1598
+ return { ...run, error: redact(run.error) };
1373
1599
  }
1374
1600
  function publicWorkflowStepRun(run, showOutput = false) {
1375
1601
  return {
1376
1602
  ...run,
1377
1603
  stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
1378
- stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
1604
+ stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
1605
+ error: redact(run.error)
1379
1606
  };
1380
1607
  }
1381
1608
  function publicWorkflowEvent(event) {
1382
- return { ...event };
1609
+ return { ...event, payload: redactSensitivePayload(event.payload) };
1383
1610
  }
1384
1611
 
1385
1612
  // src/lib/executor.ts
1386
- import { spawn } from "child_process";
1613
+ import { spawn, spawnSync as spawnSync2 } from "child_process";
1387
1614
  import { once } from "events";
1615
+ import { resolveMachineCommand } from "@hasna/machines/consumer";
1388
1616
 
1389
1617
  // src/lib/accounts.ts
1390
1618
  import { spawnSync } from "child_process";
@@ -1541,6 +1769,59 @@ function commandNotFoundMessage(command, env = process.env) {
1541
1769
  return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
1542
1770
  }
1543
1771
 
1772
+ // src/lib/machines.ts
1773
+ import {
1774
+ discoverMachineTopology,
1775
+ resolveMachineRoute
1776
+ } from "@hasna/machines/consumer";
1777
+ function compact(value) {
1778
+ const text = value?.trim();
1779
+ return text ? text : undefined;
1780
+ }
1781
+ function entryToSummary(entry, topology) {
1782
+ return {
1783
+ id: entry.machine_id,
1784
+ hostname: compact(entry.hostname),
1785
+ platform: compact(entry.platform),
1786
+ user: compact(entry.user),
1787
+ workspacePath: compact(entry.workspace_path),
1788
+ route: entry.ssh.route,
1789
+ local: entry.machine_id === topology.local_machine_id || entry.ssh.route === "local",
1790
+ heartbeatStatus: entry.heartbeat_status,
1791
+ tailscaleOnline: entry.tailscale.online,
1792
+ tags: entry.tags
1793
+ };
1794
+ }
1795
+ function machineFromRoute(route, topology) {
1796
+ if (!route.ok || !route.machine_id) {
1797
+ throw new Error(`OpenMachines route not found for machine: ${route.requested_machine_id}`);
1798
+ }
1799
+ const entry = topology.machines.find((machine) => machine.machine_id === route.machine_id);
1800
+ return {
1801
+ id: route.machine_id,
1802
+ requestedId: route.requested_machine_id !== route.machine_id ? route.requested_machine_id : undefined,
1803
+ route: route.route,
1804
+ local: route.local,
1805
+ confidence: route.confidence,
1806
+ workspacePath: compact(entry?.workspace_path),
1807
+ resolvedAt: route.generated_at,
1808
+ packageVersion: route.package.version,
1809
+ warnings: route.warnings.length ? route.warnings : undefined
1810
+ };
1811
+ }
1812
+ function listOpenMachines() {
1813
+ const topology = discoverMachineTopology();
1814
+ return topology.machines.map((entry) => entryToSummary(entry, topology));
1815
+ }
1816
+ function resolveLoopMachine(machineId) {
1817
+ const topology = discoverMachineTopology();
1818
+ const route = resolveMachineRoute(machineId, { topology });
1819
+ return machineFromRoute(route, topology);
1820
+ }
1821
+ function refreshLoopMachine(machine) {
1822
+ return resolveLoopMachine(machine.id);
1823
+ }
1824
+
1544
1825
  // src/lib/executor.ts
1545
1826
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
1546
1827
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
@@ -1561,6 +1842,23 @@ var AUTH_ENV_KEYS = [
1561
1842
  "XDG_STATE_HOME",
1562
1843
  "XDG_CACHE_HOME"
1563
1844
  ];
1845
+ var TRANSPORT_ENV_KEYS = new Set([
1846
+ "BUN_INSTALL",
1847
+ "HOME",
1848
+ "LANG",
1849
+ "LANGUAGE",
1850
+ "LOGNAME",
1851
+ "PATH",
1852
+ "SHELL",
1853
+ "SSH_AGENT_PID",
1854
+ "SSH_AUTH_SOCK",
1855
+ "TERM",
1856
+ "TMP",
1857
+ "TMPDIR",
1858
+ "TEMP",
1859
+ "USER",
1860
+ "XDG_RUNTIME_DIR"
1861
+ ]);
1564
1862
  function appendBounded(current, chunk, maxBytes) {
1565
1863
  const next = current + chunk.toString("utf8");
1566
1864
  if (Buffer.byteLength(next, "utf8") <= maxBytes)
@@ -1587,6 +1885,29 @@ function killProcessGroup(pid) {
1587
1885
  }
1588
1886
  }, 2000).unref();
1589
1887
  }
1888
+ function shellQuote(value) {
1889
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1890
+ }
1891
+ function metadataEnv(metadata) {
1892
+ const env = {};
1893
+ if (metadata.loopId)
1894
+ env.LOOPS_LOOP_ID = metadata.loopId;
1895
+ if (metadata.loopName)
1896
+ env.LOOPS_LOOP_NAME = metadata.loopName;
1897
+ if (metadata.runId)
1898
+ env.LOOPS_RUN_ID = metadata.runId;
1899
+ if (metadata.scheduledFor)
1900
+ env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
1901
+ if (metadata.workflowId)
1902
+ env.LOOPS_WORKFLOW_ID = metadata.workflowId;
1903
+ if (metadata.workflowName)
1904
+ env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
1905
+ if (metadata.workflowRunId)
1906
+ env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1907
+ if (metadata.workflowStepId)
1908
+ env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1909
+ return env;
1910
+ }
1590
1911
  function providerCommand(provider) {
1591
1912
  switch (provider) {
1592
1913
  case "claude":
@@ -1615,7 +1936,7 @@ function agentArgs(target) {
1615
1936
  args.push("--model", target.model);
1616
1937
  if (target.agent)
1617
1938
  args.push("--agent", target.agent);
1618
- args.push(...target.extraArgs ?? [], target.prompt);
1939
+ args.push(...target.extraArgs ?? []);
1619
1940
  return args;
1620
1941
  case "cursor":
1621
1942
  args.push("-p");
@@ -1623,7 +1944,7 @@ function agentArgs(target) {
1623
1944
  args.push("--model", target.model);
1624
1945
  if (target.agent)
1625
1946
  args.push("--agent", target.agent);
1626
- args.push(...target.extraArgs ?? [], target.prompt);
1947
+ args.push(...target.extraArgs ?? []);
1627
1948
  return args;
1628
1949
  case "codewith":
1629
1950
  args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
@@ -1635,7 +1956,7 @@ function agentArgs(target) {
1635
1956
  args.push("--model", target.model);
1636
1957
  if (target.agent)
1637
1958
  args.push("--agent", target.agent);
1638
- args.push(...target.extraArgs ?? [], target.prompt);
1959
+ args.push(...target.extraArgs ?? []);
1639
1960
  return args;
1640
1961
  case "codex":
1641
1962
  args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
@@ -1645,7 +1966,7 @@ function agentArgs(target) {
1645
1966
  args.push("--cd", target.cwd);
1646
1967
  if (target.model)
1647
1968
  args.push("--model", target.model);
1648
- args.push(...target.extraArgs ?? [], target.prompt);
1969
+ args.push(...target.extraArgs ?? []);
1649
1970
  return args;
1650
1971
  case "aicopilot":
1651
1972
  args.push("run", "--format", "json");
@@ -1657,7 +1978,7 @@ function agentArgs(target) {
1657
1978
  args.push("--model", target.model);
1658
1979
  if (target.agent)
1659
1980
  args.push("--agent", target.agent);
1660
- args.push(...target.extraArgs ?? [], target.prompt);
1981
+ args.push(...target.extraArgs ?? []);
1661
1982
  return args;
1662
1983
  case "opencode":
1663
1984
  args.push("run", "--format", "json");
@@ -1669,7 +1990,7 @@ function agentArgs(target) {
1669
1990
  args.push("--model", target.model);
1670
1991
  if (target.agent)
1671
1992
  args.push("--agent", target.agent);
1672
- args.push(...target.extraArgs ?? [], target.prompt);
1993
+ args.push(...target.extraArgs ?? []);
1673
1994
  return args;
1674
1995
  }
1675
1996
  }
@@ -1694,7 +2015,8 @@ function commandSpec(target) {
1694
2015
  cwd: agentTarget.cwd,
1695
2016
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1696
2017
  account: agentTarget.account,
1697
- accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
2018
+ accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
2019
+ stdin: agentTarget.prompt
1698
2020
  };
1699
2021
  }
1700
2022
  function executionEnv(spec, metadata, opts) {
@@ -1707,26 +2029,213 @@ function executionEnv(spec, metadata, opts) {
1707
2029
  }
1708
2030
  Object.assign(env, spec.env ?? {});
1709
2031
  env.PATH = normalizeExecutionPath(env);
1710
- if (metadata.loopId)
1711
- env.LOOPS_LOOP_ID = metadata.loopId;
1712
- if (metadata.loopName)
1713
- env.LOOPS_LOOP_NAME = metadata.loopName;
1714
- if (metadata.runId)
1715
- env.LOOPS_RUN_ID = metadata.runId;
1716
- if (metadata.scheduledFor)
1717
- env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
1718
- if (metadata.workflowId)
1719
- env.LOOPS_WORKFLOW_ID = metadata.workflowId;
1720
- if (metadata.workflowName)
1721
- env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
1722
- if (metadata.workflowRunId)
1723
- env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1724
- if (metadata.workflowStepId)
1725
- env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
2032
+ Object.assign(env, metadataEnv(metadata));
1726
2033
  return env;
1727
2034
  }
2035
+ function resolvedMachine(opts) {
2036
+ if (!opts.machine)
2037
+ return;
2038
+ return (opts.machineResolver ?? refreshLoopMachine)(opts.machine);
2039
+ }
2040
+ function commandForShell(spec) {
2041
+ if (!spec.args.length)
2042
+ return spec.command;
2043
+ return [spec.command, ...spec.args.map(shellQuote)].join(" ");
2044
+ }
2045
+ function hereDoc(value) {
2046
+ let delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
2047
+ while (value.split(/\r?\n/).includes(delimiter2)) {
2048
+ delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
2049
+ }
2050
+ return [`cat > "$__OPENLOOPS_STDIN" <<'${delimiter2}'`, value, delimiter2];
2051
+ }
2052
+ function remoteBootstrapLines(spec, metadata) {
2053
+ const lines = [
2054
+ "set -e",
2055
+ '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}"'
2056
+ ];
2057
+ if (spec.cwd)
2058
+ lines.push(`cd ${shellQuote(spec.cwd)}`);
2059
+ if (spec.account) {
2060
+ if (!spec.accountTool)
2061
+ throw new Error("account.tool is required when no provider tool can be inferred");
2062
+ 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)}`);
2063
+ }
2064
+ for (const [key, value] of Object.entries({ ...metadataEnv(metadata), ...spec.env ?? {} })) {
2065
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
2066
+ continue;
2067
+ lines.push(`export ${key}=${shellQuote(value)}`);
2068
+ }
2069
+ return lines;
2070
+ }
2071
+ function remoteScript(spec, metadata) {
2072
+ const lines = remoteBootstrapLines(spec, metadata);
2073
+ let stdinRedirect = "";
2074
+ if (spec.stdin !== undefined) {
2075
+ lines.push('__OPENLOOPS_STDIN="$(mktemp -t openloops-stdin.XXXXXX)"', `trap 'rm -f "$__OPENLOOPS_STDIN"' EXIT`);
2076
+ lines.push(...hereDoc(spec.stdin));
2077
+ stdinRedirect = ' < "$__OPENLOOPS_STDIN"';
2078
+ }
2079
+ const invocation = spec.shell ? `sh -lc ${shellQuote(commandForShell(spec))}${stdinRedirect}` : `${[spec.command, ...spec.args].map(shellQuote).join(" ")}${stdinRedirect}`;
2080
+ lines.push(invocation);
2081
+ return `${lines.join(`
2082
+ `)}
2083
+ `;
2084
+ }
2085
+ function remotePreflightScript(spec, metadata) {
2086
+ return [
2087
+ ...remoteBootstrapLines(spec, metadata),
2088
+ "command -v bash >/dev/null 2>&1",
2089
+ `command -v ${shellQuote(spec.shell ? "sh" : spec.command)} >/dev/null 2>&1`
2090
+ ].join(`
2091
+ `);
2092
+ }
2093
+ function transportEnv(opts) {
2094
+ const source = opts.env ?? process.env;
2095
+ const env = {};
2096
+ for (const [key, value] of Object.entries(source)) {
2097
+ if (value === undefined)
2098
+ continue;
2099
+ if (TRANSPORT_ENV_KEYS.has(key) || key.startsWith("LC_"))
2100
+ env[key] = value;
2101
+ }
2102
+ env.PATH = normalizeExecutionPath(env);
2103
+ return env;
2104
+ }
2105
+ function preflightRemoteSpec(spec, machine, metadata, opts) {
2106
+ const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2107
+ const result = spawnSync2(plan.command, plan.args, {
2108
+ encoding: "utf8",
2109
+ env: transportEnv(opts),
2110
+ input: remotePreflightScript(spec, metadata),
2111
+ stdio: ["pipe", "pipe", "pipe"],
2112
+ timeout: 15000
2113
+ });
2114
+ if (result.error)
2115
+ throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
2116
+ if ((result.status ?? 1) !== 0) {
2117
+ const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
2118
+ throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
2119
+ }
2120
+ }
2121
+ async function executeRemoteSpec(spec, machine, metadata, opts) {
2122
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
2123
+ const startedAt = nowIso();
2124
+ let stdout = "";
2125
+ let stderr = "";
2126
+ let timedOut = false;
2127
+ let exitCode;
2128
+ let error;
2129
+ let plan;
2130
+ let script;
2131
+ try {
2132
+ plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
2133
+ script = remoteScript(spec, metadata);
2134
+ } catch (err) {
2135
+ return {
2136
+ status: "failed",
2137
+ stdout: "",
2138
+ stderr: "",
2139
+ error: err instanceof Error ? err.message : String(err),
2140
+ startedAt,
2141
+ finishedAt: nowIso(),
2142
+ durationMs: 0
2143
+ };
2144
+ }
2145
+ const child = spawn(plan.command, plan.args, {
2146
+ env: transportEnv(opts),
2147
+ detached: true,
2148
+ stdio: ["pipe", "pipe", "pipe"]
2149
+ });
2150
+ if (child.pid)
2151
+ opts.onSpawn?.(child.pid);
2152
+ child.stdin?.on("error", (err) => {
2153
+ if (err.code !== "EPIPE")
2154
+ error = err.message;
2155
+ });
2156
+ child.stdin?.end(script);
2157
+ const abortHandler = () => {
2158
+ error = "cancelled";
2159
+ if (child.pid)
2160
+ killProcessGroup(child.pid);
2161
+ };
2162
+ if (opts.signal?.aborted)
2163
+ abortHandler();
2164
+ opts.signal?.addEventListener("abort", abortHandler, { once: true });
2165
+ child.stdout?.on("data", (chunk) => {
2166
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
2167
+ });
2168
+ child.stderr?.on("data", (chunk) => {
2169
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
2170
+ });
2171
+ const timer = setTimeout(() => {
2172
+ timedOut = true;
2173
+ if (child.pid)
2174
+ killProcessGroup(child.pid);
2175
+ }, spec.timeoutMs);
2176
+ timer.unref();
2177
+ try {
2178
+ const [code, signal] = await once(child, "exit");
2179
+ if (typeof code === "number")
2180
+ exitCode = code;
2181
+ if (signal)
2182
+ error = `terminated by ${signal}`;
2183
+ } catch (err) {
2184
+ error = err instanceof Error ? err.message : String(err);
2185
+ } finally {
2186
+ clearTimeout(timer);
2187
+ opts.signal?.removeEventListener("abort", abortHandler);
2188
+ }
2189
+ const finishedAt = nowIso();
2190
+ const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
2191
+ if (timedOut) {
2192
+ return {
2193
+ status: "timed_out",
2194
+ exitCode,
2195
+ stdout,
2196
+ stderr,
2197
+ error: `timed out after ${spec.timeoutMs}ms`,
2198
+ pid: child.pid,
2199
+ startedAt,
2200
+ finishedAt,
2201
+ durationMs
2202
+ };
2203
+ }
2204
+ if (error || exitCode !== 0) {
2205
+ return {
2206
+ status: "failed",
2207
+ exitCode,
2208
+ stdout,
2209
+ stderr,
2210
+ error: error ?? `remote process on ${machine.id} exited with code ${exitCode ?? "unknown"}`,
2211
+ pid: child.pid,
2212
+ startedAt,
2213
+ finishedAt,
2214
+ durationMs
2215
+ };
2216
+ }
2217
+ return {
2218
+ status: "succeeded",
2219
+ exitCode,
2220
+ stdout,
2221
+ stderr,
2222
+ pid: child.pid,
2223
+ startedAt,
2224
+ finishedAt,
2225
+ durationMs
2226
+ };
2227
+ }
1728
2228
  function preflightTarget(target, metadata = {}, opts = {}) {
1729
2229
  const spec = commandSpec(target);
2230
+ const machine = resolvedMachine(opts);
2231
+ if (machine && !machine.local) {
2232
+ preflightRemoteSpec(spec, machine, metadata, opts);
2233
+ return {
2234
+ command: spec.command,
2235
+ accountProfile: spec.account?.profile,
2236
+ accountTool: spec.accountTool
2237
+ };
2238
+ }
1730
2239
  const env = executionEnv(spec, metadata, opts);
1731
2240
  if (!spec.shell && !executableExists(spec.command, env)) {
1732
2241
  throw new Error(commandNotFoundMessage(spec.command, env));
@@ -1739,6 +2248,9 @@ function preflightTarget(target, metadata = {}, opts = {}) {
1739
2248
  }
1740
2249
  async function executeTarget(target, metadata = {}, opts = {}) {
1741
2250
  const spec = commandSpec(target);
2251
+ const machine = resolvedMachine(opts);
2252
+ if (machine && !machine.local)
2253
+ return executeRemoteSpec(spec, machine, metadata, opts);
1742
2254
  const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
1743
2255
  const startedAt = nowIso();
1744
2256
  let stdout = "";
@@ -1763,10 +2275,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1763
2275
  env,
1764
2276
  shell: spec.shell ?? false,
1765
2277
  detached: true,
1766
- stdio: ["ignore", "pipe", "pipe"]
2278
+ stdio: spec.stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
1767
2279
  });
1768
2280
  if (child.pid)
1769
2281
  opts.onSpawn?.(child.pid);
2282
+ if (spec.stdin !== undefined && child.stdin) {
2283
+ child.stdin.on("error", (err) => {
2284
+ if (err.code !== "EPIPE")
2285
+ error = err.message;
2286
+ });
2287
+ child.stdin.end(spec.stdin);
2288
+ }
1770
2289
  const abortHandler = () => {
1771
2290
  error = "cancelled";
1772
2291
  if (child.pid)
@@ -1775,10 +2294,10 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1775
2294
  if (opts.signal?.aborted)
1776
2295
  abortHandler();
1777
2296
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
1778
- child.stdout.on("data", (chunk) => {
2297
+ child.stdout?.on("data", (chunk) => {
1779
2298
  stdout = appendBounded(stdout, chunk, maxOutputBytes);
1780
2299
  });
1781
- child.stderr.on("data", (chunk) => {
2300
+ child.stderr?.on("data", (chunk) => {
1782
2301
  stderr = appendBounded(stderr, chunk, maxOutputBytes);
1783
2302
  });
1784
2303
  const timer = setTimeout(() => {
@@ -1847,7 +2366,7 @@ async function executeLoop(loop, run, opts = {}) {
1847
2366
  loopName: loop.name,
1848
2367
  runId: run.id,
1849
2368
  scheduledFor: run.scheduledFor
1850
- }, opts);
2369
+ }, { ...opts, machine: opts.machine ?? loop.machine });
1851
2370
  }
1852
2371
 
1853
2372
  // src/lib/workflow-runner.ts
@@ -1877,7 +2396,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1877
2396
  loop: opts.loop,
1878
2397
  loopRun: opts.loopRun,
1879
2398
  scheduledFor: opts.scheduledFor,
1880
- idempotencyKey: opts.idempotencyKey
2399
+ idempotencyKey: opts.idempotencyKey,
2400
+ daemonLeaseId: opts.daemonLeaseId
1881
2401
  });
1882
2402
  const startedAt = run.startedAt ?? nowIso();
1883
2403
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
@@ -1911,12 +2431,14 @@ async function executeWorkflow(store, workflow, opts = {}) {
1911
2431
  return !dependencyStep?.continueOnFailure;
1912
2432
  });
1913
2433
  if (blockedBy) {
1914
- store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`);
2434
+ opts.beforePersist?.();
2435
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
1915
2436
  blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
1916
2437
  terminalStatus = "failed";
1917
2438
  continue;
1918
2439
  }
1919
- const startedStep = store.startWorkflowStepRun(run.id, step.id);
2440
+ opts.beforePersist?.();
2441
+ const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
1920
2442
  if (startedStep.status !== "running") {
1921
2443
  terminalStatus = "failed";
1922
2444
  blockingError = `step ${step.id} could not start because workflow is no longer running`;
@@ -1946,9 +2468,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
1946
2468
  try {
1947
2469
  result = await executeTarget(targetWithStepAccount(step), metadata, {
1948
2470
  ...opts,
2471
+ machine: opts.machine ?? opts.loop?.machine,
1949
2472
  signal: controller.signal,
1950
2473
  onSpawn: (pid) => {
1951
- store.markWorkflowStepPid(run.id, step.id, pid);
2474
+ opts.beforePersist?.();
2475
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
1952
2476
  opts.onSpawn?.(pid);
1953
2477
  }
1954
2478
  });
@@ -1976,6 +2500,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
1976
2500
  blockingError = "workflow run was cancelled";
1977
2501
  break;
1978
2502
  }
2503
+ opts.beforePersist?.();
1979
2504
  store.finalizeWorkflowStepRun(run.id, step.id, {
1980
2505
  status: result.status,
1981
2506
  finishedAt: result.finishedAt,
@@ -1984,6 +2509,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1984
2509
  stderr: result.stderr,
1985
2510
  exitCode: result.exitCode,
1986
2511
  error: result.error
2512
+ }, {
2513
+ daemonLeaseId: opts.daemonLeaseId
1987
2514
  });
1988
2515
  if (result.status !== "succeeded" && !step.continueOnFailure) {
1989
2516
  terminalStatus = result.status;
@@ -1995,7 +2522,9 @@ async function executeWorkflow(store, workflow, opts = {}) {
1995
2522
  for (const step of ordered) {
1996
2523
  const existing = store.getWorkflowStepRun(run.id, step.id);
1997
2524
  if (existing?.status === "pending" || existing?.status === "running") {
1998
- store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run");
2525
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
2526
+ daemonLeaseId: opts.daemonLeaseId
2527
+ });
1999
2528
  }
2000
2529
  }
2001
2530
  }
@@ -2005,10 +2534,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
2005
2534
  const steps2 = store.listWorkflowStepRuns(run.id);
2006
2535
  return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
2007
2536
  }
2537
+ opts.beforePersist?.();
2008
2538
  const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
2009
2539
  finishedAt,
2010
2540
  durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
2011
2541
  error: blockingError
2542
+ }, {
2543
+ daemonLeaseId: opts.daemonLeaseId
2012
2544
  });
2013
2545
  const steps = store.listWorkflowStepRuns(run.id);
2014
2546
  return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
@@ -2056,52 +2588,81 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
2056
2588
 
2057
2589
  // src/lib/scheduler.ts
2058
2590
  function manualRunScheduledFor(loop, now = new Date) {
2059
- if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
2591
+ if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
2060
2592
  return loop.retryScheduledFor ?? loop.nextRunAt;
2061
2593
  }
2062
2594
  return now.toISOString();
2063
2595
  }
2064
2596
  function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
2597
+ if (loop.status !== "active")
2598
+ return false;
2065
2599
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
2066
2600
  return false;
2067
2601
  return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
2068
2602
  }
2603
+ function manualRunSource(loop, scheduledFor, now = new Date) {
2604
+ if (loop.status !== "active")
2605
+ return "ad_hoc";
2606
+ if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
2607
+ return "ad_hoc";
2608
+ if (loop.retryScheduledFor && scheduledFor === loop.retryScheduledFor)
2609
+ return "retry_slot";
2610
+ return "due_slot";
2611
+ }
2069
2612
  function nextAfterRetry(loop, now) {
2070
2613
  return new Date(now.getTime() + loop.retryDelayMs).toISOString();
2071
2614
  }
2072
- function advanceLoop(store, loop, run, finishedAt, succeeded) {
2615
+ function isDaemonLeaseLost(error) {
2616
+ return error instanceof Error && error.message === "daemon lease lost";
2617
+ }
2618
+ function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
2073
2619
  if (run.status === "running")
2074
2620
  return;
2075
2621
  const current = store.getLoop(loop.id);
2076
2622
  if (!current || current.status !== "active")
2077
2623
  return;
2078
- const shouldRetry = !succeeded && run.attempt < loop.maxAttempts;
2624
+ if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
2625
+ return;
2626
+ const shouldRetry = !succeeded && run.attempt < current.maxAttempts;
2079
2627
  if (shouldRetry) {
2080
- store.updateLoop(loop.id, {
2628
+ store.updateLoop(current.id, {
2081
2629
  status: "active",
2082
- nextRunAt: nextAfterRetry(loop, finishedAt),
2630
+ nextRunAt: nextAfterRetry(current, finishedAt),
2083
2631
  retryScheduledFor: run.scheduledFor
2084
- });
2632
+ }, { daemonLeaseId: opts.daemonLeaseId });
2085
2633
  return;
2086
2634
  }
2087
- const nextRunAt = computeNextAfter(loop.schedule, new Date(run.scheduledFor), finishedAt);
2088
- store.updateLoop(loop.id, {
2635
+ const deferredRetry = store.nextRetryableRun(current.id, current.maxAttempts, run.scheduledFor);
2636
+ if (deferredRetry) {
2637
+ store.updateLoop(current.id, {
2638
+ status: "active",
2639
+ nextRunAt: nextAfterRetry(current, finishedAt),
2640
+ retryScheduledFor: deferredRetry.scheduledFor
2641
+ }, { daemonLeaseId: opts.daemonLeaseId });
2642
+ return;
2643
+ }
2644
+ const nextRunAt = computeNextAfter(current.schedule, new Date(run.scheduledFor), finishedAt);
2645
+ store.updateLoop(current.id, {
2089
2646
  status: nextRunAt ? "active" : "stopped",
2090
2647
  nextRunAt,
2091
2648
  retryScheduledFor: undefined
2092
- });
2649
+ }, { daemonLeaseId: opts.daemonLeaseId });
2093
2650
  }
2094
2651
  async function executeClaimedRun(deps) {
2095
2652
  let heartbeat;
2096
2653
  const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
2097
2654
  heartbeat = setInterval(() => {
2098
- deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
2655
+ deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs, new Date, {
2656
+ daemonLeaseId: deps.daemonLeaseId
2657
+ });
2099
2658
  }, heartbeatEveryMs);
2100
2659
  heartbeat.unref();
2101
2660
  try {
2102
2661
  const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
2103
- onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
2662
+ daemonLeaseId: deps.daemonLeaseId,
2663
+ onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId, { daemonLeaseId: deps.daemonLeaseId })
2104
2664
  })))(deps.loop, deps.run);
2665
+ deps.beforeFinalize?.(deps.loop, deps.run);
2105
2666
  return deps.store.finalizeRun(deps.run.id, {
2106
2667
  status: result.status,
2107
2668
  finishedAt: result.finishedAt,
@@ -2113,10 +2674,16 @@ async function executeClaimedRun(deps) {
2113
2674
  pid: result.pid
2114
2675
  }, {
2115
2676
  claimedBy: deps.runnerId,
2677
+ daemonLeaseId: deps.daemonLeaseId,
2116
2678
  now: deps.now?.() ?? new Date(result.finishedAt)
2117
2679
  });
2118
2680
  } catch (err) {
2119
2681
  deps.onError?.(deps.loop, err);
2682
+ try {
2683
+ deps.beforeFinalize?.(deps.loop, deps.run);
2684
+ } catch {
2685
+ return deps.store.getRun(deps.run.id) ?? deps.run;
2686
+ }
2120
2687
  const finishedAt = new Date;
2121
2688
  return deps.store.finalizeRun(deps.run.id, {
2122
2689
  status: "failed",
@@ -2127,6 +2694,7 @@ async function executeClaimedRun(deps) {
2127
2694
  error: err instanceof Error ? err.message : String(err)
2128
2695
  }, {
2129
2696
  claimedBy: deps.runnerId,
2697
+ daemonLeaseId: deps.daemonLeaseId,
2130
2698
  now: deps.now?.() ?? finishedAt
2131
2699
  });
2132
2700
  } finally {
@@ -2136,15 +2704,33 @@ async function executeClaimedRun(deps) {
2136
2704
  }
2137
2705
  async function runSlot(deps, loop, scheduledFor) {
2138
2706
  const now = deps.now?.() ?? new Date;
2707
+ deps.beforeRun?.(loop, scheduledFor);
2139
2708
  if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
2140
- const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
2141
- advanceLoop(deps.store, loop, skipped, now, true);
2709
+ let skipped;
2710
+ try {
2711
+ skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
2712
+ daemonLeaseId: deps.daemonLeaseId
2713
+ });
2714
+ } catch (error) {
2715
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
2716
+ return;
2717
+ throw error;
2718
+ }
2719
+ advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
2142
2720
  deps.onRun?.(skipped);
2143
2721
  return skipped;
2144
2722
  }
2145
- const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
2723
+ let claim;
2724
+ try {
2725
+ claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
2726
+ } catch (error) {
2727
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
2728
+ return;
2729
+ throw error;
2730
+ }
2146
2731
  if (!claim)
2147
2732
  return;
2733
+ deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
2148
2734
  deps.onRun?.(claim.run);
2149
2735
  const finalRun = await executeClaimedRun({
2150
2736
  store: deps.store,
@@ -2153,21 +2739,42 @@ async function runSlot(deps, loop, scheduledFor) {
2153
2739
  run: claim.run,
2154
2740
  now: deps.now,
2155
2741
  execute: deps.execute,
2742
+ beforeFinalize: deps.beforeFinalize,
2743
+ daemonLeaseId: deps.daemonLeaseId,
2156
2744
  onError: deps.onError
2157
2745
  });
2158
- advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
2746
+ advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded", { daemonLeaseId: deps.daemonLeaseId });
2159
2747
  deps.onRun?.(finalRun);
2160
2748
  return finalRun;
2161
2749
  }
2162
2750
  async function tick(deps) {
2163
2751
  const now = deps.now?.() ?? new Date;
2164
- const recovered = deps.store.recoverExpiredRunLeases(now);
2752
+ const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
2753
+ const recoveredByLoop = new Map;
2165
2754
  for (const run of recovered) {
2166
- const loop = deps.store.getLoop(run.loopId);
2167
- if (loop)
2168
- advanceLoop(deps.store, loop, run, new Date(run.finishedAt ?? now), false);
2755
+ recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
2756
+ }
2757
+ for (const runs of recoveredByLoop.values()) {
2758
+ const loop = deps.store.getLoop(runs[0].loopId);
2759
+ if (!loop)
2760
+ continue;
2761
+ const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
2762
+ if (retryable) {
2763
+ advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
2764
+ daemonLeaseId: deps.daemonLeaseId
2765
+ });
2766
+ continue;
2767
+ }
2768
+ for (const run of runs) {
2769
+ const current = deps.store.getLoop(run.loopId);
2770
+ if (current) {
2771
+ advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
2772
+ daemonLeaseId: deps.daemonLeaseId
2773
+ });
2774
+ }
2775
+ }
2169
2776
  }
2170
- const expired = deps.store.expireLoops(now);
2777
+ const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
2171
2778
  const claimed = [];
2172
2779
  const completed = [];
2173
2780
  const skipped = [];
@@ -2388,8 +2995,10 @@ async function runDaemon(opts = {}) {
2388
2995
  const result = await tick({
2389
2996
  store,
2390
2997
  runnerId,
2998
+ daemonLeaseId: leaseId,
2999
+ beforeRun: () => ensureLease(),
2391
3000
  execute: async (loop, run) => {
2392
- const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs / 3));
3001
+ const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
2393
3002
  const timer = setInterval(() => {
2394
3003
  try {
2395
3004
  ensureLease();
@@ -2401,8 +3010,14 @@ async function runDaemon(opts = {}) {
2401
3010
  try {
2402
3011
  const result2 = await executeLoopTarget(store, loop, run, {
2403
3012
  signal: runAbort.signal,
2404
- onSpawn: (pid) => store.markRunPid(run.id, pid, runnerId)
3013
+ beforePersist: () => ensureLease(),
3014
+ daemonLeaseId: leaseId,
3015
+ onSpawn: (pid) => {
3016
+ ensureLease();
3017
+ store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
3018
+ }
2405
3019
  });
3020
+ ensureLease();
2406
3021
  if (leaseLost)
2407
3022
  throw new Error("daemon lease lost during run");
2408
3023
  return result2;
@@ -2410,6 +3025,7 @@ async function runDaemon(opts = {}) {
2410
3025
  clearInterval(timer);
2411
3026
  }
2412
3027
  },
3028
+ beforeFinalize: () => ensureLease(),
2413
3029
  onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
2414
3030
  });
2415
3031
  const changed = result.completed.length + result.skipped.length + result.recovered.length + result.expired.length;
@@ -2455,7 +3071,7 @@ async function startDaemon(opts) {
2455
3071
 
2456
3072
  // src/daemon/install.ts
2457
3073
  import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
2458
- import { spawnSync as spawnSync2 } from "child_process";
3074
+ import { spawnSync as spawnSync3 } from "child_process";
2459
3075
  import { dirname as dirname3 } from "path";
2460
3076
  function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
2461
3077
  const command = [execPath, cliEntry, ...args].join(" ");
@@ -2525,7 +3141,7 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
2525
3141
  function enableStartup(result) {
2526
3142
  const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
2527
3143
  return commands.map((command) => {
2528
- const run = spawnSync2("sh", ["-c", command], {
3144
+ const run = spawnSync3("sh", ["-c", command], {
2529
3145
  encoding: "utf8",
2530
3146
  stdio: ["ignore", "pipe", "pipe"]
2531
3147
  });
@@ -2539,7 +3155,7 @@ function enableStartup(result) {
2539
3155
  }
2540
3156
 
2541
3157
  // src/lib/doctor.ts
2542
- import { spawnSync as spawnSync3 } from "child_process";
3158
+ import { spawnSync as spawnSync4 } from "child_process";
2543
3159
  import { accessSync as accessSync2, constants as constants2 } from "fs";
2544
3160
  var PROVIDER_COMMANDS = [
2545
3161
  "claude",
@@ -2550,11 +3166,11 @@ var PROVIDER_COMMANDS = [
2550
3166
  "codex"
2551
3167
  ];
2552
3168
  function hasCommand(command) {
2553
- const result = spawnSync3("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
3169
+ const result = spawnSync4("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
2554
3170
  return (result.status ?? 1) === 0;
2555
3171
  }
2556
3172
  function commandVersion(command) {
2557
- const result = spawnSync3(command, ["--version"], {
3173
+ const result = spawnSync4(command, ["--version"], {
2558
3174
  encoding: "utf8",
2559
3175
  stdio: ["ignore", "pipe", "pipe"]
2560
3176
  });
@@ -2580,6 +3196,23 @@ function runDoctor(store) {
2580
3196
  checks.push(bunVersion ? { id: "bun", status: "ok", message: "bun is available", detail: bunVersion } : { id: "bun", status: "fail", message: "bun is not available on PATH" });
2581
3197
  const accountsVersion = commandVersion("accounts");
2582
3198
  checks.push(accountsVersion ? { id: "accounts", status: "ok", message: "accounts is available", detail: accountsVersion } : { id: "accounts", status: "warn", message: "accounts CLI is not available; account-routed steps will fail" });
3199
+ try {
3200
+ const machines = listOpenMachines();
3201
+ const local = machines.find((machine) => machine.local);
3202
+ checks.push({
3203
+ id: "machines",
3204
+ status: "ok",
3205
+ message: `OpenMachines topology available (${machines.length} machine(s))`,
3206
+ detail: local ? `local=${local.id}` : undefined
3207
+ });
3208
+ } catch (error) {
3209
+ checks.push({
3210
+ id: "machines",
3211
+ status: "warn",
3212
+ message: "OpenMachines topology is not available; machine-assigned loops will fail",
3213
+ detail: error instanceof Error ? error.message : String(error)
3214
+ });
3215
+ }
2583
3216
  for (const command of PROVIDER_COMMANDS) {
2584
3217
  checks.push(hasCommand(command) ? { id: `provider:${command}`, status: "ok", message: `${command} is available` } : { id: `provider:${command}`, status: "warn", message: `${command} is not on PATH` });
2585
3218
  }
@@ -2592,16 +3225,16 @@ function runDoctor(store) {
2592
3225
  if (loop.target.type === "workflow") {
2593
3226
  const workflow = store.requireWorkflow(loop.target.workflowId);
2594
3227
  for (const step of workflowExecutionOrder(workflow)) {
2595
- preflightTarget({ ...step.target, account: step.account ?? step.target.account, timeoutMs: step.timeoutMs ?? step.target.timeoutMs }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id });
3228
+ preflightTarget({ ...step.target, account: step.account ?? step.target.account, timeoutMs: step.timeoutMs ?? step.target.timeoutMs }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id }, { machine: loop.machine });
2596
3229
  }
2597
3230
  } else {
2598
- preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name });
3231
+ preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name }, { machine: loop.machine });
2599
3232
  }
2600
3233
  checks.push({ id: `loop:${loop.id}:preflight`, status: "ok", message: `active loop target is ready: ${loop.name}` });
2601
3234
  } catch (error) {
2602
3235
  checks.push({
2603
3236
  id: `loop:${loop.id}:preflight`,
2604
- status: "warn",
3237
+ status: "fail",
2605
3238
  message: `active loop target preflight failed: ${loop.name}`,
2606
3239
  detail: error instanceof Error ? error.message : String(error)
2607
3240
  });
@@ -2615,7 +3248,7 @@ function runDoctor(store) {
2615
3248
 
2616
3249
  // src/cli/index.ts
2617
3250
  var program = new Command;
2618
- program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.2");
3251
+ program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.4");
2619
3252
  program.option("-j, --json", "print JSON");
2620
3253
  function isJson() {
2621
3254
  return Boolean(program.opts().json);
@@ -2694,6 +3327,7 @@ function baseCreateInput(name, opts, target) {
2694
3327
  description: typeof opts.description === "string" ? opts.description : undefined,
2695
3328
  schedule,
2696
3329
  target,
3330
+ machine: typeof opts.machine === "string" ? resolveLoopMachine(opts.machine) : undefined,
2697
3331
  ...policy,
2698
3332
  expiresAt: typeof opts.expiresAt === "string" ? new Date(opts.expiresAt).toISOString() : undefined
2699
3333
  };
@@ -2704,6 +3338,9 @@ function addScheduleOptions(command) {
2704
3338
  function addAccountOptions(command) {
2705
3339
  return command.option("--account <profile>", "OpenAccounts profile name for this target").option("--account-tool <tool>", "OpenAccounts tool id; defaults from provider for agents");
2706
3340
  }
3341
+ function addMachineOptions(command) {
3342
+ return command.option("--machine <id>", "OpenMachines machine id to assign this loop to");
3343
+ }
2707
3344
  function accountFromOpts(opts) {
2708
3345
  if (!opts.account && opts.accountTool)
2709
3346
  throw new Error("--account-tool requires --account");
@@ -2717,7 +3354,7 @@ function providerAuthProfileFromOpts(opts, provider) {
2717
3354
  return opts.authProfile;
2718
3355
  }
2719
3356
  var create = program.command("create").description("create loops");
2720
- addAccountOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell"))).action((name, opts) => {
3357
+ addAccountOptions(addMachineOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell")))).action((name, opts) => {
2721
3358
  const store = new Store;
2722
3359
  try {
2723
3360
  const target = {
@@ -2734,7 +3371,7 @@ addAccountOptions(addScheduleOptions(create.command("command <name>").descriptio
2734
3371
  store.close();
2735
3372
  }
2736
3373
  });
2737
- addAccountOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe"))).action((name, opts) => {
3374
+ addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe")))).action((name, opts) => {
2738
3375
  const provider = opts.provider;
2739
3376
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
2740
3377
  throw new Error("unsupported provider");
@@ -2762,7 +3399,7 @@ addAccountOptions(addScheduleOptions(create.command("agent <name>").description(
2762
3399
  store.close();
2763
3400
  }
2764
3401
  });
2765
- addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name")).action((name, opts) => {
3402
+ addMachineOptions(addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name"))).action((name, opts) => {
2766
3403
  const store = new Store;
2767
3404
  try {
2768
3405
  const workflow = store.requireWorkflow(opts.workflow);
@@ -2777,6 +3414,21 @@ addScheduleOptions(create.command("workflow <name>").description("schedule a sto
2777
3414
  }
2778
3415
  });
2779
3416
  var workflows = program.command("workflows").alias("workflow").description("manage workflow specs and runs");
3417
+ var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
3418
+ machines.command("list").alias("ls").description("list known machines").action(() => {
3419
+ const values = listOpenMachines();
3420
+ if (isJson())
3421
+ print(values);
3422
+ else {
3423
+ for (const machine of values) {
3424
+ const route = machine.local ? "local" : machine.route ?? "-";
3425
+ console.log(`${machine.id.padEnd(12)} ${route.padEnd(10)} workspace=${machine.workspacePath ?? "-"} host=${machine.hostname ?? "-"}`);
3426
+ }
3427
+ }
3428
+ });
3429
+ machines.command("show <id>").description("resolve a machine assignment").action((id) => {
3430
+ print(resolveLoopMachine(id));
3431
+ });
2780
3432
  workflows.command("validate <file>").description("validate a workflow JSON file without storing or running it").option("--name <name>", "override workflow name from the file").option("--preflight", "also check account env and target executables").action((file, opts) => {
2781
3433
  const body = workflowBodyFromJson(JSON.parse(readFileSync2(file, "utf8")), opts.name);
2782
3434
  const now = new Date().toISOString();
@@ -2834,7 +3486,7 @@ workflows.command("inspect <runId>").description("show a workflow run with steps
2834
3486
  const events = store.listWorkflowEvents(run.id);
2835
3487
  const value = {
2836
3488
  workflowRun: publicWorkflowRun(run),
2837
- steps: steps.map((step) => publicWorkflowStepRun(step, isJson())),
3489
+ steps: steps.map((step) => publicWorkflowStepRun(step)),
2838
3490
  events: events.map(publicWorkflowEvent)
2839
3491
  };
2840
3492
  if (isJson())
@@ -2842,7 +3494,8 @@ workflows.command("inspect <runId>").description("show a workflow run with steps
2842
3494
  else {
2843
3495
  console.log(`${run.id} ${run.status} ${run.workflowName}`);
2844
3496
  for (const step of steps) {
2845
- console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${step.error ?? ""}`);
3497
+ const publicStep = publicWorkflowStepRun(step);
3498
+ console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${publicStep.error ?? ""}`);
2846
3499
  }
2847
3500
  console.log(` events=${events.length}`);
2848
3501
  }
@@ -2858,7 +3511,7 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
2858
3511
  const run = store.listWorkflowRuns({ workflowId: workflow.id, limit: 1 })[0];
2859
3512
  const steps = run ? store.listWorkflowStepRuns(run.id) : [];
2860
3513
  const value = {
2861
- result,
3514
+ result: publicExecutorResult(result),
2862
3515
  workflowRun: run ? publicWorkflowRun(run) : undefined,
2863
3516
  steps: steps.map((step) => publicWorkflowStepRun(step, opts.showOutput))
2864
3517
  };
@@ -2867,7 +3520,8 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
2867
3520
  else {
2868
3521
  console.log(`${run?.id ?? workflow.id} ${result.status}`);
2869
3522
  for (const step of steps) {
2870
- console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${step.error ?? ""}`);
3523
+ const publicStep = publicWorkflowStepRun(step, opts.showOutput);
3524
+ console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${publicStep.error ?? ""}`);
2871
3525
  if (opts.showOutput)
2872
3526
  printTextOutput(step);
2873
3527
  }
@@ -2945,7 +3599,8 @@ program.command("list").alias("ls").option("--status <status>", "filter by statu
2945
3599
  print(loops.map(publicLoop));
2946
3600
  else {
2947
3601
  for (const loop of loops) {
2948
- console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}`);
3602
+ const machine = loop.machine ? ` machine=${loop.machine.id}` : "";
3603
+ console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}`);
2949
3604
  }
2950
3605
  }
2951
3606
  } finally {
@@ -3006,16 +3661,27 @@ program.command("run-now <idOrName>").option("--show-output", "show stdout/stder
3006
3661
  const loop = store.requireLoop(idOrName);
3007
3662
  const runnerId = `manual:${process.pid}`;
3008
3663
  const now = new Date;
3009
- const scheduledFor = manualRunScheduledFor(loop, now);
3010
- const shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
3011
- const claim = store.claimRun(loop, scheduledFor, runnerId, now);
3664
+ let scheduledFor = manualRunScheduledFor(loop, now);
3665
+ let source = manualRunSource(loop, scheduledFor, now);
3666
+ let shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
3667
+ let claim = store.claimRun(loop, scheduledFor, runnerId, now);
3668
+ if (!claim && shouldAdvance) {
3669
+ const existing = store.getRunBySlot(loop.id, scheduledFor);
3670
+ if (existing && existing.status !== "running") {
3671
+ scheduledFor = now.toISOString();
3672
+ source = "ad_hoc";
3673
+ shouldAdvance = false;
3674
+ claim = store.claimRun(loop, scheduledFor, runnerId, now);
3675
+ }
3676
+ }
3012
3677
  if (!claim)
3013
3678
  throw new Error("could not claim manual run");
3014
3679
  const run = await executeClaimedRun({ store, runnerId, loop: claim.loop, run: claim.run });
3015
3680
  if (shouldAdvance) {
3016
3681
  advanceLoop(store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
3017
3682
  }
3018
- print(publicRun(run, opts.showOutput), `${run.id} ${run.status}`);
3683
+ const value = { ...publicRun(run, opts.showOutput), runNow: { source, advancesLoop: shouldAdvance } };
3684
+ print(value, `${run.id} ${run.status} source=${source} slot=${run.scheduledFor}`);
3019
3685
  if (!isJson() && opts.showOutput)
3020
3686
  printTextOutput(run);
3021
3687
  if (run.status !== "succeeded")
@@ -3044,9 +3710,9 @@ program.command("doctor").description("check local OpenLoops runtime dependencie
3044
3710
  const marker = check.status === "ok" ? "ok" : check.status === "warn" ? "warn" : "fail";
3045
3711
  console.log(`${marker.padEnd(4)} ${check.id.padEnd(22)} ${check.message}${check.detail ? ` (${check.detail})` : ""}`);
3046
3712
  }
3047
- if (!report.ok)
3048
- process.exitCode = 1;
3049
3713
  }
3714
+ if (!report.ok)
3715
+ process.exitCode = 1;
3050
3716
  } finally {
3051
3717
  store.close();
3052
3718
  }