@hasna/loops 0.3.2 → 0.3.3

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
@@ -615,6 +615,13 @@ class Store {
615
615
  } catch {}
616
616
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
617
617
  }
618
+ assertDaemonLeaseFence(opts = {}, now = nowIso()) {
619
+ if (!opts.daemonLeaseId)
620
+ return;
621
+ const row = this.db.query("SELECT id FROM daemon_lease WHERE id = ? AND expires_at > ?").get(opts.daemonLeaseId, now);
622
+ if (!row)
623
+ throw new Error("daemon lease lost");
624
+ }
618
625
  createLoop(input, from = new Date) {
619
626
  const now = nowIso();
620
627
  const loop = {
@@ -684,21 +691,31 @@ class Store {
684
691
  ORDER BY next_run_at ASC`).all(now.toISOString());
685
692
  return rows.map(rowToLoop);
686
693
  }
687
- updateLoop(id, patch) {
694
+ updateLoop(id, patch, opts = {}) {
688
695
  const current = this.getLoop(id);
689
696
  if (!current)
690
697
  throw new Error(`loop not found: ${id}`);
691
- const merged = { ...current, ...patch, updatedAt: nowIso() };
698
+ const updated = (opts.now ?? new Date).toISOString();
699
+ const merged = { ...current, ...patch, updatedAt: updated };
692
700
  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({
701
+ expires_at=$expiresAt, updated_at=$updated
702
+ WHERE id=$id
703
+ AND ($daemonLeaseId IS NULL OR EXISTS (
704
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
705
+ ))`).run({
694
706
  $id: id,
695
707
  $status: merged.status,
696
708
  $nextRun: merged.nextRunAt ?? null,
697
709
  $retrySlot: merged.retryScheduledFor ?? null,
698
710
  $expiresAt: merged.expiresAt ?? null,
699
- $updated: merged.updatedAt
711
+ $updated: merged.updatedAt,
712
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
713
+ $now: updated
700
714
  });
701
- return merged;
715
+ const after = this.getLoop(id);
716
+ if (!after)
717
+ throw new Error(`loop not found after update: ${id}`);
718
+ return after;
702
719
  }
703
720
  deleteLoop(idOrName) {
704
721
  const loop = this.requireLoop(idOrName);
@@ -762,11 +779,14 @@ class Store {
762
779
  const now = nowIso();
763
780
  if (input.idempotencyKey) {
764
781
  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)
782
+ if (existing) {
783
+ this.assertDaemonLeaseFence(input);
766
784
  return rowToWorkflowRun(existing);
785
+ }
767
786
  }
768
787
  this.db.exec("BEGIN IMMEDIATE");
769
788
  try {
789
+ this.assertDaemonLeaseFence(input, now);
770
790
  if (input.idempotencyKey) {
771
791
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
772
792
  if (existing) {
@@ -865,31 +885,60 @@ class Store {
865
885
  const run = this.getWorkflowRun(workflowRunId);
866
886
  return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
867
887
  }
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}`);
888
+ startWorkflowStepRun(workflowRunId, stepId, opts = {}) {
889
+ const now = (opts.now ?? new Date).toISOString();
890
+ this.db.exec("BEGIN IMMEDIATE");
891
+ try {
892
+ const res = this.db.query(`UPDATE workflow_step_runs
893
+ SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
894
+ pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
895
+ WHERE workflow_run_id=$workflowRunId
896
+ AND step_id=$stepId
897
+ AND status IN ('pending', 'failed', 'timed_out')
898
+ AND EXISTS (
899
+ SELECT 1 FROM workflow_runs
900
+ WHERE id=$workflowRunId AND status='running'
901
+ )
902
+ AND ($daemonLeaseId IS NULL OR EXISTS (
903
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
904
+ ))`).run({
905
+ $workflowRunId: workflowRunId,
906
+ $stepId: stepId,
907
+ $started: now,
908
+ $updated: now,
909
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
910
+ $now: now
911
+ });
912
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
913
+ if (!run)
914
+ throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
915
+ if (res.changes !== 1) {
916
+ throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
917
+ }
918
+ this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
919
+ this.db.exec("COMMIT");
920
+ return run;
921
+ } catch (error) {
922
+ try {
923
+ this.db.exec("ROLLBACK");
924
+ } catch {}
925
+ throw error;
885
926
  }
886
- this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
887
- return run;
888
927
  }
889
- markWorkflowStepPid(workflowRunId, stepId, pid) {
890
- const now = nowIso();
928
+ markWorkflowStepPid(workflowRunId, stepId, pid, opts = {}) {
929
+ const now = (opts.now ?? new Date).toISOString();
891
930
  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 });
931
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
932
+ AND ($daemonLeaseId IS NULL OR EXISTS (
933
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
934
+ ))`).run({
935
+ $workflowRunId: workflowRunId,
936
+ $stepId: stepId,
937
+ $pid: pid,
938
+ $updated: now,
939
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
940
+ $now: now
941
+ });
893
942
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
894
943
  if (!run)
895
944
  throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
@@ -917,60 +966,110 @@ class Store {
917
966
  recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
918
967
  };
919
968
  }
920
- finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
969
+ finalizeWorkflowStepRun(workflowRunId, stepId, patch, opts = {}) {
921
970
  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
971
+ this.db.exec("BEGIN IMMEDIATE");
972
+ try {
973
+ const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
974
+ pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
975
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
976
+ AND ($daemonLeaseId IS NULL OR EXISTS (
977
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
978
+ ))`).run({
979
+ $workflowRunId: workflowRunId,
980
+ $stepId: stepId,
981
+ $status: patch.status,
982
+ $finished: finishedAt,
983
+ $exitCode: patch.exitCode ?? null,
984
+ $durationMs: patch.durationMs ?? null,
985
+ $stdout: patch.stdout ?? null,
986
+ $stderr: patch.stderr ?? null,
987
+ $error: patch.error ?? null,
988
+ $updated: finishedAt,
989
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
990
+ $now: (opts.now ?? new Date(finishedAt)).toISOString()
940
991
  });
992
+ if (res.changes === 1) {
993
+ this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
994
+ exitCode: patch.exitCode,
995
+ error: patch.error
996
+ });
997
+ }
998
+ this.db.exec("COMMIT");
999
+ } catch (error) {
1000
+ try {
1001
+ this.db.exec("ROLLBACK");
1002
+ } catch {}
1003
+ throw error;
941
1004
  }
942
1005
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
943
1006
  if (!run)
944
1007
  throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
945
1008
  return run;
946
1009
  }
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 });
1010
+ skipWorkflowStepRun(workflowRunId, stepId, reason, opts = {}) {
1011
+ const now = (opts.now ?? new Date).toISOString();
1012
+ this.db.exec("BEGIN IMMEDIATE");
1013
+ try {
1014
+ const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
1015
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')
1016
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1017
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1018
+ ))`).run({
1019
+ $workflowRunId: workflowRunId,
1020
+ $stepId: stepId,
1021
+ $finished: now,
1022
+ $error: reason,
1023
+ $updated: now,
1024
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1025
+ $now: now
1026
+ });
1027
+ if (res.changes === 1)
1028
+ this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
1029
+ this.db.exec("COMMIT");
1030
+ } catch (error) {
1031
+ try {
1032
+ this.db.exec("ROLLBACK");
1033
+ } catch {}
1034
+ throw error;
1035
+ }
953
1036
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
954
1037
  if (!run)
955
1038
  throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
956
1039
  return run;
957
1040
  }
958
- finalizeWorkflowRun(workflowRunId, status, patch = {}) {
1041
+ finalizeWorkflowRun(workflowRunId, status, patch = {}, opts = {}) {
959
1042
  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
- });
1043
+ let changed = false;
1044
+ this.db.exec("BEGIN IMMEDIATE");
1045
+ try {
1046
+ const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
1047
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
1048
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1049
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1050
+ ))`).run({
1051
+ $id: workflowRunId,
1052
+ $status: status,
1053
+ $finished: finishedAt,
1054
+ $durationMs: patch.durationMs ?? null,
1055
+ $error: patch.error ?? null,
1056
+ $updated: finishedAt,
1057
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1058
+ $now: (opts.now ?? new Date(finishedAt)).toISOString()
1059
+ });
1060
+ changed = res.changes === 1;
1061
+ if (changed)
1062
+ this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
1063
+ this.db.exec("COMMIT");
1064
+ } catch (error) {
1065
+ try {
1066
+ this.db.exec("ROLLBACK");
1067
+ } catch {}
1068
+ throw error;
1069
+ }
969
1070
  const run = this.getWorkflowRun(workflowRunId);
970
1071
  if (!run)
971
1072
  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
1073
  return run;
975
1074
  }
976
1075
  cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
@@ -1024,10 +1123,24 @@ class Store {
1024
1123
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
1025
1124
  return (row?.count ?? 0) > 0;
1026
1125
  }
1027
- markRunPid(id, pid, claimedBy) {
1028
- const now = nowIso();
1126
+ markRunPid(id, pid, claimedBy, opts = {}) {
1127
+ const now = (opts.now ?? new Date).toISOString();
1029
1128
  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 });
1129
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy
1130
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1131
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1132
+ ))`).run({
1133
+ $id: id,
1134
+ $pid: pid,
1135
+ $updated: now,
1136
+ $claimedBy: claimedBy,
1137
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1138
+ $now: now
1139
+ }) : this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
1140
+ WHERE id=$id AND status='running'
1141
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1142
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1143
+ ))`).run({ $id: id, $pid: pid, $updated: now, $daemonLeaseId: opts.daemonLeaseId ?? null, $now: now });
1031
1144
  if (res.changes !== 1)
1032
1145
  return;
1033
1146
  return this.getRun(id);
@@ -1042,7 +1155,7 @@ class Store {
1042
1155
  AND wsr.pid IS NOT NULL`).all(loopRunId);
