@hasna/loops 0.3.8 → 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 +164 -42
- package/dist/daemon/daemon.d.ts +3 -0
- package/dist/daemon/index.js +165 -43
- package/dist/index.js +102 -8
- 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 +102 -8
- 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
|
|
@@ -2953,7 +2957,8 @@ function planPrompt(spec) {
|
|
|
2953
2957
|
const budget = spec.tokenBudget ? `Token budget: ${spec.tokenBudget}.` : "No explicit token budget.";
|
|
2954
2958
|
return [
|
|
2955
2959
|
"Create a flat DAG goal plan for this objective.",
|
|
2956
|
-
"Each node must have a stable short key, a concrete objective,
|
|
2960
|
+
"Each node must have a stable short key, a concrete objective, dependsOn keys, priority, and tokenBudget.",
|
|
2961
|
+
"Use dependsOn: [] when there are no dependencies, priority: 0 when no priority is needed, and tokenBudget: null when no per-node budget is needed.",
|
|
2957
2962
|
"Prefer the smallest plan that can prove the explicit requirements.",
|
|
2958
2963
|
budget,
|
|
2959
2964
|
`Objective: ${spec.objective}`
|
|
@@ -2965,6 +2970,9 @@ function achievementPrompt(goal, nodes, evidence) {
|
|
|
2965
2970
|
"Run an adversarial achievement audit.",
|
|
2966
2971
|
"Completion is unproven until every explicit requirement is verified against evidence.",
|
|
2967
2972
|
"Return achieved=false if evidence is missing, ambiguous, or only asserts completion.",
|
|
2973
|
+
"Return achieved=true when the evidence proves every explicit requirement; do not return achieved=false with an empty unmetRequirements array.",
|
|
2974
|
+
"Return status as complete, active, blocked, budgetLimited, cancelled, or null when no status change is appropriate.",
|
|
2975
|
+
"Return evidence and unmetRequirements as arrays, using [] when empty.",
|
|
2968
2976
|
"adversarialReview must be non-empty and must describe the attempted falsification.",
|
|
2969
2977
|
`Goal: ${goal.objective}`,
|
|
2970
2978
|
`Nodes: ${nodes.map((node) => `${node.key}=${node.status}`).join(", ")}`,
|
|
@@ -2981,18 +2989,18 @@ var DEFAULT_MAX_TURNS = 10;
|
|
|
2981
2989
|
var PlanNodeSchema = z.object({
|
|
2982
2990
|
key: z.string().min(1).max(64).regex(/^[A-Za-z0-9_.-]+$/),
|
|
2983
2991
|
objective: z.string().min(1),
|
|
2984
|
-
dependsOn: z.array(z.string().min(1))
|
|
2985
|
-
priority: z.number().int()
|
|
2986
|
-
tokenBudget: z.number().int().positive().
|
|
2992
|
+
dependsOn: z.array(z.string().min(1)),
|
|
2993
|
+
priority: z.number().int(),
|
|
2994
|
+
tokenBudget: z.number().int().positive().nullable()
|
|
2987
2995
|
});
|
|
2988
2996
|
var PlanSchema = z.object({
|
|
2989
2997
|
nodes: z.array(PlanNodeSchema).min(1)
|
|
2990
2998
|
});
|
|
2991
2999
|
var AchievementSchema = z.object({
|
|
2992
3000
|
achieved: z.boolean(),
|
|
2993
|
-
status: z.enum(["active", "blocked", "budgetLimited", "complete", "cancelled"]).
|
|
2994
|
-
evidence: z.array(z.string())
|
|
2995
|
-
unmetRequirements: z.array(z.string())
|
|
3001
|
+
status: z.enum(["active", "blocked", "budgetLimited", "complete", "cancelled"]).nullable(),
|
|
3002
|
+
evidence: z.array(z.string()),
|
|
3003
|
+
unmetRequirements: z.array(z.string()),
|
|
2996
3004
|
adversarialReview: z.string().min(1)
|
|
2997
3005
|
});
|
|
2998
3006
|
function normalizeGoalSpec2(spec) {
|
|
@@ -3076,7 +3084,7 @@ async function planGoal(store, goal, spec, model, opts) {
|
|
|
3076
3084
|
objective: node.objective,
|
|
3077
3085
|
dependsOn: node.dependsOn ?? [],
|
|
3078
3086
|
priority: node.priority ?? 0,
|
|
3079
|
-
tokenBudget: node.tokenBudget,
|
|
3087
|
+
tokenBudget: node.tokenBudget ?? undefined,
|
|
3080
3088
|
sequence: index
|
|
3081
3089
|
}));
|
|
3082
3090
|
assertAcyclicNodes(rawNodes.map((node) => ({ key: node.key, dependsOn: node.dependsOn })));
|
|
@@ -3715,6 +3723,92 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
3715
3723
|
deps.onRun?.(finalRun);
|
|
3716
3724
|
return finalRun;
|
|
3717
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
|
+
}
|
|
3718
3812
|
async function tick(deps) {
|
|
3719
3813
|
const now = deps.now?.() ?? new Date;
|
|
3720
3814
|
const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
|
|
@@ -3901,6 +3995,13 @@ function intervalFromEnv() {
|
|
|
3901
3995
|
const value = Number(raw);
|
|
3902
3996
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
3903
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
|
+
}
|
|
3904
4005
|
async function runDaemon(opts = {}) {
|
|
3905
4006
|
ensureDataDir();
|
|
3906
4007
|
const pidPath = opts.pidPath ?? pidFilePath();
|
|
@@ -3915,6 +4016,7 @@ async function runDaemon(opts = {}) {
|
|
|
3915
4016
|
const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
|
|
3916
4017
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
3917
4018
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
4019
|
+
const concurrency = Math.max(1, opts.concurrency ?? concurrencyFromEnv() ?? 4);
|
|
3918
4020
|
const log = opts.log ?? ((message) => console.error(`[loops-daemon] ${message}`));
|
|
3919
4021
|
const lease = store.acquireDaemonLease({
|
|
3920
4022
|
id: leaseId,
|
|
@@ -3929,6 +4031,7 @@ async function runDaemon(opts = {}) {
|
|
|
3929
4031
|
let stopFlag = false;
|
|
3930
4032
|
let leaseLost = false;
|
|
3931
4033
|
const runAbort = new AbortController;
|
|
4034
|
+
const activeRuns = new Map;
|
|
3932
4035
|
const requestStop = (message) => {
|
|
3933
4036
|
stopFlag = true;
|
|
3934
4037
|
if (!runAbort.signal.aborted)
|
|
@@ -3952,6 +4055,48 @@ async function runDaemon(opts = {}) {
|
|
|
3952
4055
|
opts.signal?.addEventListener("abort", onSignal, { once: true });
|
|
3953
4056
|
process.on("SIGINT", onSignal);
|
|
3954
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
|
+
};
|
|
3955
4100
|
try {
|
|
3956
4101
|
await runLoop({
|
|
3957
4102
|
intervalMs,
|
|
@@ -3960,49 +4105,26 @@ async function runDaemon(opts = {}) {
|
|
|
3960
4105
|
onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
|
|
3961
4106
|
tickFn: async () => {
|
|
3962
4107
|
ensureLease();
|
|
3963
|
-
const
|
|
4108
|
+
const available = Math.max(0, concurrency - activeRuns.size);
|
|
4109
|
+
const result = claimDueRuns({
|
|
3964
4110
|
store,
|
|
3965
4111
|
runnerId,
|
|
3966
4112
|
daemonLeaseId: leaseId,
|
|
3967
4113
|
beforeRun: () => ensureLease(),
|
|
3968
|
-
|
|
3969
|
-
const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
|
|
3970
|
-
const timer = setInterval(() => {
|
|
3971
|
-
try {
|
|
3972
|
-
ensureLease();
|
|
3973
|
-
} catch (err) {
|
|
3974
|
-
log(err instanceof Error ? err.message : String(err));
|
|
3975
|
-
}
|
|
3976
|
-
}, heartbeatMs);
|
|
3977
|
-
timer.unref();
|
|
3978
|
-
try {
|
|
3979
|
-
const result2 = await executeLoopTarget(store, loop, run, {
|
|
3980
|
-
signal: runAbort.signal,
|
|
3981
|
-
beforePersist: () => ensureLease(),
|
|
3982
|
-
daemonLeaseId: leaseId,
|
|
3983
|
-
onSpawn: (pid) => {
|
|
3984
|
-
ensureLease();
|
|
3985
|
-
store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
|
|
3986
|
-
}
|
|
3987
|
-
});
|
|
3988
|
-
ensureLease();
|
|
3989
|
-
if (leaseLost)
|
|
3990
|
-
throw new Error("daemon lease lost during run");
|
|
3991
|
-
return result2;
|
|
3992
|
-
} finally {
|
|
3993
|
-
clearInterval(timer);
|
|
3994
|
-
}
|
|
3995
|
-
},
|
|
3996
|
-
beforeFinalize: () => ensureLease(),
|
|
3997
|
-
onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
4114
|
+
maxClaims: available
|
|
3998
4115
|
});
|
|
3999
|
-
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;
|
|
4000
4119
|
if (changed > 0) {
|
|
4001
|
-
log(`tick
|
|
4120
|
+
log(`tick claimed=${result.claims.length} active=${activeRuns.size} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
|
|
4002
4121
|
}
|
|
4003
4122
|
}
|
|
4004
4123
|
});
|
|
4005
4124
|
} finally {
|
|
4125
|
+
if (activeRuns.size > 0 && !runAbort.signal.aborted)
|
|
4126
|
+
runAbort.abort();
|
|
4127
|
+
await Promise.allSettled([...activeRuns.values()]);
|
|
4006
4128
|
opts.signal?.removeEventListener("abort", onSignal);
|
|
4007
4129
|
process.off("SIGINT", onSignal);
|
|
4008
4130
|
process.off("SIGTERM", onSignal);
|
|
@@ -4216,7 +4338,7 @@ function runDoctor(store) {
|
|
|
4216
4338
|
// package.json
|
|
4217
4339
|
var package_default = {
|
|
4218
4340
|
name: "@hasna/loops",
|
|
4219
|
-
version: "0.3.
|
|
4341
|
+
version: "0.3.10",
|
|
4220
4342
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4221
4343
|
type: "module",
|
|
4222
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
|
|
@@ -2848,7 +2852,8 @@ function planPrompt(spec) {
|
|
|
2848
2852
|
const budget = spec.tokenBudget ? `Token budget: ${spec.tokenBudget}.` : "No explicit token budget.";
|
|
2849
2853
|
return [
|
|
2850
2854
|
"Create a flat DAG goal plan for this objective.",
|
|
2851
|
-
"Each node must have a stable short key, a concrete objective,
|
|
2855
|
+
"Each node must have a stable short key, a concrete objective, dependsOn keys, priority, and tokenBudget.",
|
|
2856
|
+
"Use dependsOn: [] when there are no dependencies, priority: 0 when no priority is needed, and tokenBudget: null when no per-node budget is needed.",
|
|
2852
2857
|
"Prefer the smallest plan that can prove the explicit requirements.",
|
|
2853
2858
|
budget,
|
|
2854
2859
|
`Objective: ${spec.objective}`
|
|
@@ -2860,6 +2865,9 @@ function achievementPrompt(goal, nodes, evidence) {
|
|
|
2860
2865
|
"Run an adversarial achievement audit.",
|
|
2861
2866
|
"Completion is unproven until every explicit requirement is verified against evidence.",
|
|
2862
2867
|
"Return achieved=false if evidence is missing, ambiguous, or only asserts completion.",
|
|
2868
|
+
"Return achieved=true when the evidence proves every explicit requirement; do not return achieved=false with an empty unmetRequirements array.",
|
|
2869
|
+
"Return status as complete, active, blocked, budgetLimited, cancelled, or null when no status change is appropriate.",
|
|
2870
|
+
"Return evidence and unmetRequirements as arrays, using [] when empty.",
|
|
2863
2871
|
"adversarialReview must be non-empty and must describe the attempted falsification.",
|
|
2864
2872
|
`Goal: ${goal.objective}`,
|
|
2865
2873
|
`Nodes: ${nodes.map((node) => `${node.key}=${node.status}`).join(", ")}`,
|
|
@@ -2876,18 +2884,18 @@ var DEFAULT_MAX_TURNS = 10;
|
|
|
2876
2884
|
var PlanNodeSchema = z.object({
|
|
2877
2885
|
key: z.string().min(1).max(64).regex(/^[A-Za-z0-9_.-]+$/),
|
|
2878
2886
|
objective: z.string().min(1),
|
|
2879
|
-
dependsOn: z.array(z.string().min(1))
|
|
2880
|
-
priority: z.number().int()
|
|
2881
|
-
tokenBudget: z.number().int().positive().
|
|
2887
|
+
dependsOn: z.array(z.string().min(1)),
|
|
2888
|
+
priority: z.number().int(),
|
|
2889
|
+
tokenBudget: z.number().int().positive().nullable()
|
|
2882
2890
|
});
|
|
2883
2891
|
var PlanSchema = z.object({
|
|
2884
2892
|
nodes: z.array(PlanNodeSchema).min(1)
|
|
2885
2893
|
});
|
|
2886
2894
|
var AchievementSchema = z.object({
|
|
2887
2895
|
achieved: z.boolean(),
|
|
2888
|
-
status: z.enum(["active", "blocked", "budgetLimited", "complete", "cancelled"]).
|
|
2889
|
-
evidence: z.array(z.string())
|
|
2890
|
-
unmetRequirements: z.array(z.string())
|
|
2896
|
+
status: z.enum(["active", "blocked", "budgetLimited", "complete", "cancelled"]).nullable(),
|
|
2897
|
+
evidence: z.array(z.string()),
|
|
2898
|
+
unmetRequirements: z.array(z.string()),
|
|
2891
2899
|
adversarialReview: z.string().min(1)
|
|
2892
2900
|
});
|
|
2893
2901
|
function normalizeGoalSpec2(spec) {
|
|
@@ -2971,7 +2979,7 @@ async function planGoal(store, goal, spec, model, opts) {
|
|
|
2971
2979
|
objective: node.objective,
|
|
2972
2980
|
dependsOn: node.dependsOn ?? [],
|
|
2973
2981
|
priority: node.priority ?? 0,
|
|
2974
|
-
tokenBudget: node.tokenBudget,
|
|
2982
|
+
tokenBudget: node.tokenBudget ?? undefined,
|
|
2975
2983
|
sequence: index
|
|
2976
2984
|
}));
|
|
2977
2985
|
assertAcyclicNodes(rawNodes.map((node) => ({ key: node.key, dependsOn: node.dependsOn })));
|
|
@@ -3610,6 +3618,92 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
3610
3618
|
deps.onRun?.(finalRun);
|
|
3611
3619
|
return finalRun;
|
|
3612
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
|
+
}
|
|
3613
3707
|
async function tick(deps) {
|
|
3614
3708
|
const now = deps.now?.() ?? new Date;
|
|
3615
3709
|
const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
|
|
@@ -3793,6 +3887,13 @@ function intervalFromEnv() {
|
|
|
3793
3887
|
const value = Number(raw);
|
|
3794
3888
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
3795
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
|
+
}
|
|
3796
3897
|
async function runDaemon(opts = {}) {
|
|
3797
3898
|
ensureDataDir();
|
|
3798
3899
|
const pidPath = opts.pidPath ?? pidFilePath();
|
|
@@ -3807,6 +3908,7 @@ async function runDaemon(opts = {}) {
|
|
|
3807
3908
|
const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
|
|
3808
3909
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
3809
3910
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
3911
|
+
const concurrency = Math.max(1, opts.concurrency ?? concurrencyFromEnv() ?? 4);
|
|
3810
3912
|
const log = opts.log ?? ((message) => console.error(`[loops-daemon] ${message}`));
|
|
3811
3913
|
const lease = store.acquireDaemonLease({
|
|
3812
3914
|
id: leaseId,
|
|
@@ -3821,6 +3923,7 @@ async function runDaemon(opts = {}) {
|
|
|
3821
3923
|
let stopFlag = false;
|
|
3822
3924
|
let leaseLost = false;
|
|
3823
3925
|
const runAbort = new AbortController;
|
|
3926
|
+
const activeRuns = new Map;
|
|
3824
3927
|
const requestStop = (message) => {
|
|
3825
3928
|
stopFlag = true;
|
|
3826
3929
|
if (!runAbort.signal.aborted)
|
|
@@ -3844,6 +3947,48 @@ async function runDaemon(opts = {}) {
|
|
|
3844
3947
|
opts.signal?.addEventListener("abort", onSignal, { once: true });
|
|
3845
3948
|
process.on("SIGINT", onSignal);
|
|
3846
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
|
+
};
|
|
3847
3992
|
try {
|
|
3848
3993
|
await runLoop({
|
|
3849
3994
|
intervalMs,
|
|
@@ -3852,49 +3997,26 @@ async function runDaemon(opts = {}) {
|
|
|
3852
3997
|
onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
|
|
3853
3998
|
tickFn: async () => {
|
|
3854
3999
|
ensureLease();
|
|
3855
|
-
const
|
|
4000
|
+
const available = Math.max(0, concurrency - activeRuns.size);
|
|
4001
|
+
const result = claimDueRuns({
|
|
3856
4002
|
store,
|
|
3857
4003
|
runnerId,
|
|
3858
4004
|
daemonLeaseId: leaseId,
|
|
3859
4005
|
beforeRun: () => ensureLease(),
|
|
3860
|
-
|
|
3861
|
-
const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
|
|
3862
|
-
const timer = setInterval(() => {
|
|
3863
|
-
try {
|
|
3864
|
-
ensureLease();
|
|
3865
|
-
} catch (err) {
|
|
3866
|
-
log(err instanceof Error ? err.message : String(err));
|
|
3867
|
-
}
|
|
3868
|
-
}, heartbeatMs);
|
|
3869
|
-
timer.unref();
|
|
3870
|
-
try {
|
|
3871
|
-
const result2 = await executeLoopTarget(store, loop, run, {
|
|
3872
|
-
signal: runAbort.signal,
|
|
3873
|
-
beforePersist: () => ensureLease(),
|
|
3874
|
-
daemonLeaseId: leaseId,
|
|
3875
|
-
onSpawn: (pid) => {
|
|
3876
|
-
ensureLease();
|
|
3877
|
-
store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
|
|
3878
|
-
}
|
|
3879
|
-
});
|
|
3880
|
-
ensureLease();
|
|
3881
|
-
if (leaseLost)
|
|
3882
|
-
throw new Error("daemon lease lost during run");
|
|
3883
|
-
return result2;
|
|
3884
|
-
} finally {
|
|
3885
|
-
clearInterval(timer);
|
|
3886
|
-
}
|
|
3887
|
-
},
|
|
3888
|
-
beforeFinalize: () => ensureLease(),
|
|
3889
|
-
onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
4006
|
+
maxClaims: available
|
|
3890
4007
|
});
|
|
3891
|
-
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;
|
|
3892
4011
|
if (changed > 0) {
|
|
3893
|
-
log(`tick
|
|
4012
|
+
log(`tick claimed=${result.claims.length} active=${activeRuns.size} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
|
|
3894
4013
|
}
|
|
3895
4014
|
}
|
|
3896
4015
|
});
|
|
3897
4016
|
} finally {
|
|
4017
|
+
if (activeRuns.size > 0 && !runAbort.signal.aborted)
|
|
4018
|
+
runAbort.abort();
|
|
4019
|
+
await Promise.allSettled([...activeRuns.values()]);
|
|
3898
4020
|
opts.signal?.removeEventListener("abort", onSignal);
|
|
3899
4021
|
process.off("SIGINT", onSignal);
|
|
3900
4022
|
process.off("SIGTERM", onSignal);
|
|
@@ -4016,7 +4138,7 @@ function enableStartup(result) {
|
|
|
4016
4138
|
// package.json
|
|
4017
4139
|
var package_default = {
|
|
4018
4140
|
name: "@hasna/loops",
|
|
4019
|
-
version: "0.3.
|
|
4141
|
+
version: "0.3.10",
|
|
4020
4142
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4021
4143
|
type: "module",
|
|
4022
4144
|
main: "dist/index.js",
|
|
@@ -4106,7 +4228,7 @@ function packageVersion() {
|
|
|
4106
4228
|
// src/daemon/index.ts
|
|
4107
4229
|
var program = new Command;
|
|
4108
4230
|
program.name("loops-daemon").description("OpenLoops daemon helper").version(packageVersion());
|
|
4109
|
-
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 }));
|
|
4110
4232
|
program.command("start").action(async () => {
|
|
4111
4233
|
const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
|
|
4112
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
|
|
@@ -2838,7 +2842,8 @@ function planPrompt(spec) {
|
|
|
2838
2842
|
const budget = spec.tokenBudget ? `Token budget: ${spec.tokenBudget}.` : "No explicit token budget.";
|
|
2839
2843
|
return [
|
|
2840
2844
|
"Create a flat DAG goal plan for this objective.",
|
|
2841
|
-
"Each node must have a stable short key, a concrete objective,
|
|
2845
|
+
"Each node must have a stable short key, a concrete objective, dependsOn keys, priority, and tokenBudget.",
|
|
2846
|
+
"Use dependsOn: [] when there are no dependencies, priority: 0 when no priority is needed, and tokenBudget: null when no per-node budget is needed.",
|
|
2842
2847
|
"Prefer the smallest plan that can prove the explicit requirements.",
|
|
2843
2848
|
budget,
|
|
2844
2849
|
`Objective: ${spec.objective}`
|
|
@@ -2850,6 +2855,9 @@ function achievementPrompt(goal, nodes, evidence) {
|
|
|
2850
2855
|
"Run an adversarial achievement audit.",
|
|
2851
2856
|
"Completion is unproven until every explicit requirement is verified against evidence.",
|
|
2852
2857
|
"Return achieved=false if evidence is missing, ambiguous, or only asserts completion.",
|
|
2858
|
+
"Return achieved=true when the evidence proves every explicit requirement; do not return achieved=false with an empty unmetRequirements array.",
|
|
2859
|
+
"Return status as complete, active, blocked, budgetLimited, cancelled, or null when no status change is appropriate.",
|
|
2860
|
+
"Return evidence and unmetRequirements as arrays, using [] when empty.",
|
|
2853
2861
|
"adversarialReview must be non-empty and must describe the attempted falsification.",
|
|
2854
2862
|
`Goal: ${goal.objective}`,
|
|
2855
2863
|
`Nodes: ${nodes.map((node) => `${node.key}=${node.status}`).join(", ")}`,
|
|
@@ -2866,18 +2874,18 @@ var DEFAULT_MAX_TURNS = 10;
|
|
|
2866
2874
|
var PlanNodeSchema = z.object({
|
|
2867
2875
|
key: z.string().min(1).max(64).regex(/^[A-Za-z0-9_.-]+$/),
|
|
2868
2876
|
objective: z.string().min(1),
|
|
2869
|
-
dependsOn: z.array(z.string().min(1))
|
|
2870
|
-
priority: z.number().int()
|
|
2871
|
-
tokenBudget: z.number().int().positive().
|
|
2877
|
+
dependsOn: z.array(z.string().min(1)),
|
|
2878
|
+
priority: z.number().int(),
|
|
2879
|
+
tokenBudget: z.number().int().positive().nullable()
|
|
2872
2880
|
});
|
|
2873
2881
|
var PlanSchema = z.object({
|
|
2874
2882
|
nodes: z.array(PlanNodeSchema).min(1)
|
|
2875
2883
|
});
|
|
2876
2884
|
var AchievementSchema = z.object({
|
|
2877
2885
|
achieved: z.boolean(),
|
|
2878
|
-
status: z.enum(["active", "blocked", "budgetLimited", "complete", "cancelled"]).
|
|
2879
|
-
evidence: z.array(z.string())
|
|
2880
|
-
unmetRequirements: z.array(z.string())
|
|
2886
|
+
status: z.enum(["active", "blocked", "budgetLimited", "complete", "cancelled"]).nullable(),
|
|
2887
|
+
evidence: z.array(z.string()),
|
|
2888
|
+
unmetRequirements: z.array(z.string()),
|
|
2881
2889
|
adversarialReview: z.string().min(1)
|
|
2882
2890
|
});
|
|
2883
2891
|
function normalizeGoalSpec2(spec) {
|
|
@@ -2961,7 +2969,7 @@ async function planGoal(store, goal, spec, model, opts) {
|
|
|
2961
2969
|
objective: node.objective,
|
|
2962
2970
|
dependsOn: node.dependsOn ?? [],
|
|
2963
2971
|
priority: node.priority ?? 0,
|
|
2964
|
-
tokenBudget: node.tokenBudget,
|
|
2972
|
+
tokenBudget: node.tokenBudget ?? undefined,
|
|
2965
2973
|
sequence: index
|
|
2966
2974
|
}));
|
|
2967
2975
|
assertAcyclicNodes(rawNodes.map((node) => ({ key: node.key, dependsOn: node.dependsOn })));
|
|
@@ -3600,6 +3608,92 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
3600
3608
|
deps.onRun?.(finalRun);
|
|
3601
3609
|
return finalRun;
|
|
3602
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
|
+
}
|
|
3603
3697
|
async function tick(deps) {
|
|
3604
3698
|
const now = deps.now?.() ?? new Date;
|
|
3605
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
|
|
@@ -2838,7 +2842,8 @@ function planPrompt(spec) {
|
|
|
2838
2842
|
const budget = spec.tokenBudget ? `Token budget: ${spec.tokenBudget}.` : "No explicit token budget.";
|
|
2839
2843
|
return [
|
|
2840
2844
|
"Create a flat DAG goal plan for this objective.",
|
|
2841
|
-
"Each node must have a stable short key, a concrete objective,
|
|
2845
|
+
"Each node must have a stable short key, a concrete objective, dependsOn keys, priority, and tokenBudget.",
|
|
2846
|
+
"Use dependsOn: [] when there are no dependencies, priority: 0 when no priority is needed, and tokenBudget: null when no per-node budget is needed.",
|
|
2842
2847
|
"Prefer the smallest plan that can prove the explicit requirements.",
|
|
2843
2848
|
budget,
|
|
2844
2849
|
`Objective: ${spec.objective}`
|
|
@@ -2850,6 +2855,9 @@ function achievementPrompt(goal, nodes, evidence) {
|
|
|
2850
2855
|
"Run an adversarial achievement audit.",
|
|
2851
2856
|
"Completion is unproven until every explicit requirement is verified against evidence.",
|
|
2852
2857
|
"Return achieved=false if evidence is missing, ambiguous, or only asserts completion.",
|
|
2858
|
+
"Return achieved=true when the evidence proves every explicit requirement; do not return achieved=false with an empty unmetRequirements array.",
|
|
2859
|
+
"Return status as complete, active, blocked, budgetLimited, cancelled, or null when no status change is appropriate.",
|
|
2860
|
+
"Return evidence and unmetRequirements as arrays, using [] when empty.",
|
|
2853
2861
|
"adversarialReview must be non-empty and must describe the attempted falsification.",
|
|
2854
2862
|
`Goal: ${goal.objective}`,
|
|
2855
2863
|
`Nodes: ${nodes.map((node) => `${node.key}=${node.status}`).join(", ")}`,
|
|
@@ -2866,18 +2874,18 @@ var DEFAULT_MAX_TURNS = 10;
|
|
|
2866
2874
|
var PlanNodeSchema = z.object({
|
|
2867
2875
|
key: z.string().min(1).max(64).regex(/^[A-Za-z0-9_.-]+$/),
|
|
2868
2876
|
objective: z.string().min(1),
|
|
2869
|
-
dependsOn: z.array(z.string().min(1))
|
|
2870
|
-
priority: z.number().int()
|
|
2871
|
-
tokenBudget: z.number().int().positive().
|
|
2877
|
+
dependsOn: z.array(z.string().min(1)),
|
|
2878
|
+
priority: z.number().int(),
|
|
2879
|
+
tokenBudget: z.number().int().positive().nullable()
|
|
2872
2880
|
});
|
|
2873
2881
|
var PlanSchema = z.object({
|
|
2874
2882
|
nodes: z.array(PlanNodeSchema).min(1)
|
|
2875
2883
|
});
|
|
2876
2884
|
var AchievementSchema = z.object({
|
|
2877
2885
|
achieved: z.boolean(),
|
|
2878
|
-
status: z.enum(["active", "blocked", "budgetLimited", "complete", "cancelled"]).
|
|
2879
|
-
evidence: z.array(z.string())
|
|
2880
|
-
unmetRequirements: z.array(z.string())
|
|
2886
|
+
status: z.enum(["active", "blocked", "budgetLimited", "complete", "cancelled"]).nullable(),
|
|
2887
|
+
evidence: z.array(z.string()),
|
|
2888
|
+
unmetRequirements: z.array(z.string()),
|
|
2881
2889
|
adversarialReview: z.string().min(1)
|
|
2882
2890
|
});
|
|
2883
2891
|
function normalizeGoalSpec2(spec) {
|
|
@@ -2961,7 +2969,7 @@ async function planGoal(store, goal, spec, model, opts) {
|
|
|
2961
2969
|
objective: node.objective,
|
|
2962
2970
|
dependsOn: node.dependsOn ?? [],
|
|
2963
2971
|
priority: node.priority ?? 0,
|
|
2964
|
-
tokenBudget: node.tokenBudget,
|
|
2972
|
+
tokenBudget: node.tokenBudget ?? undefined,
|
|
2965
2973
|
sequence: index
|
|
2966
2974
|
}));
|
|
2967
2975
|
assertAcyclicNodes(rawNodes.map((node) => ({ key: node.key, dependsOn: node.dependsOn })));
|
|
@@ -3600,6 +3608,92 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
3600
3608
|
deps.onRun?.(finalRun);
|
|
3601
3609
|
return finalRun;
|
|
3602
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
|
+
}
|
|
3603
3697
|
async function tick(deps) {
|
|
3604
3698
|
const now = deps.now?.() ?? new Date;
|
|
3605
3699
|
const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
|