@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 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, optional dependsOn keys, and optional priority.",
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)).optional().default([]),
2985
- priority: z.number().int().optional().default(0),
2986
- tokenBudget: z.number().int().positive().optional()
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"]).optional(),
2994
- evidence: z.array(z.string()).optional().default([]),
2995
- unmetRequirements: z.array(z.string()).optional().default([]),
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 result = await tick({
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
- execute: async (loop, run) => {
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 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;
4000
4119
  if (changed > 0) {
4001
- 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}`);
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.8",
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",
@@ -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
@@ -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, optional dependsOn keys, and optional priority.",
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)).optional().default([]),
2880
- priority: z.number().int().optional().default(0),
2881
- tokenBudget: z.number().int().positive().optional()
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"]).optional(),
2889
- evidence: z.array(z.string()).optional().default([]),
2890
- unmetRequirements: z.array(z.string()).optional().default([]),
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 result = await tick({
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
- execute: async (loop, run) => {
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 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;
3892
4011
  if (changed > 0) {
3893
- 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}`);
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.8",
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, optional dependsOn keys, and optional priority.",
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)).optional().default([]),
2870
- priority: z.number().int().optional().default(0),
2871
- tokenBudget: z.number().int().positive().optional()
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"]).optional(),
2879
- evidence: z.array(z.string()).optional().default([]),
2880
- unmetRequirements: z.array(z.string()).optional().default([]),
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 });
@@ -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
@@ -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, optional dependsOn keys, and optional priority.",
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)).optional().default([]),
2870
- priority: z.number().int().optional().default(0),
2871
- tokenBudget: z.number().int().positive().optional()
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"]).optional(),
2879
- evidence: z.array(z.string()).optional().default([]),
2880
- unmetRequirements: z.array(z.string()).optional().default([]),
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.3.8",
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",