1043
1156
  return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
1044
1157
  }
1045
- createSkippedRun(loop, scheduledFor, reason) {
1158
+ createSkippedRun(loop, scheduledFor, reason, opts = {}) {
1046
1159
  const now = nowIso();
1047
1160
  const run = {
1048
1161
  id: genId(),
@@ -1056,21 +1169,31 @@ class Store {
1056
1169
  createdAt: now,
1057
1170
  updatedAt: now
1058
1171
  };
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
- });
1172
+ this.db.exec("BEGIN IMMEDIATE");
1173
+ try {
1174
+ this.assertDaemonLeaseFence(opts, now);
1175
+ this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
1176
+ claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
1177
+ VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
1178
+ NULL, NULL, $error, $created, $updated)`).run({
1179
+ $id: run.id,
1180
+ $loopId: run.loopId,
1181
+ $loopName: run.loopName,
1182
+ $scheduledFor: run.scheduledFor,
1183
+ $attempt: run.attempt,
1184
+ $status: run.status,
1185
+ $finished: run.finishedAt ?? null,
1186
+ $error: run.error ?? null,
1187
+ $created: run.createdAt,
1188
+ $updated: run.updatedAt
1189
+ });
1190
+ this.db.exec("COMMIT");
1191
+ } catch (error) {
1192
+ try {
1193
+ this.db.exec("ROLLBACK");
1194
+ } catch {}
1195
+ throw error;
1196
+ }
1074
1197
  return this.getRunBySlot(loop.id, scheduledFor) ?? run;
1075
1198
  }
1076
1199
  getRun(id) {
@@ -1081,11 +1204,20 @@ class Store {
1081
1204
  const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
1082
1205
  return row ? rowToRun(row) : undefined;
1083
1206
  }
1084
- claimRun(loop, scheduledFor, runnerId, now = new Date) {
1207
+ nextRetryableRun(loopId, maxAttempts, afterScheduledFor) {
1208
+ const row = afterScheduledFor ? this.db.query(`SELECT * FROM loop_runs
1209
+ WHERE loop_id = ? AND scheduled_for > ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
1210
+ ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, afterScheduledFor, maxAttempts) : this.db.query(`SELECT * FROM loop_runs
1211
+ WHERE loop_id = ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
1212
+ ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, maxAttempts);
1213
+ return row ? rowToRun(row) : undefined;
1214
+ }
1215
+ claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
1085
1216
  const startedAt = now.toISOString();
1086
1217
  const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1087
1218
  this.db.exec("BEGIN IMMEDIATE");
1088
1219
  try {
1220
+ this.assertDaemonLeaseFence(opts, startedAt);
1089
1221
  const existing = this.getRunBySlot(loop.id, scheduledFor);
1090
1222
  if (existing) {
1091
1223
  if (existing.status === "running") {
@@ -1180,11 +1312,15 @@ class Store {
1180
1312
  $error: patch.error ?? null,
1181
1313
  $updated: finishedAt,
1182
1314
  $claimedBy: opts.claimedBy ?? null,
1183
- $now: (opts.now ?? new Date).toISOString()
1315
+ $now: (opts.now ?? new Date).toISOString(),
1316
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1184
1317
  };
1185
1318
  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
1319
  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,
1320
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
1321
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1322
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1323
+ ))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
1188
1324
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
1189
1325
  const run = this.getRun(id);
1190
1326
  if (!run)
@@ -1193,10 +1329,20 @@ class Store {
1193
1329
  return run;
1194
1330
  return run;
1195
1331
  }
1196
- heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
1332
+ heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
1197
1333
  const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
1198
1334
  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() });
1335
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
1336
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1337
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1338
+ ))`).run({
1339
+ $id: id,
1340
+ $claimedBy: claimedBy,
1341
+ $expires: expiresAt,
1342
+ $updated: now.toISOString(),
1343
+ $now: now.toISOString(),
1344
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1345
+ });
1200
1346
  if (res.changes !== 1)
