@hasna/loops 0.3.9 → 0.3.10

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
@@ -1670,6 +1670,10 @@ class Store {
1670
1670
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
1671
1671
  return (row?.count ?? 0) > 0;
1672
1672
  }
1673
+ hasRunningRunForSlot(loopId, scheduledFor) {
1674
+ const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND scheduled_for = ? AND status = 'running'").get(loopId, scheduledFor);
1675
+ return (row?.count ?? 0) > 0;
1676
+ }
1673
1677
  markRunPid(id, pid, claimedBy, opts = {}) {
1674
1678
  const now = (opts.now ?? new Date).toISOString();
1675
1679
  const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
@@ -3719,6 +3723,92 @@ async function runSlot(deps, loop, scheduledFor) {
3719
3723
  deps.onRun?.(finalRun);
3720
3724
  return finalRun;
3721
3725
  }
3726
+ function claimSlot(deps, loop, scheduledFor) {
3727
+ const now = deps.now?.() ?? new Date;
3728
+ deps.beforeRun?.(loop, scheduledFor);
3729
+ if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
3730
+ if (deps.store.hasRunningRunForSlot(loop.id, scheduledFor))
3731
+ return;
3732
+ let skipped;
3733
+ try {
3734
+ skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
3735
+ daemonLeaseId: deps.daemonLeaseId
3736
+ });
3737
+ } catch (error) {
3738
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
3739
+ return;
3740
+ throw error;
3741
+ }
3742
+ advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
3743
+ deps.onRun?.(skipped);
3744
+ return skipped;
3745
+ }
3746
+ let claim;
3747
+ try {
3748
+ claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
3749
+ } catch (error) {
3750
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
3751
+ return;
3752
+ throw error;
3753
+ }
3754
+ if (!claim)
3755
+ return;
3756
+ deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
3757
+ deps.onRun?.(claim.run);
3758
+ return claim;
3759
+ }
3760
+ function claimDueRuns(deps) {
3761
+ const now = deps.now?.() ?? new Date;
3762
+ const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
3763
+ const recoveredByLoop = new Map;
3764
+ for (const run of recovered) {
3765
+ recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
3766
+ }
3767
+ for (const runs of recoveredByLoop.values()) {
3768
+ const loop = deps.store.getLoop(runs[0].loopId);
3769
+ if (!loop)
3770
+ continue;
3771
+ const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
3772
+ if (retryable) {
3773
+ advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
3774
+ daemonLeaseId: deps.daemonLeaseId
3775
+ });
3776
+ continue;
3777
+ }
3778
+ for (const run of runs) {
3779
+ const current = deps.store.getLoop(run.loopId);
3780
+ if (current) {
3781
+ advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
3782
+ daemonLeaseId: deps.daemonLeaseId
3783
+ });
3784
+ }
3785
+ }
3786
+ }
3787
+ const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
3788
+ const claims = [];
3789
+ const claimed = [];
3790
+ const skipped = [];
3791
+ const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
3792
+ for (const loop of deps.store.dueLoops(now)) {
3793
+ if (claims.length >= maxClaims)
3794
+ break;
3795
+ const plan = dueSlots(loop, now);
3796
+ for (const slot of plan.slots) {
3797
+ if (claims.length >= maxClaims)
3798
+ break;
3799
+ const run = claimSlot(deps, loop, slot);
3800
+ if (!run)
3801
+ continue;
3802
+ if ("loop" in run) {
3803
+ claims.push(run);
3804
+ claimed.push(run.run);
3805
+ } else if (run.status === "skipped") {
3806
+ skipped.push(run);
3807
+ }
3808
+ }
3809
+ }
3810
+ return { claims, claimed, completed: [], skipped, recovered, expired };
3811
+ }
3722
3812
  async function tick(deps) {
3723
3813
  const now = deps.now?.() ?? new Date;
3724
3814
  const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
@@ -3905,6 +3995,13 @@ function intervalFromEnv() {
3905
3995
  const value = Number(raw);
3906
3996
  return Number.isFinite(value) && value > 0 ? value : undefined;
3907
3997
  }
3998
+ function concurrencyFromEnv() {
3999
+ const raw = process.env.LOOPS_DAEMON_CONCURRENCY;
4000
+ if (!raw)
4001
+ return;
4002
+ const value = Number(raw);
4003
+ return Number.isInteger(value) && value > 0 ? value : undefined;
4004
+ }
3908
4005
  async function runDaemon(opts = {}) {
3909
4006
  ensureDataDir();
3910
4007
  const pidPath = opts.pidPath ?? pidFilePath();
@@ -3919,6 +4016,7 @@ async function runDaemon(opts = {}) {
3919
4016
  const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
3920
4017
  const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
3921
4018
  const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
4019
+ const concurrency = Math.max(1, opts.concurrency ?? concurrencyFromEnv() ?? 4);
3922
4020
  const log = opts.log ?? ((message) => console.error(`[loops-daemon] ${message}`));
3923
4021
  const lease = store.acquireDaemonLease({
3924
4022
  id: leaseId,
@@ -3933,6 +4031,7 @@ async function runDaemon(opts = {}) {
3933
4031
  let stopFlag = false;
3934
4032
  let leaseLost = false;
3935
4033
  const runAbort = new AbortController;
4034
+ const activeRuns = new Map;
3936
4035
  const requestStop = (message) => {
3937
4036
  stopFlag = true;
3938
4037
  if (!runAbort.signal.aborted)
@@ -3956,6 +4055,48 @@ async function runDaemon(opts = {}) {
3956
4055
  opts.signal?.addEventListener("abort", onSignal, { once: true });
3957
4056
  process.on("SIGINT", onSignal);
3958
4057
  process.on("SIGTERM", onSignal);
4058
+ const executeDaemonRun = async (claim) => {
4059
+ const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
4060
+ const timer = setInterval(() => {
4061
+ try {
4062
+ ensureLease();
4063
+ } catch (err) {
4064
+ log(err instanceof Error ? err.message : String(err));
4065
+ }
4066
+ }, heartbeatMs);
4067
+ timer.unref();
4068
+ try {
4069
+ const finalRun = await executeClaimedRun({
4070
+ store,
4071
+ runnerId,
4072
+ loop: claim.loop,
4073
+ run: claim.run,
4074
+ daemonLeaseId: leaseId,
4075
+ beforeFinalize: () => ensureLease(),
4076
+ execute: opts.execute ?? ((loop, run) => executeLoopTarget(store, loop, run, {
4077
+ signal: runAbort.signal,
4078
+ beforePersist: () => ensureLease(),
4079
+ daemonLeaseId: leaseId,
4080
+ onSpawn: (pid) => {
4081
+ ensureLease();
4082
+ store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
4083
+ }
4084
+ })),
4085
+ onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
4086
+ });
4087
+ ensureLease();
4088
+ if (leaseLost)
4089
+ throw new Error("daemon lease lost during run");
4090
+ advanceLoop(store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded", { daemonLeaseId: leaseId });
4091
+ log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
4092
+ } finally {
4093
+ clearInterval(timer);
4094
+ }
4095
+ };
4096
+ const startClaim = (claim) => {
4097
+ const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => activeRuns.delete(claim.run.id));
4098
+ activeRuns.set(claim.run.id, task);
4099
+ };
3959
4100
  try {
3960
4101
  await runLoop({
3961
4102
  intervalMs,
@@ -3964,49 +4105,26 @@ async function runDaemon(opts = {}) {
3964
4105
  onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
3965
4106
  tickFn: async () => {
3966
4107
  ensureLease();
3967
- const result = await tick({
4108
+ const available = Math.max(0, concurrency - activeRuns.size);
4109
+ const result = claimDueRuns({
3968
4110
  store,
3969
4111
  runnerId,
3970
4112
  daemonLeaseId: leaseId,
3971
4113
  beforeRun: () => ensureLease(),
3972
- execute: async (loop, run) => {
3973
- const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
3974
- const timer = setInterval(() => {
3975
- try {
3976
- ensureLease();
3977
- } catch (err) {
3978
- log(err instanceof Error ? err.message : String(err));
3979
- }
3980
- }, heartbeatMs);
3981
- timer.unref();
3982
- try {
3983
- const result2 = await executeLoopTarget(store, loop, run, {
3984
- signal: runAbort.signal,
3985
- beforePersist: () => ensureLease(),
3986
- daemonLeaseId: leaseId,
3987
- onSpawn: (pid) => {
3988
- ensureLease();
3989
- store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
3990
- }
3991
- });
3992
- ensureLease();
3993
- if (leaseLost)
3994
- throw new Error("daemon lease lost during run");
3995
- return result2;
3996
- } finally {
3997
- clearInterval(timer);
3998
- }
3999
- },
4000
- beforeFinalize: () => ensureLease(),
4001
- onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
4114
+ maxClaims: available
4002
4115
  });
4003
- const changed = result.completed.length + result.skipped.length + result.recovered.length + result.expired.length;
4116
+ for (const claim of result.claims)
4117
+ startClaim(claim);
4118
+ const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
4004
4119
  if (changed > 0) {
4005
- log(`tick completed=${result.completed.length} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
4120
+ log(`tick claimed=${result.claims.length} active=${activeRuns.size} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
4006
4121
  }
4007
4122
  }
4008
4123
  });
4009
4124
  } finally {
4125
+ if (activeRuns.size > 0 && !runAbort.signal.aborted)
4126
+ runAbort.abort();
4127
+ await Promise.allSettled([...activeRuns.values()]);
4010
4128
  opts.signal?.removeEventListener("abort", onSignal);
4011
4129
  process.off("SIGINT", onSignal);
4012
4130
  process.off("SIGTERM", onSignal);
@@ -4220,7 +4338,7 @@ function runDoctor(store) {
4220
4338
  // package.json
4221
4339
  var package_default = {
4222
4340
  name: "@hasna/loops",
4223
- version: "0.3.9",
4341
+ version: "0.3.10",
4224
4342
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4225
4343
  type: "module",
4226
4344
  main: "dist/index.js",
@@ -1,9 +1,12 @@
1
1
  import { Store } from "../lib/store.js";
2
+ import type { ExecutorResult, Loop, LoopRun } from "../types.js";
2
3
  export interface RunDaemonOptions {
3
4
  intervalMs?: number;
4
5
  leaseTtlMs?: number;
6
+ concurrency?: number;
5
7
  store?: Store;
6
8
  pidPath?: string;
9
+ execute?: (loop: Loop, run: LoopRun) => Promise<ExecutorResult>;
7
10
  shouldStop?: () => boolean;
8
11
  sleep?: (ms: number) => Promise<void>;
9
12
  log?: (message: string) => void;
@@ -1670,6 +1670,10 @@ class Store {
1670
1670
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
1671
1671
  return (row?.count ?? 0) > 0;
1672
1672
  }
1673
+ hasRunningRunForSlot(loopId, scheduledFor) {
1674
+ const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND scheduled_for = ? AND status = 'running'").get(loopId, scheduledFor);
1675
+ return (row?.count ?? 0) > 0;
1676
+ }
1673
1677
  markRunPid(id, pid, claimedBy, opts = {}) {
1674
1678
  const now = (opts.now ?? new Date).toISOString();
1675
1679
  const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
@@ -3614,6 +3618,92 @@ async function runSlot(deps, loop, scheduledFor) {
3614
3618
  deps.onRun?.(finalRun);
3615
3619
  return finalRun;
3616
3620
  }
3621
+ function claimSlot(deps, loop, scheduledFor) {
3622
+ const now = deps.now?.() ?? new Date;
3623
+ deps.beforeRun?.(loop, scheduledFor);
3624
+ if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
3625
+ if (deps.store.hasRunningRunForSlot(loop.id, scheduledFor))
3626
+ return;
3627
+ let skipped;
3628
+ try {
3629
+ skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
3630
+ daemonLeaseId: deps.daemonLeaseId
3631
+ });
3632
+ } catch (error) {
3633
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
3634
+ return;
3635
+ throw error;
3636
+ }
3637
+ advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
3638
+ deps.onRun?.(skipped);
3639
+ return skipped;
3640
+ }
3641
+ let claim;
3642
+ try {
3643
+ claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
3644
+ } catch (error) {
3645
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
3646
+ return;
3647
+ throw error;
3648
+ }
3649
+ if (!claim)
3650
+ return;
3651
+ deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
3652
+ deps.onRun?.(claim.run);
3653
+ return claim;
3654
+ }
3655
+ function claimDueRuns(deps) {
3656
+ const now = deps.now?.() ?? new Date;
3657
+ const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
3658
+ const recoveredByLoop = new Map;
3659
+ for (const run of recovered) {
3660
+ recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
3661
+ }
3662
+ for (const runs of recoveredByLoop.values()) {
3663
+ const loop = deps.store.getLoop(runs[0].loopId);
3664
+ if (!loop)
3665
+ continue;
3666
+ const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
3667
+ if (retryable) {
3668
+ advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
3669
+ daemonLeaseId: deps.daemonLeaseId
3670
+ });
3671
+ continue;
3672
+ }
3673
+ for (const run of runs) {
3674
+ const current = deps.store.getLoop(run.loopId);
3675
+ if (current) {
3676
+ advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
3677
+ daemonLeaseId: deps.daemonLeaseId
3678
+ });
3679
+ }
3680
+ }
3681
+ }
3682
+ const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
3683
+ const claims = [];
3684
+ const claimed = [];
3685
+ const skipped = [];
3686
+ const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
3687
+ for (const loop of deps.store.dueLoops(now)) {
3688
+ if (claims.length >= maxClaims)
3689
+ break;
3690
+ const plan = dueSlots(loop, now);
3691
+ for (const slot of plan.slots) {
3692
+ if (claims.length >= maxClaims)
3693
+ break;
3694
+ const run = claimSlot(deps, loop, slot);
3695
+ if (!run)
3696
+ continue;
3697
+ if ("loop" in run) {
3698
+ claims.push(run);
3699
+ claimed.push(run.run);
3700
+ } else if (run.status === "skipped") {
3701
+ skipped.push(run);
3702
+ }
3703
+ }
3704
+ }
3705
+ return { claims, claimed, completed: [], skipped, recovered, expired };
3706
+ }
3617
3707
  async function tick(deps) {
3618
3708
  const now = deps.now?.() ?? new Date;
3619
3709
  const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
@@ -3797,6 +3887,13 @@ function intervalFromEnv() {
3797
3887
  const value = Number(raw);
3798
3888
  return Number.isFinite(value) && value > 0 ? value : undefined;
3799
3889
  }
3890
+ function concurrencyFromEnv() {
3891
+ const raw = process.env.LOOPS_DAEMON_CONCURRENCY;
3892
+ if (!raw)
3893
+ return;
3894
+ const value = Number(raw);
3895
+ return Number.isInteger(value) && value > 0 ? value : undefined;
3896
+ }
3800
3897
  async function runDaemon(opts = {}) {
3801
3898
  ensureDataDir();
3802
3899
  const pidPath = opts.pidPath ?? pidFilePath();
@@ -3811,6 +3908,7 @@ async function runDaemon(opts = {}) {
3811
3908
  const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
3812
3909
  const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
3813
3910
  const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
3911
+ const concurrency = Math.max(1, opts.concurrency ?? concurrencyFromEnv() ?? 4);
3814
3912
  const log = opts.log ?? ((message) => console.error(`[loops-daemon] ${message}`));
3815
3913
  const lease = store.acquireDaemonLease({
3816
3914
  id: leaseId,
@@ -3825,6 +3923,7 @@ async function runDaemon(opts = {}) {
3825
3923
  let stopFlag = false;
3826
3924
  let leaseLost = false;
3827
3925
  const runAbort = new AbortController;
3926
+ const activeRuns = new Map;
3828
3927
  const requestStop = (message) => {
3829
3928
  stopFlag = true;
3830
3929
  if (!runAbort.signal.aborted)
@@ -3848,6 +3947,48 @@ async function runDaemon(opts = {}) {
3848
3947
  opts.signal?.addEventListener("abort", onSignal, { once: true });
3849
3948
  process.on("SIGINT", onSignal);
3850
3949
  process.on("SIGTERM", onSignal);
3950
+ const executeDaemonRun = async (claim) => {
3951
+ const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
3952
+ const timer = setInterval(() => {
3953
+ try {
3954
+ ensureLease();
3955
+ } catch (err) {
3956
+ log(err instanceof Error ? err.message : String(err));
3957
+ }
3958
+ }, heartbeatMs);
3959
+ timer.unref();
3960
+ try {
3961
+ const finalRun = await executeClaimedRun({
3962
+ store,
3963
+ runnerId,
3964
+ loop: claim.loop,
3965
+ run: claim.run,
3966
+ daemonLeaseId: leaseId,
3967
+ beforeFinalize: () => ensureLease(),
3968
+ execute: opts.execute ?? ((loop, run) => executeLoopTarget(store, loop, run, {
3969
+ signal: runAbort.signal,
3970
+ beforePersist: () => ensureLease(),
3971
+ daemonLeaseId: leaseId,
3972
+ onSpawn: (pid) => {
3973
+ ensureLease();
3974
+ store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
3975
+ }
3976
+ })),
3977
+ onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
3978
+ });
3979
+ ensureLease();
3980
+ if (leaseLost)
3981
+ throw new Error("daemon lease lost during run");
3982
+ advanceLoop(store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded", { daemonLeaseId: leaseId });
3983
+ log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
3984
+ } finally {
3985
+ clearInterval(timer);
3986
+ }
3987
+ };
3988
+ const startClaim = (claim) => {
3989
+ const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => activeRuns.delete(claim.run.id));
3990
+ activeRuns.set(claim.run.id, task);
3991
+ };
3851
3992
  try {
3852
3993
  await runLoop({
3853
3994
  intervalMs,
@@ -3856,49 +3997,26 @@ async function runDaemon(opts = {}) {
3856
3997
  onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
3857
3998
  tickFn: async () => {
3858
3999
  ensureLease();
3859
- const result = await tick({
4000
+ const available = Math.max(0, concurrency - activeRuns.size);
4001
+ const result = claimDueRuns({
3860
4002
  store,
3861
4003
  runnerId,
3862
4004
  daemonLeaseId: leaseId,
3863
4005
  beforeRun: () => ensureLease(),
3864
- execute: async (loop, run) => {
3865
- const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
3866
- const timer = setInterval(() => {
3867
- try {
3868
- ensureLease();
3869
- } catch (err) {
3870
- log(err instanceof Error ? err.message : String(err));
3871
- }
3872
- }, heartbeatMs);
3873
- timer.unref();
3874
- try {
3875
- const result2 = await executeLoopTarget(store, loop, run, {
3876
- signal: runAbort.signal,
3877
- beforePersist: () => ensureLease(),
3878
- daemonLeaseId: leaseId,
3879
- onSpawn: (pid) => {
3880
- ensureLease();
3881
- store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
3882
- }
3883
- });
3884
- ensureLease();
3885
- if (leaseLost)
3886
- throw new Error("daemon lease lost during run");
3887
- return result2;
3888
- } finally {
3889
- clearInterval(timer);
3890
- }
3891
- },
3892
- beforeFinalize: () => ensureLease(),
3893
- onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
4006
+ maxClaims: available
3894
4007
  });
3895
- const changed = result.completed.length + result.skipped.length + result.recovered.length + result.expired.length;
4008
+ for (const claim of result.claims)
4009
+ startClaim(claim);
4010
+ const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
3896
4011
  if (changed > 0) {
3897
- log(`tick completed=${result.completed.length} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
4012
+ log(`tick claimed=${result.claims.length} active=${activeRuns.size} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
3898
4013
  }
3899
4014
  }
3900
4015
  });
3901
4016
  } finally {
4017
+ if (activeRuns.size > 0 && !runAbort.signal.aborted)
4018
+ runAbort.abort();
4019
+ await Promise.allSettled([...activeRuns.values()]);
3902
4020
  opts.signal?.removeEventListener("abort", onSignal);
3903
4021
  process.off("SIGINT", onSignal);
3904
4022
  process.off("SIGTERM", onSignal);
@@ -4020,7 +4138,7 @@ function enableStartup(result) {
4020
4138
  // package.json
4021
4139
  var package_default = {
4022
4140
  name: "@hasna/loops",
4023
- version: "0.3.9",
4141
+ version: "0.3.10",
4024
4142
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4025
4143
  type: "module",
4026
4144
  main: "dist/index.js",
@@ -4110,7 +4228,7 @@ function packageVersion() {
4110
4228
  // src/daemon/index.ts
4111
4229
  var program = new Command;
4112
4230
  program.name("loops-daemon").description("OpenLoops daemon helper").version(packageVersion());
4113
- program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs }));
4231
+ program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).option("--concurrency <n>", "maximum loop runs to execute concurrently", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs, concurrency: opts.concurrency }));
4114
4232
  program.command("start").action(async () => {
4115
4233
  const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
4116
4234
  console.log(JSON.stringify(result, null, 2));
package/dist/index.js CHANGED
@@ -1668,6 +1668,10 @@ class Store {
1668
1668
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
1669
1669
  return (row?.count ?? 0) > 0;
1670
1670
  }
1671
+ hasRunningRunForSlot(loopId, scheduledFor) {
1672
+ const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND scheduled_for = ? AND status = 'running'").get(loopId, scheduledFor);
1673
+ return (row?.count ?? 0) > 0;
1674
+ }
1671
1675
  markRunPid(id, pid, claimedBy, opts = {}) {
1672
1676
  const now = (opts.now ?? new Date).toISOString();
1673
1677
  const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
@@ -3604,6 +3608,92 @@ async function runSlot(deps, loop, scheduledFor) {
3604
3608
  deps.onRun?.(finalRun);
3605
3609
  return finalRun;
3606
3610
  }
3611
+ function claimSlot(deps, loop, scheduledFor) {
3612
+ const now = deps.now?.() ?? new Date;
3613
+ deps.beforeRun?.(loop, scheduledFor);
3614
+ if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
3615
+ if (deps.store.hasRunningRunForSlot(loop.id, scheduledFor))
3616
+ return;
3617
+ let skipped;
3618
+ try {
3619
+ skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
3620
+ daemonLeaseId: deps.daemonLeaseId
3621
+ });
3622
+ } catch (error) {
3623
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
3624
+ return;
3625
+ throw error;
3626
+ }
3627
+ advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
3628
+ deps.onRun?.(skipped);
3629
+ return skipped;
3630
+ }
3631
+ let claim;
3632
+ try {
3633
+ claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
3634
+ } catch (error) {
3635
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
3636
+ return;
3637
+ throw error;
3638
+ }
3639
+ if (!claim)
3640
+ return;
3641
+ deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
3642
+ deps.onRun?.(claim.run);
3643
+ return claim;
3644
+ }
3645
+ function claimDueRuns(deps) {
3646
+ const now = deps.now?.() ?? new Date;
3647
+ const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
3648
+ const recoveredByLoop = new Map;
3649
+ for (const run of recovered) {
3650
+ recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
3651
+ }
3652
+ for (const runs of recoveredByLoop.values()) {
3653
+ const loop = deps.store.getLoop(runs[0].loopId);
3654
+ if (!loop)
3655
+ continue;
3656
+ const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
3657
+ if (retryable) {
3658
+ advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
3659
+ daemonLeaseId: deps.daemonLeaseId
3660
+ });
3661
+ continue;
3662
+ }
3663
+ for (const run of runs) {
3664
+ const current = deps.store.getLoop(run.loopId);
3665
+ if (current) {
3666
+ advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
3667
+ daemonLeaseId: deps.daemonLeaseId
3668
+ });
3669
+ }
3670
+ }
3671
+ }
3672
+ const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
3673
+ const claims = [];
3674
+ const claimed = [];
3675
+ const skipped = [];
3676
+ const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
3677
+ for (const loop of deps.store.dueLoops(now)) {
3678
+ if (claims.length >= maxClaims)
3679
+ break;
3680
+ const plan = dueSlots(loop, now);
3681
+ for (const slot of plan.slots) {
3682
+ if (claims.length >= maxClaims)
3683
+ break;
3684
+ const run = claimSlot(deps, loop, slot);
3685
+ if (!run)
3686
+ continue;
3687
+ if ("loop" in run) {
3688
+ claims.push(run);
3689
+ claimed.push(run.run);
3690
+ } else if (run.status === "skipped") {
3691
+ skipped.push(run);
3692
+ }
3693
+ }
3694
+ }
3695
+ return { claims, claimed, completed: [], skipped, recovered, expired };
3696
+ }
3607
3697
  async function tick(deps) {
3608
3698
  const now = deps.now?.() ?? new Date;
3609
3699
  const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
@@ -18,6 +18,13 @@ export interface TickResult {
18
18
  recovered: LoopRun[];
19
19
  expired: Loop[];
20
20
  }
21
+ export interface ClaimedLoopRun {
22
+ loop: Loop;
23
+ run: LoopRun;
24
+ }
25
+ export interface ClaimDueRunsResult extends TickResult {
26
+ claims: ClaimedLoopRun[];
27
+ }
21
28
  export declare function manualRunScheduledFor(loop: Loop, now?: Date): string;
22
29
  export declare function shouldAdvanceManualRun(loop: Loop, scheduledFor: string, now?: Date): boolean;
23
30
  export type ManualRunSource = "ad_hoc" | "due_slot" | "retry_slot";
@@ -36,4 +43,7 @@ export declare function executeClaimedRun(deps: {
36
43
  execute?: (loop: Loop, run: LoopRun) => Promise<ExecutorResult>;
37
44
  onError?: (loop: Loop, error: unknown) => void;
38
45
  }): Promise<LoopRun>;
46
+ export declare function claimDueRuns(deps: SchedulerDeps & {
47
+ maxClaims?: number;
48
+ }): ClaimDueRunsResult;
39
49
  export declare function tick(deps: SchedulerDeps): Promise<TickResult>;
@@ -130,6 +130,7 @@ export declare class Store {
130
130
  appendWorkflowEvent(workflowRunId: string, eventType: string, stepId?: string, payload?: Record<string, unknown>): WorkflowEvent;
131
131
  listWorkflowEvents(workflowRunId: string, limit?: number): WorkflowEvent[];
132
132
  hasRunningRun(loopId: string): boolean;
133
+ hasRunningRunForSlot(loopId: string, scheduledFor: string): boolean;
133
134
  markRunPid(id: string, pid: number, claimedBy?: string, opts?: DaemonLeaseFence): LoopRun | undefined;
134
135
  private hasLiveWorkflowStepProcesses;
135
136
  createSkippedRun(loop: Loop, scheduledFor: string, reason: string, opts?: DaemonLeaseFence): LoopRun;
package/dist/lib/store.js CHANGED
@@ -1668,6 +1668,10 @@ class Store {
1668
1668
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
1669
1669
  return (row?.count ?? 0) > 0;
1670
1670
  }
1671
+ hasRunningRunForSlot(loopId, scheduledFor) {
1672
+ const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND scheduled_for = ? AND status = 'running'").get(loopId, scheduledFor);
1673
+ return (row?.count ?? 0) > 0;
1674
+ }
1671
1675
  markRunPid(id, pid, claimedBy, opts = {}) {
1672
1676
  const now = (opts.now ?? new Date).toISOString();
1673
1677
  const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
package/dist/sdk/index.js CHANGED
@@ -1668,6 +1668,10 @@ class Store {
1668
1668
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
1669
1669
  return (row?.count ?? 0) > 0;
1670
1670
  }
1671
+ hasRunningRunForSlot(loopId, scheduledFor) {
1672
+ const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND scheduled_for = ? AND status = 'running'").get(loopId, scheduledFor);
1673
+ return (row?.count ?? 0) > 0;
1674
+ }
1671
1675
  markRunPid(id, pid, claimedBy, opts = {}) {
1672
1676
  const now = (opts.now ?? new Date).toISOString();
1673
1677
  const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
@@ -3604,6 +3608,92 @@ async function runSlot(deps, loop, scheduledFor) {
3604
3608
  deps.onRun?.(finalRun);
3605
3609
  return finalRun;
3606
3610
  }
3611
+ function claimSlot(deps, loop, scheduledFor) {
3612
+ const now = deps.now?.() ?? new Date;
3613
+ deps.beforeRun?.(loop, scheduledFor);
3614
+ if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
3615
+ if (deps.store.hasRunningRunForSlot(loop.id, scheduledFor))
3616
+ return;
3617
+ let skipped;
3618
+ try {
3619
+ skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
3620
+ daemonLeaseId: deps.daemonLeaseId
3621
+ });
3622
+ } catch (error) {
3623
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
3624
+ return;
3625
+ throw error;
3626
+ }
3627
+ advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
3628
+ deps.onRun?.(skipped);
3629
+ return skipped;
3630
+ }
3631
+ let claim;
3632
+ try {
3633
+ claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
3634
+ } catch (error) {
3635
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
3636
+ return;
3637
+ throw error;
3638
+ }
3639
+ if (!claim)
3640
+ return;
3641
+ deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
3642
+ deps.onRun?.(claim.run);
3643
+ return claim;
3644
+ }
3645
+ function claimDueRuns(deps) {
3646
+ const now = deps.now?.() ?? new Date;
3647
+ const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
3648
+ const recoveredByLoop = new Map;
3649
+ for (const run of recovered) {
3650
+ recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
3651
+ }
3652
+ for (const runs of recoveredByLoop.values()) {
3653
+ const loop = deps.store.getLoop(runs[0].loopId);
3654
+ if (!loop)
3655
+ continue;
3656
+ const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
3657
+ if (retryable) {
3658
+ advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
3659
+ daemonLeaseId: deps.daemonLeaseId
3660
+ });
3661
+ continue;
3662
+ }
3663
+ for (const run of runs) {
3664
+ const current = deps.store.getLoop(run.loopId);
3665
+ if (current) {
3666
+ advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
3667
+ daemonLeaseId: deps.daemonLeaseId
3668
+ });
3669
+ }
3670
+ }
3671
+ }
3672
+ const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
3673
+ const claims = [];
3674
+ const claimed = [];
3675
+ const skipped = [];
3676
+ const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
3677
+ for (const loop of deps.store.dueLoops(now)) {
3678
+ if (claims.length >= maxClaims)
3679
+ break;
3680
+ const plan = dueSlots(loop, now);
3681
+ for (const slot of plan.slots) {
3682
+ if (claims.length >= maxClaims)
3683
+ break;
3684
+ const run = claimSlot(deps, loop, slot);
3685
+ if (!run)
3686
+ continue;
3687
+ if ("loop" in run) {
3688
+ claims.push(run);
3689
+ claimed.push(run.run);
3690
+ } else if (run.status === "skipped") {
3691
+ skipped.push(run);
3692
+ }
3693
+ }
3694
+ }
3695
+ return { claims, claimed, completed: [], skipped, recovered, expired };
3696
+ }
3607
3697
  async function tick(deps) {
3608
3698
  const now = deps.now?.() ?? new Date;
3609
3699
  const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",