@hasna/loops 0.3.1 → 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
@@ -260,6 +260,11 @@ function validateTarget(value, label) {
260
260
  const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
261
261
  if (!providers.includes(value.provider))
262
262
  throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
263
+ if (value.authProfile !== undefined) {
264
+ assertString(value.authProfile, `${label}.authProfile`);
265
+ if (value.provider !== "codewith")
266
+ throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
267
+ }
263
268
  return value;
264
269
  }
265
270
  throw new Error(`${label}.type must be command or agent`);
@@ -610,6 +615,13 @@ class Store {
610
615
  } catch {}
611
616
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
612
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
+ }
613
625
  createLoop(input, from = new Date) {
614
626
  const now = nowIso();
615
627
  const loop = {
@@ -679,21 +691,31 @@ class Store {
679
691
  ORDER BY next_run_at ASC`).all(now.toISOString());
680
692
  return rows.map(rowToLoop);
681
693
  }
682
- updateLoop(id, patch) {
694
+ updateLoop(id, patch, opts = {}) {
683
695
  const current = this.getLoop(id);
684
696
  if (!current)
685
697
  throw new Error(`loop not found: ${id}`);
686
- const merged = { ...current, ...patch, updatedAt: nowIso() };
698
+ const updated = (opts.now ?? new Date).toISOString();
699
+ const merged = { ...current, ...patch, updatedAt: updated };
687
700
  this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
688
- 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({
689
706
  $id: id,
690
707
  $status: merged.status,
691
708
  $nextRun: merged.nextRunAt ?? null,
692
709
  $retrySlot: merged.retryScheduledFor ?? null,
693
710
  $expiresAt: merged.expiresAt ?? null,
694
- $updated: merged.updatedAt
711
+ $updated: merged.updatedAt,
712
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
713
+ $now: updated
695
714
  });
696
- return merged;
715
+ const after = this.getLoop(id);
716
+ if (!after)
717
+ throw new Error(`loop not found after update: ${id}`);
718
+ return after;
697
719
  }
698
720
  deleteLoop(idOrName) {
699
721
  const loop = this.requireLoop(idOrName);
@@ -757,11 +779,14 @@ class Store {
757
779
  const now = nowIso();
758
780
  if (input.idempotencyKey) {
759
781
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
760
- if (existing)
782
+ if (existing) {
783
+ this.assertDaemonLeaseFence(input);
761
784
  return rowToWorkflowRun(existing);
785
+ }
762
786
  }
763
787
  this.db.exec("BEGIN IMMEDIATE");
764
788
  try {
789
+ this.assertDaemonLeaseFence(input, now);
765
790
  if (input.idempotencyKey) {
766
791
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
767
792
  if (existing) {
@@ -860,31 +885,60 @@ class Store {
860
885
  const run = this.getWorkflowRun(workflowRunId);
861
886
  return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
862
887
  }
863
- startWorkflowStepRun(workflowRunId, stepId) {
864
- const now = nowIso();
865
- const res = this.db.query(`UPDATE workflow_step_runs
866
- SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
867
- pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
868
- WHERE workflow_run_id=$workflowRunId
869
- AND step_id=$stepId
870
- AND status IN ('pending', 'failed', 'timed_out')
871
- AND EXISTS (
872
- SELECT 1 FROM workflow_runs
873
- WHERE id=$workflowRunId AND status='running'
874
- )`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
875
- const run = this.getWorkflowStepRun(workflowRunId, stepId);
876
- if (!run)
877
- throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
878
- if (res.changes !== 1) {
879
- 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;
880
926
  }
881
- this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
882
- return run;
883
927
  }
884
- markWorkflowStepPid(workflowRunId, stepId, pid) {
885
- const now = nowIso();
928
+ markWorkflowStepPid(workflowRunId, stepId, pid, opts = {}) {
929
+ const now = (opts.now ?? new Date).toISOString();
886
930
  this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
887
- 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
+ });
888
942
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
889
943
  if (!run)
890
944
  throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
@@ -912,60 +966,110 @@ class Store {
912
966
  recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
913
967
  };
914
968
  }
915
- finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
969
+ finalizeWorkflowStepRun(workflowRunId, stepId, patch, opts = {}) {
916
970
  const finishedAt = patch.finishedAt ?? nowIso();
917
- const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
918
- pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
919
- WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({
920
- $workflowRunId: workflowRunId,
921
- $stepId: stepId,
922
- $status: patch.status,
923
- $finished: finishedAt,
924
- $exitCode: patch.exitCode ?? null,
925
- $durationMs: patch.durationMs ?? null,
926
- $stdout: patch.stdout ?? null,
927
- $stderr: patch.stderr ?? null,
928
- $error: patch.error ?? null,
929
- $updated: finishedAt
930
- });
931
- if (res.changes === 1) {
932
- this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
933
- exitCode: patch.exitCode,
934
- 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()
935
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;
936
1004
  }
937
1005
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
938
1006
  if (!run)
939
1007
  throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
940
1008
  return run;
941
1009
  }
942
- skipWorkflowStepRun(workflowRunId, stepId, reason) {
943
- const now = nowIso();
944
- const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
945
- 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 });
946
- if (res.changes === 1)
947
- 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
+ }
948
1036
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
949
1037
  if (!run)
950
1038
  throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
951
1039
  return run;
952
1040
  }
953
- finalizeWorkflowRun(workflowRunId, status, patch = {}) {
1041
+ finalizeWorkflowRun(workflowRunId, status, patch = {}, opts = {}) {
954
1042
  const finishedAt = patch.finishedAt ?? nowIso();
955
- const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
956
- WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({
957
- $id: workflowRunId,
958
- $status: status,
959
- $finished: finishedAt,
960
- $durationMs: patch.durationMs ?? null,
961
- $error: patch.error ?? null,
962
- $updated: finishedAt
963
- });
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
+ }
964
1070
  const run = this.getWorkflowRun(workflowRunId);
965
1071
  if (!run)
966
1072
  throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
967
- if (res.changes === 1)
968
- this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
969
1073
  return run;
970
1074
  }
971
1075
  cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
@@ -1019,10 +1123,24 @@ class Store {
1019
1123
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
1020
1124
  return (row?.count ?? 0) > 0;
1021
1125
  }
1022
- markRunPid(id, pid, claimedBy) {
1023
- const now = nowIso();
1126
+ markRunPid(id, pid, claimedBy, opts = {}) {
1127
+ const now = (opts.now ?? new Date).toISOString();
1024
1128
  const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
1025
- 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 });
1026
1144
  if (res.changes !== 1)
1027
1145
  return;
1028
1146
  return this.getRun(id);
@@ -1037,7 +1155,7 @@ class Store {
1037
1155
  AND wsr.pid IS NOT NULL`).all(loopRunId);
1038
1156
  return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
1039
1157
  }
1040
- createSkippedRun(loop, scheduledFor, reason) {
1158
+ createSkippedRun(loop, scheduledFor, reason, opts = {}) {
1041
1159
  const now = nowIso();
1042
1160
  const run = {
1043
1161
  id: genId(),
@@ -1051,21 +1169,31 @@ class Store {
1051
1169
  createdAt: now,
1052
1170
  updatedAt: now
1053
1171
  };
1054
- this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
1055
- claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
1056
- VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
1057
- NULL, NULL, $error, $created, $updated)`).run({
1058
- $id: run.id,
1059
- $loopId: run.loopId,
1060
- $loopName: run.loopName,
1061
- $scheduledFor: run.scheduledFor,
1062
- $attempt: run.attempt,
1063
- $status: run.status,
1064
- $finished: run.finishedAt ?? null,
1065
- $error: run.error ?? null,
1066
- $created: run.createdAt,
1067
- $updated: run.updatedAt
1068
- });
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
+ }
1069
1197
  return this.getRunBySlot(loop.id, scheduledFor) ?? run;
1070
1198
  }
1071
1199
  getRun(id) {
@@ -1076,11 +1204,20 @@ class Store {
1076
1204
  const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
1077
1205
  return row ? rowToRun(row) : undefined;
1078
1206
  }
1079
- 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 = {}) {
1080
1216
  const startedAt = now.toISOString();
1081
1217
  const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1082
1218
  this.db.exec("BEGIN IMMEDIATE");
1083
1219
  try {
1220
+ this.assertDaemonLeaseFence(opts, startedAt);
1084
1221
  const existing = this.getRunBySlot(loop.id, scheduledFor);
1085
1222
  if (existing) {
1086
1223
  if (existing.status === "running") {
@@ -1175,11 +1312,15 @@ class Store {
1175
1312
  $error: patch.error ?? null,
1176
1313
  $updated: finishedAt,
1177
1314
  $claimedBy: opts.claimedBy ?? null,
1178
- $now: (opts.now ?? new Date).toISOString()
1315
+ $now: (opts.now ?? new Date).toISOString(),
1316
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1179
1317
  };
1180
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,
1181
1319
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
1182
- 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,
1183
1324
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
1184
1325
  const run = this.getRun(id);
1185
1326
  if (!run)
@@ -1188,10 +1329,20 @@ class Store {
1188
1329
  return run;
1189
1330
  return run;
1190
1331
  }
1191
- heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
1332
+ heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
1192
1333
  const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
1193
1334
  const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
1194
- 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
+ });
1195
1346
  if (res.changes !== 1)
1196
1347
  return;
1197
1348
  return this.getRun(id);
@@ -1210,7 +1361,7 @@ class Store {
1210
1361
  }
1211
1362
  return rows.map(rowToRun);
1212
1363
  }
1213
- recoverExpiredRunLeases(now = new Date) {
1364
+ recoverExpiredRunLeases(now = new Date, opts = {}) {
1214
1365
  const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
1215
1366
  const recovered = [];
1216
1367
  for (const row of rows) {
@@ -1219,20 +1370,63 @@ class Store {
1219
1370
  if (this.hasLiveWorkflowStepProcesses(row.id))
1220
1371
  continue;
1221
1372
  const finished = now.toISOString();
1222
- this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
1223
- error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: finished, $updated: finished });
1224
- 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);
1225
- for (const workflowRow of workflowRows) {
1226
- this.db.query(`UPDATE workflow_runs
1227
- SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
1228
- WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRow.id, $finished: finished, $updated: finished });
1229
- this.db.query(`UPDATE workflow_step_runs
1230
- SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
1231
- WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRow.id, $finished: finished, $updated: finished });
1232
- this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
1233
- error: "parent loop run lease expired before completion",
1234
- 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
1235
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;
1236
1430
  }
1237
1431
  const run = this.getRun(row.id);
1238
1432
  if (run)
@@ -1240,11 +1434,14 @@ class Store {
1240
1434
  }
1241
1435
  return recovered;
1242
1436
  }
1243
- expireLoops(now = new Date) {
1437
+ expireLoops(now = new Date, opts = {}) {
1244
1438
  const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
1245
1439
  const expired = [];
1246
- for (const row of rows)
1247
- 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
+ }
1248
1445
  return expired;
1249
1446
  }
1250
1447
  countLoops(status) {
@@ -1287,7 +1484,7 @@ class Store {
1287
1484
  }
1288
1485
  heartbeatDaemonLease(id, ttlMs, now = new Date) {
1289
1486
  const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
1290
- 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() });
1291
1488
  if (res.changes !== 1)
1292
1489
  return;
1293
1490
  return this.getDaemonLease();
@@ -1310,11 +1507,14 @@ import { Command } from "commander";
1310
1507
 
1311
1508
  // src/lib/format.ts
1312
1509
  var TEXT_OUTPUT_LIMIT = 32 * 1024;
1313
- function redact(value, visible = 80) {
1510
+ var SENSITIVE_PAYLOAD_KEYS = new Set(["env", "error", "prompt", "reason", "stderr", "stdout"]);
1511
+ function redact(value, visible = 0) {
1314
1512
  if (!value)
1315
1513
  return value;
1316
1514
  if (value.length <= visible)
1317
1515
  return value;
1516
+ if (visible <= 0)
1517
+ return `[redacted ${value.length} chars]`;
1318
1518
  return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
1319
1519
  }
1320
1520
  function truncateTextOutput(value) {
@@ -1323,6 +1523,21 @@ function truncateTextOutput(value) {
1323
1523
  return `${value.slice(0, TEXT_OUTPUT_LIMIT)}
1324
1524
  [truncated ${value.length - TEXT_OUTPUT_LIMIT} chars]`;
1325
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
+ }
1326
1541
  function textOutputBlocks(value, opts = {}) {
1327
1542
  const indent = opts.indent ?? "";
1328
1543
  const nested = `${indent} `;
@@ -1354,6 +1569,14 @@ function publicRun(run, showOutput = false) {
1354
1569
  stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
1355
1570
  };
1356
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
+ }
1357
1580
  function publicWorkflow(workflow) {
1358
1581
  return {
1359
1582
  ...workflow,
@@ -1364,17 +1587,18 @@ function publicWorkflow(workflow) {
1364
1587
  };
1365
1588
  }
1366
1589
  function publicWorkflowRun(run) {
1367
- return { ...run };
1590
+ return { ...run, error: redact(run.error) };
1368
1591
  }
1369
1592
  function publicWorkflowStepRun(run, showOutput = false) {
1370
1593
  return {
1371
1594
  ...run,
1372
1595
  stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
1373
- 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)
1374
1598
  };
1375
1599
  }
1376
1600
  function publicWorkflowEvent(event) {
1377
- return { ...event };
1601
+ return { ...event, payload: redactSensitivePayload(event.payload) };
1378
1602
  }
1379
1603
 
1380
1604
  // src/lib/executor.ts
@@ -1610,7 +1834,7 @@ function agentArgs(target) {
1610
1834
  args.push("--model", target.model);
1611
1835
  if (target.agent)
1612
1836
  args.push("--agent", target.agent);
1613
- args.push(...target.extraArgs ?? [], target.prompt);
1837
+ args.push(...target.extraArgs ?? []);
1614
1838
  return args;
1615
1839
  case "cursor":
1616
1840
  args.push("-p");
@@ -1618,10 +1842,10 @@ function agentArgs(target) {
1618
1842
  args.push("--model", target.model);
1619
1843
  if (target.agent)
1620
1844
  args.push("--agent", target.agent);
1621
- args.push(...target.extraArgs ?? [], target.prompt);
1845
+ args.push(...target.extraArgs ?? []);
1622
1846
  return args;
1623
1847
  case "codewith":
1624
- args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
1848
+ args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
1625
1849
  if (isolation === "safe")
1626
1850
  args.push("--ignore-rules");
1627
1851
  if (target.cwd)
@@ -1630,7 +1854,7 @@ function agentArgs(target) {
1630
1854
  args.push("--model", target.model);
1631
1855
  if (target.agent)
1632
1856
  args.push("--agent", target.agent);
1633
- args.push(...target.extraArgs ?? [], target.prompt);
1857
+ args.push(...target.extraArgs ?? []);
1634
1858
  return args;
1635
1859
  case "codex":
1636
1860
  args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
@@ -1640,7 +1864,7 @@ function agentArgs(target) {
1640
1864
  args.push("--cd", target.cwd);
1641
1865
  if (target.model)
1642
1866
  args.push("--model", target.model);
1643
- args.push(...target.extraArgs ?? [], target.prompt);
1867
+ args.push(...target.extraArgs ?? []);
1644
1868
  return args;
1645
1869
  case "aicopilot":
1646
1870
  args.push("run", "--format", "json");
@@ -1652,7 +1876,7 @@ function agentArgs(target) {
1652
1876
  args.push("--model", target.model);
1653
1877
  if (target.agent)
1654
1878
  args.push("--agent", target.agent);
1655
- args.push(...target.extraArgs ?? [], target.prompt);
1879
+ args.push(...target.extraArgs ?? []);
1656
1880
  return args;
1657
1881
  case "opencode":
1658
1882
  args.push("run", "--format", "json");
@@ -1664,7 +1888,7 @@ function agentArgs(target) {
1664
1888
  args.push("--model", target.model);
1665
1889
  if (target.agent)
1666
1890
  args.push("--agent", target.agent);
1667
- args.push(...target.extraArgs ?? [], target.prompt);
1891
+ args.push(...target.extraArgs ?? []);
1668
1892
  return args;
1669
1893
  }
1670
1894
  }
@@ -1689,7 +1913,8 @@ function commandSpec(target) {
1689
1913
  cwd: agentTarget.cwd,
1690
1914
  timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1691
1915
  account: agentTarget.account,
1692
- accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
1916
+ accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
1917
+ stdin: agentTarget.prompt
1693
1918
  };
1694
1919
  }
1695
1920
  function executionEnv(spec, metadata, opts) {
@@ -1758,10 +1983,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1758
1983
  env,
1759
1984
  shell: spec.shell ?? false,
1760
1985
  detached: true,
1761
- stdio: ["ignore", "pipe", "pipe"]
1986
+ stdio: spec.stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
1762
1987
  });
1763
1988
  if (child.pid)
1764
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
+ }
1765
1997
  const abortHandler = () => {
1766
1998
  error = "cancelled";
1767
1999
  if (child.pid)
@@ -1770,10 +2002,10 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1770
2002
  if (opts.signal?.aborted)
1771
2003
  abortHandler();
1772
2004
  opts.signal?.addEventListener("abort", abortHandler, { once: true });
1773
- child.stdout.on("data", (chunk) => {
2005
+ child.stdout?.on("data", (chunk) => {
1774
2006
  stdout = appendBounded(stdout, chunk, maxOutputBytes);
1775
2007
  });
1776
- child.stderr.on("data", (chunk) => {
2008
+ child.stderr?.on("data", (chunk) => {
1777
2009
  stderr = appendBounded(stderr, chunk, maxOutputBytes);
1778
2010
  });
1779
2011
  const timer = setTimeout(() => {
@@ -1872,7 +2104,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1872
2104
  loop: opts.loop,
1873
2105
  loopRun: opts.loopRun,
1874
2106
  scheduledFor: opts.scheduledFor,
1875
- idempotencyKey: opts.idempotencyKey
2107
+ idempotencyKey: opts.idempotencyKey,
2108
+ daemonLeaseId: opts.daemonLeaseId
1876
2109
  });
1877
2110
  const startedAt = run.startedAt ?? nowIso();
1878
2111
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
@@ -1906,12 +2139,14 @@ async function executeWorkflow(store, workflow, opts = {}) {
1906
2139
  return !dependencyStep?.continueOnFailure;
1907
2140
  });
1908
2141
  if (blockedBy) {
1909
- 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 });
1910
2144
  blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
1911
2145
  terminalStatus = "failed";
1912
2146
  continue;
1913
2147
  }
1914
- const startedStep = store.startWorkflowStepRun(run.id, step.id);
2148
+ opts.beforePersist?.();
2149
+ const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
1915
2150
  if (startedStep.status !== "running") {
1916
2151
  terminalStatus = "failed";
1917
2152
  blockingError = `step ${step.id} could not start because workflow is no longer running`;
@@ -1943,7 +2178,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1943
2178
  ...opts,
1944
2179
  signal: controller.signal,
1945
2180
  onSpawn: (pid) => {
1946
- store.markWorkflowStepPid(run.id, step.id, pid);
2181
+ opts.beforePersist?.();
2182
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
1947
2183
  opts.onSpawn?.(pid);
1948
2184
  }
1949
2185
  });
@@ -1971,6 +2207,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
1971
2207
  blockingError = "workflow run was cancelled";
1972
2208
  break;
1973
2209
  }
2210
+ opts.beforePersist?.();
1974
2211
  store.finalizeWorkflowStepRun(run.id, step.id, {
1975
2212
  status: result.status,
1976
2213
  finishedAt: result.finishedAt,
@@ -1979,6 +2216,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
1979
2216
  stderr: result.stderr,
1980
2217
  exitCode: result.exitCode,
1981
2218
  error: result.error
2219
+ }, {
2220
+ daemonLeaseId: opts.daemonLeaseId
1982
2221
  });
1983
2222
  if (result.status !== "succeeded" && !step.continueOnFailure) {
1984
2223
  terminalStatus = result.status;
@@ -1990,7 +2229,9 @@ async function executeWorkflow(store, workflow, opts = {}) {
1990
2229
  for (const step of ordered) {
1991
2230
  const existing = store.getWorkflowStepRun(run.id, step.id);
1992
2231
  if (existing?.status === "pending" || existing?.status === "running") {
1993
- 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
+ });
1994
2235
  }
1995
2236
  }
1996
2237
  }
@@ -2000,10 +2241,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
2000
2241
  const steps2 = store.listWorkflowStepRuns(run.id);
2001
2242
  return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
2002
2243
  }
2244
+ opts.beforePersist?.();
2003
2245
  const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
2004
2246
  finishedAt,
2005
2247
  durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
2006
2248
  error: blockingError
2249
+ }, {
2250
+ daemonLeaseId: opts.daemonLeaseId
2007
2251
  });
2008
2252
  const steps = store.listWorkflowStepRuns(run.id);
2009
2253
  return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
@@ -2051,52 +2295,81 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
2051
2295
 
2052
2296
  // src/lib/scheduler.ts
2053
2297
  function manualRunScheduledFor(loop, now = new Date) {
2054
- if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
2298
+ if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
2055
2299
  return loop.retryScheduledFor ?? loop.nextRunAt;
2056
2300
  }
2057
2301
  return now.toISOString();
2058
2302
  }
2059
2303
  function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
2304
+ if (loop.status !== "active")
2305
+ return false;
2060
2306
  if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
2061
2307
  return false;
2062
2308
  return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
2063
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
+ }
2064
2319
  function nextAfterRetry(loop, now) {
2065
2320
  return new Date(now.getTime() + loop.retryDelayMs).toISOString();
2066
2321
  }
2067
- 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 = {}) {
2068
2326
  if (run.status === "running")
2069
2327
  return;
2070
2328
  const current = store.getLoop(loop.id);
2071
2329
  if (!current || current.status !== "active")
2072
2330
  return;
2073
- 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;
2074
2334
  if (shouldRetry) {
2075
- store.updateLoop(loop.id, {
2335
+ store.updateLoop(current.id, {
2076
2336
  status: "active",
2077
- nextRunAt: nextAfterRetry(loop, finishedAt),
2337
+ nextRunAt: nextAfterRetry(current, finishedAt),
2078
2338
  retryScheduledFor: run.scheduledFor
2079
- });
2339
+ }, { daemonLeaseId: opts.daemonLeaseId });
2340
+ return;
2341
+ }
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 });
2080
2349
  return;
2081
2350
  }
2082
- const nextRunAt = computeNextAfter(loop.schedule, new Date(run.scheduledFor), finishedAt);
2083
- store.updateLoop(loop.id, {
2351
+ const nextRunAt = computeNextAfter(current.schedule, new Date(run.scheduledFor), finishedAt);
2352
+ store.updateLoop(current.id, {
2084
2353
  status: nextRunAt ? "active" : "stopped",
2085
2354
  nextRunAt,
2086
2355
  retryScheduledFor: undefined
2087
- });
2356
+ }, { daemonLeaseId: opts.daemonLeaseId });
2088
2357
  }
2089
2358
  async function executeClaimedRun(deps) {
2090
2359
  let heartbeat;
2091
2360
  const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
2092
2361
  heartbeat = setInterval(() => {
2093
- 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
+ });
2094
2365
  }, heartbeatEveryMs);
2095
2366
  heartbeat.unref();
2096
2367
  try {
2097
2368
  const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
2098
- 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 })
2099
2371
  })))(deps.loop, deps.run);
2372
+ deps.beforeFinalize?.(deps.loop, deps.run);
2100
2373
  return deps.store.finalizeRun(deps.run.id, {
2101
2374
  status: result.status,
2102
2375
  finishedAt: result.finishedAt,
@@ -2108,10 +2381,16 @@ async function executeClaimedRun(deps) {
2108
2381
  pid: result.pid
2109
2382
  }, {
2110
2383
  claimedBy: deps.runnerId,
2384
+ daemonLeaseId: deps.daemonLeaseId,
2111
2385
  now: deps.now?.() ?? new Date(result.finishedAt)
2112
2386
  });
2113
2387
  } catch (err) {
2114
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
+ }
2115
2394
  const finishedAt = new Date;
2116
2395
  return deps.store.finalizeRun(deps.run.id, {
2117
2396
  status: "failed",
@@ -2122,6 +2401,7 @@ async function executeClaimedRun(deps) {
2122
2401
  error: err instanceof Error ? err.message : String(err)
2123
2402
  }, {
2124
2403
  claimedBy: deps.runnerId,
2404
+ daemonLeaseId: deps.daemonLeaseId,
2125
2405
  now: deps.now?.() ?? finishedAt
2126
2406
  });
2127
2407
  } finally {
@@ -2131,15 +2411,33 @@ async function executeClaimedRun(deps) {
2131
2411
  }
2132
2412
  async function runSlot(deps, loop, scheduledFor) {
2133
2413
  const now = deps.now?.() ?? new Date;
2414
+ deps.beforeRun?.(loop, scheduledFor);
2134
2415
  if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
2135
- const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
2136
- 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 });
2137
2427
  deps.onRun?.(skipped);
2138
2428
  return skipped;
2139
2429
  }
2140
- 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
+ }
2141
2438
  if (!claim)
2142
2439
  return;
2440
+ deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
2143
2441
  deps.onRun?.(claim.run);
2144
2442
  const finalRun = await executeClaimedRun({
2145
2443
  store: deps.store,
@@ -2148,21 +2446,42 @@ async function runSlot(deps, loop, scheduledFor) {
2148
2446
  run: claim.run,
2149
2447
  now: deps.now,
2150
2448
  execute: deps.execute,
2449
+ beforeFinalize: deps.beforeFinalize,
2450
+ daemonLeaseId: deps.daemonLeaseId,
2151
2451
  onError: deps.onError
2152
2452
  });
2153
- 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 });
2154
2454
  deps.onRun?.(finalRun);
2155
2455
  return finalRun;
2156
2456
  }
2157
2457
  async function tick(deps) {
2158
2458
  const now = deps.now?.() ?? new Date;
2159
- const recovered = deps.store.recoverExpiredRunLeases(now);
2459
+ const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
2460
+ const recoveredByLoop = new Map;
2160
2461
  for (const run of recovered) {
2161
- const loop = deps.store.getLoop(run.loopId);
2162
- if (loop)
2163
- 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
+ }
2164
2483
  }
2165
- const expired = deps.store.expireLoops(now);
2484
+ const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
2166
2485
  const claimed = [];
2167
2486
  const completed = [];
2168
2487
  const skipped = [];
@@ -2383,8 +2702,10 @@ async function runDaemon(opts = {}) {
2383
2702
  const result = await tick({
2384
2703
  store,
2385
2704
  runnerId,
2705
+ daemonLeaseId: leaseId,
2706
+ beforeRun: () => ensureLease(),
2386
2707
  execute: async (loop, run) => {
2387
- const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs / 3));
2708
+ const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
2388
2709
  const timer = setInterval(() => {
2389
2710
  try {
2390
2711
  ensureLease();
@@ -2396,8 +2717,14 @@ async function runDaemon(opts = {}) {
2396
2717
  try {
2397
2718
  const result2 = await executeLoopTarget(store, loop, run, {
2398
2719
  signal: runAbort.signal,
2399
- 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
+ }
2400
2726
  });
2727
+ ensureLease();
2401
2728
  if (leaseLost)
2402
2729
  throw new Error("daemon lease lost during run");
2403
2730
  return result2;
@@ -2405,6 +2732,7 @@ async function runDaemon(opts = {}) {
2405
2732
  clearInterval(timer);
2406
2733
  }
2407
2734
  },
2735
+ beforeFinalize: () => ensureLease(),
2408
2736
  onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
2409
2737
  });
2410
2738
  const changed = result.completed.length + result.skipped.length + result.recovered.length + result.expired.length;
@@ -2610,7 +2938,7 @@ function runDoctor(store) {
2610
2938
 
2611
2939
  // src/cli/index.ts
2612
2940
  var program = new Command;
2613
- program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.1");
2941
+ program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.3");
2614
2942
  program.option("-j, --json", "print JSON");
2615
2943
  function isJson() {
2616
2944
  return Boolean(program.opts().json);
@@ -2704,6 +3032,13 @@ function accountFromOpts(opts) {
2704
3032
  throw new Error("--account-tool requires --account");
2705
3033
  return opts.account ? { profile: opts.account, tool: opts.accountTool } : undefined;
2706
3034
  }
3035
+ function providerAuthProfileFromOpts(opts, provider) {
3036
+ if (!opts.authProfile)
3037
+ return;
3038
+ if (provider !== "codewith")
3039
+ throw new Error("--auth-profile is currently supported only for --provider codewith");
3040
+ return opts.authProfile;
3041
+ }
2707
3042
  var create = program.command("create").description("create loops");
2708
3043
  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) => {
2709
3044
  const store = new Store;
@@ -2722,7 +3057,7 @@ addAccountOptions(addScheduleOptions(create.command("command <name>").descriptio
2722
3057
  store.close();
2723
3058
  }
2724
3059
  });
2725
- 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("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe"))).action((name, opts) => {
3060
+ 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) => {
2726
3061
  const provider = opts.provider;
2727
3062
  if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
2728
3063
  throw new Error("unsupported provider");
@@ -2739,6 +3074,7 @@ addAccountOptions(addScheduleOptions(create.command("agent <name>").description(
2739
3074
  cwd: opts.cwd,
2740
3075
  model: opts.model,
2741
3076
  agent: opts.agent,
3077
+ authProfile: providerAuthProfileFromOpts(opts, provider),
2742
3078
  timeoutMs: opts.timeout ? parseDuration(opts.timeout) : undefined,
2743
3079
  configIsolation: opts.configIsolation,
2744
3080
  account: accountFromOpts(opts)
@@ -2821,7 +3157,7 @@ workflows.command("inspect <runId>").description("show a workflow run with steps
2821
3157
  const events = store.listWorkflowEvents(run.id);
2822
3158
  const value = {
2823
3159
  workflowRun: publicWorkflowRun(run),
2824
- steps: steps.map((step) => publicWorkflowStepRun(step, isJson())),
3160
+ steps: steps.map((step) => publicWorkflowStepRun(step)),
2825
3161
  events: events.map(publicWorkflowEvent)
2826
3162
  };
2827
3163
  if (isJson())
@@ -2829,7 +3165,8 @@ workflows.command("inspect <runId>").description("show a workflow run with steps
2829
3165
  else {
2830
3166
  console.log(`${run.id} ${run.status} ${run.workflowName}`);
2831
3167
  for (const step of steps) {
2832
- 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 ?? ""}`);
2833
3170
  }