1201
1347
  return;
1202
1348
  return this.getRun(id);
@@ -1215,7 +1361,7 @@ class Store {
1215
1361
  }
1216
1362
  return rows.map(rowToRun);
1217
1363
  }
1218
- recoverExpiredRunLeases(now = new Date) {
1364
+ recoverExpiredRunLeases(now = new Date, opts = {}) {
1219
1365
  const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
1220
1366
  const recovered = [];
1221
1367
  for (const row of rows) {
@@ -1224,20 +1370,63 @@ class Store {
1224
1370
  if (this.hasLiveWorkflowStepProcesses(row.id))
1225
1371
  continue;
1226
1372
  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
1373
+ this.db.exec("BEGIN IMMEDIATE");
1374
+ try {
1375
+ const res = this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
1376
+ error='run lease expired before completion', updated_at=$updated
1377
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
1378
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1379
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1380
+ ))`).run({
1381
+ $id: row.id,
1382
+ $finished: finished,
1383
+ $updated: finished,
1384
+ $now: finished,
1385
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1240
1386
  });
1387
+ if (res.changes !== 1) {
1388
+ this.db.exec("COMMIT");
1389
+ continue;
1390
+ }
1391
+ 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);
1392
+ for (const workflowRow of workflowRows) {
1393
+ const workflowRes = this.db.query(`UPDATE workflow_runs
1394
+ SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
1395
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
1396
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1397
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1398
+ ))`).run({
1399
+ $id: workflowRow.id,
1400
+ $finished: finished,
1401
+ $updated: finished,
1402
+ $now: finished,
1403
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1404
+ });
1405
+ if (workflowRes.changes !== 1)
1406
+ continue;
1407
+ this.db.query(`UPDATE workflow_step_runs
1408
+ SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
1409
+ WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')
1410
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1411
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1412
+ ))`).run({
1413
+ $workflowRunId: workflowRow.id,
1414
+ $finished: finished,
1415
+ $updated: finished,
1416
+ $now: finished,
1417
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1418
+ });
1419
+ this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
1420
+ error: "parent loop run lease expired before completion",
1421
+ loopRunId: row.id
1422
+ });
1423
+ }
1424
+ this.db.exec("COMMIT");
1425
+ } catch (error) {
1426
+ try {
1427
+ this.db.exec("ROLLBACK");
1428
+ } catch {}
1429
+ throw error;
1241
1430
  }
1242
1431
  const run = this.getRun(row.id);
1243
1432
  if (run)
@@ -1245,11 +1434,14 @@ class Store {
1245
1434
  }
1246
1435
  return recovered;
1247
1436
  }
1248
- expireLoops(now = new Date) {
1437
+ expireLoops(now = new Date, opts = {}) {
1249
1438
  const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
1250
1439
  const expired = [];
1251
- for (const row of rows)
1252
- expired.push(this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }));
1440
+ for (const row of rows) {
1441
+ const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
1442
+ if (updated.status === "expired")
1443
+ expired.push(updated);
1444
+ }
1253
1445
  return expired;
1254
1446
  }
1255
1447
  countLoops(status) {
@@ -1292,7 +1484,7 @@ class Store {
1292
1484
  }
1293
1485
  heartbeatDaemonLease(id, ttlMs, now = new Date) {
1294
1486
  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() });
1487
+ 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
1488
  if (res.changes !== 1)
1297
1489
  return;
1298
1490
  return this.getDaemonLease();
@@ -1315,11 +1507,14 @@ import { Command } from "commander";
1315
1507
 
1316
1508
  // src/lib/format.ts
1317
1509
  var TEXT_OUTPUT_LIMIT = 32 * 1024;
1318
- function redact(value, visible = 80) {
1510
+ var SENSITIVE_PAYLOAD_KEYS = new Set(["env", "error", "prompt", "reason", "stderr", "stdout"]);
1511
+ function redact(value, visible = 0) {
1319
1512
  if (!value)
1320
1513
  return value;
1321
1514
  if (value.length <= visible)
1322
1515
  return value;
1516
+ if (visible <= 0)
1517
+ return `[redacted ${value.length} chars]`;
1323
1518
  return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
1324
1519
  }
1325
1520
  function truncateTextOutput(value) {
@@ -1328,6 +1523,21 @@ function truncateTextOutput(value) {
1328
1523
  return `${value.slice(0, TEXT_OUTPUT_LIMIT)}
