@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 +152 -34
- package/dist/daemon/daemon.d.ts +3 -0
- package/dist/daemon/index.js +153 -35
- package/dist/index.js +90 -0
- package/dist/lib/scheduler.d.ts +10 -0
- package/dist/lib/store.d.ts +1 -0
- package/dist/lib/store.js +4 -0
- package/dist/sdk/index.js +90 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|
package/dist/daemon/daemon.d.ts
CHANGED
|
@@ -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;
|
package/dist/daemon/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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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 });
|
package/dist/lib/scheduler.d.ts
CHANGED
|
@@ -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>;
|
package/dist/lib/store.d.ts
CHANGED
|
@@ -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 });
|