2834
3171
  console.log(` events=${events.length}`);
2835
3172
  }
@@ -2845,7 +3182,7 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
2845
3182
  const run = store.listWorkflowRuns({ workflowId: workflow.id, limit: 1 })[0];
2846
3183
  const steps = run ? store.listWorkflowStepRuns(run.id) : [];
2847
3184
  const value = {
2848
- result,
3185
+ result: publicExecutorResult(result),
2849
3186
  workflowRun: run ? publicWorkflowRun(run) : undefined,
2850
3187
  steps: steps.map((step) => publicWorkflowStepRun(step, opts.showOutput))
2851
3188
  };
@@ -2854,7 +3191,8 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
2854
3191
  else {
2855
3192
  console.log(`${run?.id ?? workflow.id} ${result.status}`);
2856
3193
  for (const step of steps) {
2857
- 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 ?? ""}`);
2858
3196
  if (opts.showOutput)
2859
3197
  printTextOutput(step);
2860
3198
  }
@@ -2993,18 +3331,31 @@ program.command("run-now <idOrName>").option("--show-output", "show stdout/stder
2993
3331
  const loop = store.requireLoop(idOrName);
2994
3332
  const runnerId = `manual:${process.pid}`;
2995
3333
  const now = new Date;
2996
- const scheduledFor = manualRunScheduledFor(loop, now);
2997
- const shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
2998
- 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
+ }
2999
3347
  if (!claim)
3000
3348
  throw new Error("could not claim manual run");
3001
3349
  const run = await executeClaimedRun({ store, runnerId, loop: claim.loop, run: claim.run });
3002
3350
  if (shouldAdvance) {
3003
3351
  advanceLoop(store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
3004
3352
  }
3005
- 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}`);
3006
3355
  if (!isJson() && opts.showOutput)
3007
3356
  printTextOutput(run);
3357
+ if (run.status !== "succeeded")
3358
+ process.exitCode = 1;
3008
3359
  } finally {
3009
3360
  store.close();
3010
3361
  }