1329
1524
  [truncated ${value.length - TEXT_OUTPUT_LIMIT} chars]`;
1330
1525
  }
1526
+ function redactSensitivePayload(value, key) {
1527
+ if (key && SENSITIVE_PAYLOAD_KEYS.has(key)) {
1528
+ if (typeof value === "string")
1529
+ return redact(value);
1530
+ if (value === undefined || value === null)
1531
+ return value;
1532
+ return "[redacted]";
1533
+ }
1534
+ if (Array.isArray(value))
1535
+ return value.map((item) => redactSensitivePayload(item));
1536
+ if (value && typeof value === "object") {
1537
+ return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, redactSensitivePayload(entryValue, entryKey)]));
1538
+ }
1539
+ return value;
1540
+ }
1331
1541
  function textOutputBlocks(value, opts = {}) {
1332
1542
  const indent = opts.indent ?? "";
1333
1543
  const nested = `${indent} `;
@@ -1359,6 +1569,14 @@ function publicRun(run, showOutput = false) {
1359
1569
  stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
1360
1570
  };
1361
1571
  }
1572
+ function publicExecutorResult(result, showOutput = false) {
1573
+ return {
1574
+ ...result,
1575
+ stdout: showOutput ? result.stdout : result.stdout ? `[redacted ${result.stdout.length} chars]` : undefined,
1576
+ stderr: showOutput ? result.stderr : result.stderr ? `[redacted ${result.stderr.length} chars]` : undefined,
1577
+ error: redact(result.error)
1578
+ };
1579
+ }
1362
1580
  function publicWorkflow(workflow) {
1363
1581
  return {
1364
1582
  ...workflow,
@@ -1369,17 +1587,18 @@ function publicWorkflow(workflow) {
1369
1587
  };
1370
1588
  }
1371
1589
  function publicWorkflowRun(run) {
1372
- return { ...run };
1590
+ return { ...run, error: redact(run.error) };
1373
1591
  }
1374
1592
  function publicWorkflowStepRun(run, showOutput = false) {
1375
1593
  return {
1376
1594
  ...run,
1377
1595
  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
1596
+ stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
1597
+ error: redact(run.error)
1379
1598
  };
1380
1599
  }
1381
1600
  function publicWorkflowEvent(event) {
1382
- return { ...event };
1601
+ return { ...event, payload: redactSensitivePayload(event.payload) };
1383
1602
  }
1384
1603
 
1385
1604
  // src/lib/executor.ts
@@ -1615,7 +1834,7 @@ function agentArgs(target) {
1615
1834
  args.push("--model", target.model);
1616
1835
  if (target.agent)
1617
1836
  args.push("--agent", target.agent);
1618
- args.push(...target.extraArgs ?? [], target.prompt);
1837
+ args.push(...target.extraArgs ?? []);
1619
1838
  return args;
1620
1839
  case "cursor":
1621
1840
  args.push("-p");
@@ -1623,7 +1842,7 @@ function agentArgs(target) {
1623
1842
  args.push("--model", target.model);
1624
1843
  if (target.agent)
1625
1844
  args.push("--agent", target.agent);
1626
- args.push(...target.extraArgs ?? [], target.prompt);
1845
+ args.push(...target.extraArgs ?? []);
1627
1846
  return args;
1628
1847
  case "codewith":
1629
1848
  args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
@@ -1635,7 +1854,7 @@ function agentArgs(target) {
1635
1854
  args.push("--model", target.model);
1636
1855
  if (target.agent)
1637
1856
  args.push("--agent", target.agent);
1638
- args.push(...target.extraArgs ?? [], target.prompt);
1857
+ args.push(...target.extraArgs ?? []);
1639
1858
  return args;
1640
1859
  case "codex":
1641
1860
  args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
@@ -1645,7 +1864,7 @@ function agentArgs(target) {
1645
1864
  args.push("--cd", target.cwd);
1646
1865
  if (target.model)
1647
1866
  args.push("--model", target.model);
1648
- args.push(...target.extraArgs ?? [], target.prompt);
1867
+ args.push(...target.extraArgs ?? []);
1649
1868
  return args;
1650
1869
  case "aicopilot":
1651
1870
  args.push("run", "--format", "json");
@@ -1657,7 +1876,7 @@ function agentArgs(target) {
1657
1876
  args.push("--model", target.model);
1658
1877
  if (target.agent)
1659
1878
  args.push("--agent", target.agent);
1660
- args.push(...target.extraArgs ?? [], target.prompt);
1879
+ args.push(...target.extraArgs ?? []);
1661
1880
  return args;
1662
1881
  case "opencode":
1663
1882
  args.push("run", "--format", "json");
@@ -1669,7 +1888,7 @@ function agentArgs(target) {
1669
1888
  args.push("--model", target.model);
1670
1889
  if (target.agent)
1671
1890
  args.push("--agent", target.agent);
1672
- args.push(...target.extraArgs ?? [], target.prompt);
1891
+ args.push(...target.extraArgs ?? []);
1673
1892
  return args;
1674
1893
  }
1675
1894
  }
@@ -1694,7 +1913,8 @@ function commandSpec(target) {
1694
1913
  cwd: agentTarget.cwd,
1695
1914
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1696
1915
  account: agentTarget.account,
1697
- accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
1916
+ accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
1917
+ stdin: agentTarget.prompt
1698
1918
  };
1699
1919
  }
1700
1920
  function executionEnv(spec, metadata, opts) {
@@ -1763,10 +1983,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1763
1983
  env,
1764
1984
  shell: spec.shell ?? false,
1765
1985
  detached: true,
1766
- stdio: ["ignore", "pipe", "pipe"]
1986
+ stdio: spec.stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
1767
1987
  });
1768
1988
  if (child.pid)
1769
1989
  opts.onSpawn?.(child.pid);
1990
+ if (spec.stdin !== undefined && child.stdin) {
1991
+ child.stdin.on("error", (err) => {
1992
+ if (err.code !== "EPIPE")
1993
+ error = err.message;
1994
+ });
1995
+ child.stdin.end(spec.stdin);
1996
+ }
1770
1997
  const abortHandler = () => {
1771
1998
  error = "cancelled";
1772
1999
  if (child.pid)
@@ -1775,10 +2002,10 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1775
2002
  if (opts.signal?.aborted)
1776
2003
  abortHandler();
1777
2004
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
1778
- child.stdout.on("data", (chunk) => {
2005
+ child.stdout?.on("data", (chunk) => {
1779
2006
  stdout = appendBounded(stdout, chunk, maxOutputBytes);
1780
2007
  });
1781
- child.stderr.on("data", (chunk) => {
2008
+ child.stderr?.on("data", (chunk) => {
1782
2009
  stderr = appendBounded(stderr, chunk, maxOutputBytes);
1783
2010
  });
1784
2011
  const timer = setTimeout(() => {
@@ -1877,7 +2104,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1877
2104
  loop: opts.loop,
1878
2105
  loopRun: opts.loopRun,
1879
2106
  scheduledFor: opts.scheduledFor,
1880
- idempotencyKey: opts.idempotencyKey
2107
+ idempotencyKey: opts.idempotencyKey,
2108
+ daemonLeaseId: opts.daemonLeaseId
1881
2109
  });
1882
2110
  const startedAt = run.startedAt ?? nowIso();
1883
2111
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
@@ -1911,12 +2139,14 @@ async function executeWorkflow(store, workflow, opts = {}) {
1911
2139
  return !dependencyStep?.continueOnFailure;
1912
2140
  });
1913
2141
  if (blockedBy) {
1914
- store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`);
2142
+ opts.beforePersist?.();
2143
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
1915
2144
  blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
1916
2145
  terminalStatus = "failed";
1917
2146
  continue;
1918
2147
  }
1919
- const startedStep = store.startWorkflowStepRun(run.id, step.id);
2148
+ opts.beforePersist?.();
2149
+ const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
1920
2150
  if (startedStep.status !== "running") {
1921
2151
  terminalStatus = "failed";
1922
2152
  blockingError = `step ${step.id} could not start because workflow is no longer running`;
@@ -1948,7 +2178,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1948
2178
  ...opts,
1949
2179
  signal: controller.signal,
1950
2180
  onSpawn: (pid) => {
1951
- store.markWorkflowStepPid(run.id, step.id, pid);
2181
+ opts.beforePersist?.();
2182
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
1952
2183
  opts.onSpawn?.(pid);
1953
2184
  }
1954
2185
  });
@@ -1976,6 +2207,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
1976
2207
  blockingError = "workflow run was cancelled";
1977
2208
  break;
1978
2209
  }
2210
+ opts.beforePersist?.();
1979
2211
  store.finalizeWorkflowStepRun(run.id, step.id, {
1980
2212
  status: result.status,
1981
2213
  finishedAt: result.finishedAt,
@@ -1984,6 +2216,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1984
2216
  stderr: result.stderr,
1985
2217
  exitCode: result.exitCode,
1986
2218
  error: result.error
2219
+ }, {
2220
+ daemonLeaseId: opts.daemonLeaseId
1987
2221
  });
1988
2222
  if (result.status !== "succeeded" && !step.continueOnFailure) {
1989
2223
  terminalStatus = result.status;
@@ -1995,7 +2229,9 @@ async function executeWorkflow(store, workflow, opts = {}) {
1995
2229
  for (const step of ordered) {
1996
2230
  const existing = store.getWorkflowStepRun(run.id, step.id);
1997
2231
  if (existing?.status === "pending" || existing?.status === "running") {
1998
- store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run");
2232
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
2233
+ daemonLeaseId: opts.daemonLeaseId
2234
+ });
1999
2235
  }
2000
2236
  }
2001
2237
  }
@@ -2005,10 +2241,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
2005
2241
  const steps2 = store.listWorkflowStepRuns(run.id);
2006
2242
  return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
2007
2243
  }
2244
+ opts.beforePersist?.();
2008
2245
  const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
2009
2246
  finishedAt,
2010
2247
  durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
2011
2248
  error: blockingError
2249
+ }, {
2250
+ daemonLeaseId: opts.daemonLeaseId
2012
2251
  });
2013
2252
  const steps = store.listWorkflowStepRuns(run.id);
2014
2253
  return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
@@ -2056,52 +2295,81 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
2056
2295
 
2057
2296
  // src/lib/scheduler.ts
2058
2297
  function manualRunScheduledFor(loop, now = new Date) {
2059
- if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
2298
+ if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
2060
2299
  return loop.retryScheduledFor ?? loop.nextRunAt;
2061
2300
  }
2062
2301
  return now.toISOString();
2063
2302
  }
2064
2303
  function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
2304
+ if (loop.status !== "active")
2305
+ return false;
2065
2306
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
2066
2307
  return false;
2067
2308
  return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
2068
2309
  }
2310
+ function manualRunSource(loop, scheduledFor, now = new Date) {
2311
+ if (loop.status !== "active")
2312
+ return "ad_hoc";
2313
+ if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
2314
+ return "ad_hoc";
2315
+ if (loop.retryScheduledFor && scheduledFor === loop.retryScheduledFor)
2316
+ return "retry_slot";
2317
+ return "due_slot";
2318
+ }
2069
2319
  function nextAfterRetry(loop, now) {
2070
2320
  return new Date(now.getTime() + loop.retryDelayMs).toISOString();
2071
2321
  }
2072
- function advanceLoop(store, loop, run, finishedAt, succeeded) {
2322
+ function isDaemonLeaseLost(error) {
2323
+ return error instanceof Error && error.message === "daemon lease lost";
2324
+ }
2325
+ function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
2073
2326
  if (run.status === "running")
2074
2327
  return;
2075
2328
  const current = store.getLoop(loop.id);
2076
2329
  if (!current || current.status !== "active")
2077
2330
  return;
2078
- const shouldRetry = !succeeded && run.attempt < loop.maxAttempts;
2331
+ if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
2332
+ return;
2333
+ const shouldRetry = !succeeded && run.attempt < current.maxAttempts;
2079
2334
  if (shouldRetry) {
2080
- store.updateLoop(loop.id, {
2335
+ store.updateLoop(current.id, {
2081
2336
  status: "active",
2082
- nextRunAt: nextAfterRetry(loop, finishedAt),
2337
+ nextRunAt: nextAfterRetry(current, finishedAt),
2083
2338
  retryScheduledFor: run.scheduledFor
2084
- });
2339
+ }, { daemonLeaseId: opts.daemonLeaseId });
2085
2340
  return;
2086
2341
  }
2087
- const nextRunAt = computeNextAfter(loop.schedule, new Date(run.scheduledFor), finishedAt);
2088
- store.updateLoop(loop.id, {
2342
+ const deferredRetry = store.nextRetryableRun(current.id, current.maxAttempts, run.scheduledFor);
2343
+ if (deferredRetry) {
2344
+ store.updateLoop(current.id, {
2345
+ status: "active",
2346
+ nextRunAt: nextAfterRetry(current, finishedAt),
2347
+ retryScheduledFor: deferredRetry.scheduledFor
2348
+ }, { daemonLeaseId: opts.daemonLeaseId });
2349
+ return;
2350
+ }
2351
+ const nextRunAt = computeNextAfter(current.schedule, new Date(run.scheduledFor), finishedAt);
2352
+ store.updateLoop(current.id, {
2089
2353
  status: nextRunAt ? "active" : "stopped",
2090
2354
  nextRunAt,
2091
2355
  retryScheduledFor: undefined
2092
- });
2356
+ }, { daemonLeaseId: opts.daemonLeaseId });
2093
2357
  }
2094
2358
  async function executeClaimedRun(deps) {
2095
2359
  let heartbeat;
2096
2360
  const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
2097
2361
  heartbeat = setInterval(() => {
2098
- deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
2362
+ deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs, new Date, {
2363
+ daemonLeaseId: deps.daemonLeaseId
2364
+ });
2099
2365
  }, heartbeatEveryMs);
2100
2366
  heartbeat.unref();
2101
2367
  try {
2102
2368
  const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
2103
- onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
2369
+ daemonLeaseId: deps.daemonLeaseId,
2370
+ onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId, { daemonLeaseId: deps.daemonLeaseId })
2104
2371
  })))(deps.loop, deps.run);
2372
+ deps.beforeFinalize?.(deps.loop, deps.run);
2105
2373
  return deps.store.finalizeRun(deps.run.id, {
2106
2374
  status: result.status,
2107
2375
  finishedAt: result.finishedAt,
@@ -2113,10 +2381,16 @@ async function executeClaimedRun(deps) {
2113
2381
  pid: result.pid
2114
2382
  }, {
2115
2383
  claimedBy: deps.runnerId,
2384
+ daemonLeaseId: deps.daemonLeaseId,
2116
2385
  now: deps.now?.() ?? new Date(result.finishedAt)
2117
2386
  });
2118
2387
  } catch (err) {
2119
2388
  deps.onError?.(deps.loop, err);
2389
+ try {
2390
+ deps.beforeFinalize?.(deps.loop, deps.run);
2391
+ } catch {
2392
+ return deps.store.getRun(deps.run.id) ?? deps.run;
2393
+ }
2120
2394
  const finishedAt = new Date;
2121
2395
  return deps.store.finalizeRun(deps.run.id, {
2122
2396
  status: "failed",
@@ -2127,6 +2401,7 @@ async function executeClaimedRun(deps) {
2127
2401
  error: err instanceof Error ? err.message : String(err)
2128
2402
  }, {
2129
2403
  claimedBy: deps.runnerId,
2404
+ daemonLeaseId: deps.daemonLeaseId,
2130
2405
  now: deps.now?.() ?? finishedAt
2131
2406
  });
2132
2407
  } finally {
@@ -2136,15 +2411,33 @@ async function executeClaimedRun(deps) {
2136
2411
  }
2137
2412
  async function runSlot(deps, loop, scheduledFor) {
2138
2413
  const now = deps.now?.() ?? new Date;
2414
+ deps.beforeRun?.(loop, scheduledFor);
2139
2415
  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);
2416
+ let skipped;
2417
+ try {
2418
+ skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
2419
+ daemonLeaseId: deps.daemonLeaseId
2420
+ });
2421
+ } catch (error) {
2422
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
2423
+ return;
2424
+ throw error;
2425
+ }
2426
+ advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
2142
2427
  deps.onRun?.(skipped);
2143
2428
  return skipped;
2144
2429
  }
2145
- const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
2430
+ let claim;
2431
+ try {
2432
+ claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
2433
+ } catch (error) {
2434
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
2435
+ return;
2436
+ throw error;
2437
+ }
2146
2438
  if (!claim)
2147
2439
  return;
2440
+ deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
2148
2441
  deps.onRun?.(claim.run);
2149
2442
  const finalRun = await executeClaimedRun({
2150
2443
  store: deps.store,
@@ -2153,21 +2446,42 @@ async function runSlot(deps, loop, scheduledFor) {
2153
2446
  run: claim.run,
2154
2447
  now: deps.now,
2155
2448
  execute: deps.execute,
2449
+ beforeFinalize: deps.beforeFinalize,
2450
+ daemonLeaseId: deps.daemonLeaseId,
2156
2451
  onError: deps.onError
2157
2452
  });
2158
- advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
2453
+ advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded", { daemonLeaseId: deps.daemonLeaseId });
2159
2454
  deps.onRun?.(finalRun);
2160
2455
  return finalRun;
2161
2456
  }
2162
2457
  async function tick(deps) {
2163
2458
  const now = deps.now?.() ?? new Date;
2164
- const recovered = deps.store.recoverExpiredRunLeases(now);
2459
+ const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
2460
+ const recoveredByLoop = new Map;
2165
2461
  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);
2462
+ recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
2463
+ }
2464
+ for (const runs of recoveredByLoop.values()) {
2465
+ const loop = deps.store.getLoop(runs[0].loopId);
2466
+ if (!loop)
2467
+ continue;
2468
+ const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
2469
+ if (retryable) {
2470
+ advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
2471
+ daemonLeaseId: deps.daemonLeaseId
2472
+ });
2473
+ continue;
2474
+ }
2475
+ for (const run of runs) {
2476
+ const current = deps.store.getLoop(run.loopId);
2477
+ if (current) {
2478
+ advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
2479
+ daemonLeaseId: deps.daemonLeaseId
2480
+ });
2481
+ }
2482
+ }
2169
2483
  }
2170
- const expired = deps.store.expireLoops(now);
2484
+ const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
2171
2485
  const claimed = [];
2172
2486
  const completed = [];
2173
2487
  const skipped = [];
@@ -2388,8 +2702,10 @@ async function runDaemon(opts = {}) {
2388
2702
  const result = await tick({
2389
2703
  store,
2390
2704
  runnerId,
2705
+ daemonLeaseId: leaseId,
2706
+ beforeRun: () => ensureLease(),
2391
2707
  execute: async (loop, run) => {
2392
- const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs / 3));
2708
+ const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
2393
2709
  const timer = setInterval(() => {
2394
2710
  try {
2395
2711
  ensureLease();
@@ -2401,8 +2717,14 @@ async function runDaemon(opts = {}) {
2401
2717
  try {
2402
2718
  const result2 = await executeLoopTarget(store, loop, run, {
2403
2719
  signal: runAbort.signal,
2404
- onSpawn: (pid) => store.markRunPid(run.id, pid, runnerId)
2720
+ beforePersist: () => ensureLease(),
2721
+ daemonLeaseId: leaseId,
2722
+ onSpawn: (pid) => {
2723
+ ensureLease();
2724
+ store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
2725
+ }
2405
2726
  });
2727
+ ensureLease();
2406
2728
  if (leaseLost)
2407
2729
  throw new Error("daemon lease lost during run");
2408
2730
  return result2;
@@ -2410,6 +2732,7 @@ async function runDaemon(opts = {}) {
2410
2732
  clearInterval(timer);
2411
2733
  }
2412
2734
  },
2735
+ beforeFinalize: () => ensureLease(),
2413
2736
  onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
2414
2737
  });
2415
2738
  const changed = result.completed.length + result.skipped.length + result.recovered.length + result.expired.length;
@@ -2615,7 +2938,7 @@ function runDoctor(store) {
2615
2938
 
2616
2939
  // src/cli/index.ts
2617
2940
  var program = new Command;
2618
- program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.2");
2941
+ program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.3");
2619
2942
  program.option("-j, --json", "print JSON");
2620
2943
  function isJson() {
2621
2944
  return Boolean(program.opts().json);
@@ -2834,7 +3157,7 @@ workflows.command("inspect <runId>").description("show a workflow run with steps
2834
3157
  const events = store.listWorkflowEvents(run.id);
2835
3158
  const value = {
2836
3159
  workflowRun: publicWorkflowRun(run),
2837
- steps: steps.map((step) => publicWorkflowStepRun(step, isJson())),
3160
+ steps: steps.map((step) => publicWorkflowStepRun(step)),
2838
3161
  events: events.map(publicWorkflowEvent)
2839
3162
  };
2840
3163
  if (isJson())
@@ -2842,7 +3165,8 @@ workflows.command("inspect <runId>").description("show a workflow run with steps
2842
3165
  else {
2843
3166
  console.log(`${run.id} ${run.status} ${run.workflowName}`);
2844
3167
  for (const step of steps) {
2845
- console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${step.error ?? ""}`);
3168
+ const publicStep = publicWorkflowStepRun(step);
3169
+ console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${publicStep.error ?? ""}`);
2846
3170
  }
2847
3171
  console.log(` events=${events.length}`);
2848
3172
  }
@@ -2858,7 +3182,7 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
2858
3182
  const run = store.listWorkflowRuns({ workflowId: workflow.id, limit: 1 })[0];
2859
3183
  const steps = run ? store.listWorkflowStepRuns(run.id) : [];
2860
3184
  const value = {
2861
- result,
3185
+ result: publicExecutorResult(result),
2862
3186
  workflowRun: run ? publicWorkflowRun(run) : undefined,
2863
3187
  steps: steps.map((step) => publicWorkflowStepRun(step, opts.showOutput))
2864
3188
  };
@@ -2867,7 +3191,8 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
2867
3191
  else {
2868
3192
  console.log(`${run?.id ?? workflow.id} ${result.status}`);
2869
3193
  for (const step of steps) {
2870
- console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${step.error ?? ""}`);
3194
+ const publicStep = publicWorkflowStepRun(step, opts.showOutput);
3195
+ console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${publicStep.error ?? ""}`);
2871
3196
  if (opts.showOutput)
2872
3197
  printTextOutput(step);
2873
3198
  }
@@ -3006,16 +3331,27 @@ program.command("run-now <idOrName>").option("--show-output", "show stdout/stder
3006
3331
  const loop = store.requireLoop(idOrName);
3007
3332
  const runnerId = `manual:${process.pid}`;
3008
3333
  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);
3334
+ let scheduledFor = manualRunScheduledFor(loop, now);
3335
+ let source = manualRunSource(loop, scheduledFor, now);
3336
+ let shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
3337
+ let claim = store.claimRun(loop, scheduledFor, runnerId, now);
3338
+ if (!claim && shouldAdvance) {
3339
+ const existing = store.getRunBySlot(loop.id, scheduledFor);
3340
+ if (existing && existing.status !== "running") {
3341
+ scheduledFor = now.toISOString();
3342
+ source = "ad_hoc";
3343
+ shouldAdvance = false;
3344
+ claim = store.claimRun(loop, scheduledFor, runnerId, now);
3345
+ }
3346
+ }
3012
3347
  if (!claim)
3013
3348
  throw new Error("could not claim manual run");
3014
3349
  const run = await executeClaimedRun({ store, runnerId, loop: claim.loop, run: claim.run });
3015
3350
  if (shouldAdvance) {
3016
3351
  advanceLoop(store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
3017
3352
  }
3018
- print(publicRun(run, opts.showOutput), `${run.id} ${run.status}`);
3353
+ const value = { ...publicRun(run, opts.showOutput), runNow: { source, advancesLoop: shouldAdvance } };
3354
+ print(value, `${run.id} ${run.status} source=${source} slot=${run.scheduledFor}`);
3019
3355
  if (!isJson() && opts.showOutput)
3020
3356
  printTextOutput(run);
3021
3357
  if (run.status !== "succeeded")