@hasna/loops 0.4.10 → 0.4.11

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/CHANGELOG.md CHANGED
@@ -5,6 +5,51 @@ documented in this file. Version entries are generated from the
5
5
  conventional-commit git history; one commit maps to one released patch version
6
6
  unless noted.
7
7
 
8
+ ## 0.4.11 (2026-07-05)
9
+
10
+ Scheduler fairness and PR-route hygiene: fast command loops no longer starve
11
+ behind long agent workers, PR tasks dedupe by GitHub identity, and merged/closed
12
+ PR routes close themselves out of the queue.
13
+
14
+ ### Fixed
15
+
16
+ - **Daemon scheduler — separated concurrency lanes:** the daemon shared a single
17
+ concurrency pool, so long agent/workflow workers (minutes to over an hour) could
18
+ occupy every slot and starve fast command loops (monitors, digests, syncs) —
19
+ even the merge router starved. Command-target and agent/workflow-target loops
20
+ now draw from independent claim budgets. New env `LOOPS_DAEMON_COMMAND_CONCURRENCY`
21
+ (default 4) and `LOOPS_DAEMON_AGENT_CONCURRENCY` (default 8); the legacy
22
+ `LOOPS_DAEMON_CONCURRENCY` / `--concurrency` still work as the agent-lane knob for
23
+ back-compat, and new `--command-concurrency` / `--agent-concurrency` daemon flags
24
+ are added. A saturated lane no longer consumes the other lane's budget.
25
+ - **Todos-task routing — PR fingerprint dedupe:** the repos registry maps several
26
+ local checkouts to one GitHub repo, so a single PR was minted as 2-3 duplicate
27
+ todos tasks (distinct ids + checkout paths) that each dispatched a full worker.
28
+ PR-subject tasks now dedupe by a stable `owner/repo#number` fingerprint (from an
29
+ explicit PR fingerprint field or a canonical PR URL / `github-pr:` handle,
30
+ lowercased) instead of the `(source-path, task-id)` key, collapsing the
31
+ duplicates to one work item. Non-PR tasks keep the path/id key (no false dedupe).
32
+ - **Todos-task routing — freshness skip closes the task:** 0.4.10's freshness gate
33
+ stopped dispatching a worker for a merged/closed PR but left the task pending +
34
+ route-opted-in, so every drain tick re-skipped it forever. On a definitive
35
+ MERGED/CLOSED freshness skip the drain now marks the source task done and strips
36
+ its `auto:route`/`route:enabled` opt-in so it leaves the queue. Only fires when
37
+ gh/metadata definitively reports MERGED/CLOSED; never on dry-run.
38
+
39
+ ## 0.4.10 (2026-07-05)
40
+
41
+ Hotfix bundle for the PR-merge drain pipeline.
42
+
43
+ ### Fixed
44
+
45
+ - **Todos-task drain:** re-admit a terminal work item whose todos task is still
46
+ actionable (bounded by a redispatch cap + per-attempt backoff) instead of
47
+ deduping it away forever, so real task work keeps dispatching.
48
+ - **PR review gate:** freshness gate skips merge/review routes for already
49
+ merged/closed PRs; normalize `app/<slug>` and `<slug>[bot]` bot logins so
50
+ bot-authored PRs resolve an author instead of hard-failing the format check.
51
+ - **Runtime/store:** cap persisted run stdout/stderr; project path resolution fix.
52
+
8
53
  ## 0.4.9 (2026-07-04)
9
54
 
10
55
  Unblock the PR-merge pipeline: task-lifecycle/route workers now dispatch real
package/dist/api/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@hasna/loops",
6
- version: "0.4.10",
6
+ version: "0.4.11",
7
7
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
8
8
  type: "module",
9
9
  main: "dist/index.js",
package/dist/cli/index.js CHANGED
@@ -4237,7 +4237,7 @@ class Store {
4237
4237
  // package.json
4238
4238
  var package_default = {
4239
4239
  name: "@hasna/loops",
4240
- version: "0.4.10",
4240
+ version: "0.4.11",
4241
4241
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4242
4242
  type: "module",
4243
4243
  main: "dist/index.js",
@@ -6969,6 +6969,9 @@ function buildHealthReport(store, opts = {}) {
6969
6969
  }
6970
6970
 
6971
6971
  // src/lib/scheduler.ts
6972
+ function loopLane(loop) {
6973
+ return loop.target.type === "command" ? "command" : "agent";
6974
+ }
6972
6975
  function manualRunScheduledFor(loop, now = new Date) {
6973
6976
  if (loop.archivedAt)
6974
6977
  return now.toISOString();
@@ -7361,14 +7364,23 @@ function claimDueRuns(deps) {
7361
7364
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
7362
7365
  if (maxClaims === 0)
7363
7366
  return { claims, claimed, completed: [], skipped, recovered, expired };
7367
+ const laneLimits = deps.laneLimits;
7368
+ const laneClaims = { command: 0, agent: 0 };
7369
+ const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
7370
+ const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
7364
7371
  for (const loop of deps.store.dueLoops(now)) {
7365
7372
  if (claims.length >= maxClaims)
7366
7373
  break;
7374
+ const lane = loopLane(loop);
7375
+ if (laneFull(lane))
7376
+ continue;
7367
7377
  const plan = dueSlots(loop, now);
7368
7378
  let loopSkips = 0;
7369
7379
  for (const slot of plan.slots) {
7370
7380
  if (claims.length >= maxClaims)
7371
7381
  break;
7382
+ if (laneFull(lane))
7383
+ break;
7372
7384
  if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
7373
7385
  break;
7374
7386
  const run = claimSlot(deps, loop, slot);
@@ -7377,6 +7389,7 @@ function claimDueRuns(deps) {
7377
7389
  if ("loop" in run) {
7378
7390
  claims.push(run);
7379
7391
  claimed.push(run.run);
7392
+ laneClaims[lane] += 1;
7380
7393
  } else if (run.status === "skipped") {
7381
7394
  skipped.push(run);
7382
7395
  loopSkips += 1;
@@ -7648,13 +7661,23 @@ function intervalFromEnv() {
7648
7661
  const value = Number(raw);
7649
7662
  return Number.isFinite(value) && value > 0 ? value : undefined;
7650
7663
  }
7651
- function concurrencyFromEnv() {
7652
- const raw = process.env.LOOPS_DAEMON_CONCURRENCY;
7664
+ function positiveIntEnv(name) {
7665
+ const raw = process.env[name];
7653
7666
  if (!raw)
7654
7667
  return;
7655
7668
  const value = Number(raw);
7656
7669
  return Number.isInteger(value) && value > 0 ? value : undefined;
7657
7670
  }
7671
+ function concurrencyFromEnv() {
7672
+ return positiveIntEnv("LOOPS_DAEMON_CONCURRENCY");
7673
+ }
7674
+ var DEFAULT_COMMAND_CONCURRENCY = 4;
7675
+ var DEFAULT_AGENT_CONCURRENCY = 8;
7676
+ function resolveLaneConcurrency(opts = {}) {
7677
+ const command = Math.max(1, opts.commandConcurrency ?? positiveIntEnv("LOOPS_DAEMON_COMMAND_CONCURRENCY") ?? DEFAULT_COMMAND_CONCURRENCY);
7678
+ const agent = Math.max(1, opts.agentConcurrency ?? opts.concurrency ?? positiveIntEnv("LOOPS_DAEMON_AGENT_CONCURRENCY") ?? concurrencyFromEnv() ?? DEFAULT_AGENT_CONCURRENCY);
7679
+ return { command, agent };
7680
+ }
7658
7681
  var DAEMON_LOG_MAX_BYTES = 50 * 1024 * 1024;
7659
7682
  var DAEMON_LOG_KEEP = 2;
7660
7683
  function daemonLogLine(message) {
@@ -7700,7 +7723,7 @@ async function runDaemon(opts = {}) {
7700
7723
  const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
7701
7724
  const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
7702
7725
  const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
7703
- const concurrency = Math.max(1, opts.concurrency ?? concurrencyFromEnv() ?? 4);
7726
+ const laneConcurrency = resolveLaneConcurrency(opts);
7704
7727
  const log = opts.log ?? defaultDaemonLog;
7705
7728
  const sleep = opts.sleep ?? realSleep;
7706
7729
  const lease = store.acquireDaemonLease({
@@ -7712,12 +7735,13 @@ async function runDaemon(opts = {}) {
7712
7735
  if (!lease)
7713
7736
  throw new Error("another loops daemon holds the database lease");
7714
7737
  writePid(process.pid, pidPath);
7715
- log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
7738
+ log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId} ` + `command_concurrency=${laneConcurrency.command} agent_concurrency=${laneConcurrency.agent}`);
7716
7739
  let stopFlag = false;
7717
7740
  let leaseLost = false;
7718
7741
  let leaseEpoch = 0;
7719
7742
  let runAbort = new AbortController;
7720
7743
  const activeRuns = new Map;
7744
+ const activeByLane = { command: 0, agent: 0 };
7721
7745
  const requestStop = (message) => {
7722
7746
  stopFlag = true;
7723
7747
  if (!runAbort.signal.aborted)
@@ -7810,7 +7834,12 @@ async function runDaemon(opts = {}) {
7810
7834
  log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
7811
7835
  };
7812
7836
  const startClaim = (claim) => {
7813
- 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));
7837
+ const lane = loopLane(claim.loop);
7838
+ activeByLane[lane] += 1;
7839
+ const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
7840
+ activeRuns.delete(claim.run.id);
7841
+ activeByLane[lane] -= 1;
7842
+ });
7814
7843
  activeRuns.set(claim.run.id, task);
7815
7844
  };
7816
7845
  try {
@@ -7846,19 +7875,23 @@ async function runDaemon(opts = {}) {
7846
7875
  onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
7847
7876
  tickFn: async () => {
7848
7877
  ensureLease();
7849
- const available = Math.max(0, concurrency - activeRuns.size);
7878
+ const laneLimits = {
7879
+ command: Math.max(0, laneConcurrency.command - activeByLane.command),
7880
+ agent: Math.max(0, laneConcurrency.agent - activeByLane.agent)
7881
+ };
7850
7882
  const result = claimDueRuns({
7851
7883
  store,
7852
7884
  runnerId,
7853
7885
  daemonLeaseId: leaseId,
7854
7886
  beforeRun: () => ensureLease(),
7855
- maxClaims: available
7887
+ maxClaims: laneLimits.command + laneLimits.agent,
7888
+ laneLimits
7856
7889
  });
7857
7890
  for (const claim of result.claims)
7858
7891
  startClaim(claim);
7859
7892
  const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
7860
7893
  if (changed > 0) {
7861
- log(`tick claimed=${result.claims.length} active=${activeRuns.size} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
7894
+ log(`tick claimed=${result.claims.length} active=${activeRuns.size} (command=${activeByLane.command} agent=${activeByLane.agent}) skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
7862
7895
  }
7863
7896
  await reapAbandoned(result.recovered);
7864
7897
  }
@@ -11513,6 +11546,37 @@ function prReferenceFrom(text) {
11513
11546
  return { owner: short[1], repo: short[2], number: Number(short[3]) };
11514
11547
  return;
11515
11548
  }
11549
+ var PR_FINGERPRINT_FIELDS = [
11550
+ "pr_fingerprint",
11551
+ "prFingerprint",
11552
+ "github_pr",
11553
+ "githubPr",
11554
+ "github_pr_fingerprint",
11555
+ "githubPrFingerprint",
11556
+ "pull_request_fingerprint",
11557
+ "pullRequestFingerprint"
11558
+ ];
11559
+ function prFingerprint(ref) {
11560
+ return `${ref.owner.toLowerCase()}/${ref.repo.toLowerCase()}#${ref.number}`;
11561
+ }
11562
+ function prFingerprintFromField(value) {
11563
+ if (!value)
11564
+ return;
11565
+ const match = /([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)/.exec(value);
11566
+ return match ? prFingerprint({ owner: match[1], repo: match[2], number: Number(match[3]) }) : undefined;
11567
+ }
11568
+ function prFingerprintFromTask(data, metadata) {
11569
+ const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
11570
+ for (const field of PR_FINGERPRINT_FIELDS) {
11571
+ for (const value of routeFieldValues(records, field)) {
11572
+ const fingerprint = prFingerprintFromField(value);
11573
+ if (fingerprint)
11574
+ return fingerprint;
11575
+ }
11576
+ }
11577
+ const ref = prReferenceFrom(routeEvidenceText(records));
11578
+ return ref ? prFingerprint(ref) : undefined;
11579
+ }
11516
11580
  function ghAuthorResolver(ref) {
11517
11581
  const result = spawnSync7("gh", ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", "author", "-q", ".author.login"], { encoding: "utf8", timeout: 20000 });
11518
11582
  if (result.error || result.status !== 0)
@@ -11588,7 +11652,9 @@ function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorR
11588
11652
  allowed: false,
11589
11653
  reason: `PR is already ${prState.toLowerCase()}; skipping merge/review route (freshness gate)`,
11590
11654
  reviewers: [],
11591
- signals: [...signals, "pr-not-open"]
11655
+ signals: [...signals, "pr-not-open"],
11656
+ freshnessSkip: true,
11657
+ prState
11592
11658
  };
11593
11659
  }
11594
11660
  const reviewers = githubLogins([
@@ -12045,7 +12111,8 @@ function routeTodosTaskEvent(event, opts) {
12045
12111
  const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
12046
12112
  const throttleLimits = routeThrottleLimitsFromOpts(opts);
12047
12113
  const sourceProjectIdempotencyPrefix = sourceTodosProjectPath ? normalizeRoutePath(sourceTodosProjectPath) ?? resolve7(sourceTodosProjectPath) : undefined;
12048
- const idempotencyKey = sourceProjectIdempotencyPrefix ? `todos-task:${sourceProjectIdempotencyPrefix}:${taskId}` : `todos-task:${taskId}`;
12114
+ const prFingerprint2 = prFingerprintFromTask(data, metadata);
12115
+ const idempotencyKey = prFingerprint2 ? `todos-task:pr:${prFingerprint2}` : sourceProjectIdempotencyPrefix ? `todos-task:${sourceProjectIdempotencyPrefix}:${taskId}` : `todos-task:${taskId}`;
12049
12116
  const idempotencySuffix = stableSuffix(idempotencyKey);
12050
12117
  const namePrefix = opts.namePrefix ?? "event:todos-task";
12051
12118
  const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
@@ -12077,7 +12144,8 @@ function routeTodosTaskEvent(event, opts) {
12077
12144
  event,
12078
12145
  taskId,
12079
12146
  routeError: true,
12080
- prReviewRouting
12147
+ prReviewRouting,
12148
+ ...prReviewRouting.freshnessSkip ? { freshnessSkip: true, prState: prReviewRouting.prState } : {}
12081
12149
  },
12082
12150
  human: `skipped task ${taskId}: ${prReviewRouting.reason}`
12083
12151
  };
@@ -12638,6 +12706,31 @@ function markInvalidDrainTaskNonRouteable(sourceTodosProject, task, reason) {
12638
12706
  untagAutoRoute: todosMutationSummary(untagResult)
12639
12707
  };
12640
12708
  }
12709
+ function isFreshnessSkip(result) {
12710
+ return result.kind === "skipped" && result.value.freshnessSkip === true;
12711
+ }
12712
+ function closeFreshnessSkippedTask(sourceTodosProject, task, reason) {
12713
+ const taskId = taskField(task, ["id", "task_id", "taskId"]);
12714
+ if (!taskId)
12715
+ return { attempted: false, reason: "task id missing" };
12716
+ const comment = `OpenLoops freshness gate closed this task: ${reason}. The referenced PR is already merged/closed, so the ` + `merge/review route will not dispatch a worker. Marked done and removed auto:route/route:enabled so drains stop re-skipping it.`;
12717
+ const commentResult = runLocalCommand("todos", ["--project", sourceTodosProject, "comment", taskId, comment], { timeoutMs: 30000 });
12718
+ const doneResult = runLocalCommand("todos", ["--project", sourceTodosProject, "done", taskId, "--notes", "PR already merged/closed; closed by OpenLoops freshness gate"], { timeoutMs: 30000 });
12719
+ const untagAutoRoute = runLocalCommand("todos", ["--project", sourceTodosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
12720
+ const untagRouteEnabled = runLocalCommand("todos", ["--project", sourceTodosProject, "untag", taskId, "route:enabled"], { timeoutMs: 30000 });
12721
+ const leftQueue = doneResult.ok || untagAutoRoute.ok || untagRouteEnabled.ok;
12722
+ return {
12723
+ ok: leftQueue,
12724
+ attempted: true,
12725
+ taskId,
12726
+ action: "freshness-close",
12727
+ error: leftQueue ? undefined : "task could not be closed or untagged; inspect per-command results",
12728
+ comment: todosMutationSummary(commentResult),
12729
+ done: todosMutationSummary(doneResult),
12730
+ untagAutoRoute: todosMutationSummary(untagAutoRoute),
12731
+ untagRouteEnabled: todosMutationSummary(untagRouteEnabled)
12732
+ };
12733
+ }
12641
12734
  function drainTodosTaskRoutes(opts) {
12642
12735
  const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
12643
12736
  const todosProject = opts.todosProject ?? defaultLoopsProject();
@@ -12679,6 +12772,12 @@ function drainTodosTaskRoutes(opts) {
12679
12772
  result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { fatal: true });
12680
12773
  }
12681
12774
  }
12775
+ if (!opts.dryRun && isFreshnessSkip(result)) {
12776
+ const sourceTaskProject = opts.todosProjectsFromRegistry ? taskField(task, ["source_project_path", "sourceProjectPath"]) ?? todosProject : todosProject;
12777
+ const reason = stringField(result.value.reason) ?? "PR already merged/closed (freshness gate)";
12778
+ const sourceTaskUpdate = closeFreshnessSkippedTask(sourceTaskProject, task, reason);
12779
+ result = { ...result, value: { ...result.value, sourceTaskUpdate } };
12780
+ }
12682
12781
  results.push(result);
12683
12782
  if (result.kind === "created")
12684
12783
  created += 1;
@@ -12704,6 +12803,7 @@ function drainTodosTaskRoutes(opts) {
12704
12803
  deduped: results.filter((result) => result.kind === "deduped").length,
12705
12804
  throttled: results.filter((result) => result.kind === "throttled").length,
12706
12805
  skipped: results.filter((result) => result.kind === "skipped").length,
12806
+ freshnessClosed: results.filter((result) => isFreshnessSkip(result) && result.value.sourceTaskUpdate?.attempted === true).length,
12707
12807
  fatal: results.filter((result) => result.value.fatal === true).length,
12708
12808
  maxDispatch,
12709
12809
  source: "todos ready",
@@ -12732,6 +12832,7 @@ function drainTodosTaskRoutes(opts) {
12732
12832
  deduped: report.deduped,
12733
12833
  throttled: report.throttled,
12734
12834
  skipped: report.skipped,
12835
+ freshnessClosed: report.freshnessClosed,
12735
12836
  fatal: report.fatal,
12736
12837
  maxDispatch: report.maxDispatch,
12737
12838
  source: report.source,
@@ -12741,7 +12842,7 @@ function drainTodosTaskRoutes(opts) {
12741
12842
  } : { ...report, evidencePath };
12742
12843
  return {
12743
12844
  value,
12744
- human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped} fatal=${report.fatal}`
12845
+ human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped} freshnessClosed=${report.freshnessClosed} fatal=${report.fatal}`
12745
12846
  };
12746
12847
  }
12747
12848
  // src/lib/route/route-tasks.ts
@@ -1,9 +1,15 @@
1
+ import { type SchedulerLane } from "../lib/scheduler.js";
1
2
  import { Store } from "../lib/store.js";
2
3
  import type { ExecutorResult, Loop, LoopRun } from "../types.js";
3
4
  export interface RunDaemonOptions {
4
5
  intervalMs?: number;
5
6
  leaseTtlMs?: number;
7
+ /** Legacy single-pool knob; back-compat alias for the agent/workflow lane budget. */
6
8
  concurrency?: number;
9
+ /** Claim budget for command-target loops (fast loops). Default 4. */
10
+ commandConcurrency?: number;
11
+ /** Claim budget for agent/workflow-target loops (long workers). Default 8; `concurrency`/LOOPS_DAEMON_CONCURRENCY still set it. */
12
+ agentConcurrency?: number;
7
13
  store?: Store;
8
14
  pidPath?: string;
9
15
  execute?: (loop: Loop, run: LoopRun) => Promise<ExecutorResult>;
@@ -13,6 +19,18 @@ export interface RunDaemonOptions {
13
19
  signal?: AbortSignal;
14
20
  reapGraceMs?: number;
15
21
  }
22
+ export declare const DEFAULT_COMMAND_CONCURRENCY = 4;
23
+ export declare const DEFAULT_AGENT_CONCURRENCY = 8;
24
+ /**
25
+ * Resolve the two separated concurrency lanes. Command loops (fast) and
26
+ * agent/workflow loops (long workers) get independent budgets so a saturated
27
+ * agent lane cannot starve command loops. Precedence per lane is explicit opt >
28
+ * lane env > legacy knob > default. The legacy `concurrency` opt and
29
+ * `LOOPS_DAEMON_CONCURRENCY` env stay wired to the agent lane (the dominant
30
+ * consumer / historical "total" knob), so existing deployments keep their
31
+ * heavy-lane capacity and simply gain a reserved command lane on top.
32
+ */
33
+ export declare function resolveLaneConcurrency(opts?: Pick<RunDaemonOptions, "concurrency" | "commandConcurrency" | "agentConcurrency">): Record<SchedulerLane, number>;
16
34
  export declare const DAEMON_LOG_MAX_BYTES: number;
17
35
  export declare const DAEMON_LOG_KEEP = 2;
18
36
  export declare function daemonLogLine(message: string): string;
@@ -6705,6 +6705,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
6705
6705
  }
6706
6706
 
6707
6707
  // src/lib/scheduler.ts
6708
+ function loopLane(loop) {
6709
+ return loop.target.type === "command" ? "command" : "agent";
6710
+ }
6708
6711
  function manualRunScheduledFor(loop, now = new Date) {
6709
6712
  if (loop.archivedAt)
6710
6713
  return now.toISOString();
@@ -7097,14 +7100,23 @@ function claimDueRuns(deps) {
7097
7100
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
7098
7101
  if (maxClaims === 0)
7099
7102
  return { claims, claimed, completed: [], skipped, recovered, expired };
7103
+ const laneLimits = deps.laneLimits;
7104
+ const laneClaims = { command: 0, agent: 0 };
7105
+ const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
7106
+ const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
7100
7107
  for (const loop of deps.store.dueLoops(now)) {
7101
7108
  if (claims.length >= maxClaims)
7102
7109
  break;
7110
+ const lane = loopLane(loop);
7111
+ if (laneFull(lane))
7112
+ continue;
7103
7113
  const plan = dueSlots(loop, now);
7104
7114
  let loopSkips = 0;
7105
7115
  for (const slot of plan.slots) {
7106
7116
  if (claims.length >= maxClaims)
7107
7117
  break;
7118
+ if (laneFull(lane))
7119
+ break;
7108
7120
  if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
7109
7121
  break;
7110
7122
  const run = claimSlot(deps, loop, slot);
@@ -7113,6 +7125,7 @@ function claimDueRuns(deps) {
7113
7125
  if ("loop" in run) {
7114
7126
  claims.push(run);
7115
7127
  claimed.push(run.run);
7128
+ laneClaims[lane] += 1;
7116
7129
  } else if (run.status === "skipped") {
7117
7130
  skipped.push(run);
7118
7131
  loopSkips += 1;
@@ -7381,13 +7394,23 @@ function intervalFromEnv() {
7381
7394
  const value = Number(raw);
7382
7395
  return Number.isFinite(value) && value > 0 ? value : undefined;
7383
7396
  }
7384
- function concurrencyFromEnv() {
7385
- const raw = process.env.LOOPS_DAEMON_CONCURRENCY;
7397
+ function positiveIntEnv(name) {
7398
+ const raw = process.env[name];
7386
7399
  if (!raw)
7387
7400
  return;
7388
7401
  const value = Number(raw);
7389
7402
  return Number.isInteger(value) && value > 0 ? value : undefined;
7390
7403
  }
7404
+ function concurrencyFromEnv() {
7405
+ return positiveIntEnv("LOOPS_DAEMON_CONCURRENCY");
7406
+ }
7407
+ var DEFAULT_COMMAND_CONCURRENCY = 4;
7408
+ var DEFAULT_AGENT_CONCURRENCY = 8;
7409
+ function resolveLaneConcurrency(opts = {}) {
7410
+ const command = Math.max(1, opts.commandConcurrency ?? positiveIntEnv("LOOPS_DAEMON_COMMAND_CONCURRENCY") ?? DEFAULT_COMMAND_CONCURRENCY);
7411
+ const agent = Math.max(1, opts.agentConcurrency ?? opts.concurrency ?? positiveIntEnv("LOOPS_DAEMON_AGENT_CONCURRENCY") ?? concurrencyFromEnv() ?? DEFAULT_AGENT_CONCURRENCY);
7412
+ return { command, agent };
7413
+ }
7391
7414
  var DAEMON_LOG_MAX_BYTES = 50 * 1024 * 1024;
7392
7415
  var DAEMON_LOG_KEEP = 2;
7393
7416
  function daemonLogLine(message) {
@@ -7433,7 +7456,7 @@ async function runDaemon(opts = {}) {
7433
7456
  const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
7434
7457
  const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
7435
7458
  const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
7436
- const concurrency = Math.max(1, opts.concurrency ?? concurrencyFromEnv() ?? 4);
7459
+ const laneConcurrency = resolveLaneConcurrency(opts);
7437
7460
  const log = opts.log ?? defaultDaemonLog;
7438
7461
  const sleep = opts.sleep ?? realSleep;
7439
7462
  const lease = store.acquireDaemonLease({
@@ -7445,12 +7468,13 @@ async function runDaemon(opts = {}) {
7445
7468
  if (!lease)
7446
7469
  throw new Error("another loops daemon holds the database lease");
7447
7470
  writePid(process.pid, pidPath);
7448
- log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
7471
+ log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId} ` + `command_concurrency=${laneConcurrency.command} agent_concurrency=${laneConcurrency.agent}`);
7449
7472
  let stopFlag = false;
7450
7473
  let leaseLost = false;
7451
7474
  let leaseEpoch = 0;
7452
7475
  let runAbort = new AbortController;
7453
7476
  const activeRuns = new Map;
7477
+ const activeByLane = { command: 0, agent: 0 };
7454
7478
  const requestStop = (message) => {
7455
7479
  stopFlag = true;
7456
7480
  if (!runAbort.signal.aborted)
@@ -7543,7 +7567,12 @@ async function runDaemon(opts = {}) {
7543
7567
  log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
7544
7568
  };
7545
7569
  const startClaim = (claim) => {
7546
- 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));
7570
+ const lane = loopLane(claim.loop);
7571
+ activeByLane[lane] += 1;
7572
+ const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
7573
+ activeRuns.delete(claim.run.id);
7574
+ activeByLane[lane] -= 1;
7575
+ });
7547
7576
  activeRuns.set(claim.run.id, task);
7548
7577
  };
7549
7578
  try {
@@ -7579,19 +7608,23 @@ async function runDaemon(opts = {}) {
7579
7608
  onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
7580
7609
  tickFn: async () => {
7581
7610
  ensureLease();
7582
- const available = Math.max(0, concurrency - activeRuns.size);
7611
+ const laneLimits = {
7612
+ command: Math.max(0, laneConcurrency.command - activeByLane.command),
7613
+ agent: Math.max(0, laneConcurrency.agent - activeByLane.agent)
7614
+ };
7583
7615
  const result = claimDueRuns({
7584
7616
  store,
7585
7617
  runnerId,
7586
7618
  daemonLeaseId: leaseId,
7587
7619
  beforeRun: () => ensureLease(),
7588
- maxClaims: available
7620
+ maxClaims: laneLimits.command + laneLimits.agent,
7621
+ laneLimits
7589
7622
  });
7590
7623
  for (const claim of result.claims)
7591
7624
  startClaim(claim);
7592
7625
  const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
7593
7626
  if (changed > 0) {
7594
- log(`tick claimed=${result.claims.length} active=${activeRuns.size} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
7627
+ log(`tick claimed=${result.claims.length} active=${activeRuns.size} (command=${activeByLane.command} agent=${activeByLane.agent}) skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
7595
7628
  }
7596
7629
  await reapAbandoned(result.recovered);
7597
7630
  }
@@ -7752,7 +7785,7 @@ function enableStartup(result) {
7752
7785
  // package.json
7753
7786
  var package_default = {
7754
7787
  name: "@hasna/loops",
7755
- version: "0.4.10",
7788
+ version: "0.4.11",
7756
7789
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7757
7790
  type: "module",
7758
7791
  main: "dist/index.js",
@@ -7883,7 +7916,12 @@ function packageVersion() {
7883
7916
  // src/daemon/index.ts
7884
7917
  var program = new Command;
7885
7918
  program.name("loops-daemon").description("OpenLoops daemon helper").version(packageVersion());
7886
- 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 }));
7919
+ program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).option("--concurrency <n>", "legacy total knob; sets the agent/workflow lane budget", (value) => Number(value)).option("--command-concurrency <n>", "claim budget for command-target loops (default 4)", (value) => Number(value)).option("--agent-concurrency <n>", "claim budget for agent/workflow-target loops (default 8)", (value) => Number(value)).action(async (opts) => runDaemon({
7920
+ intervalMs: opts.intervalMs,
7921
+ concurrency: opts.concurrency,
7922
+ commandConcurrency: opts.commandConcurrency,
7923
+ agentConcurrency: opts.agentConcurrency
7924
+ }));
7887
7925
  program.command("start").action(async () => {
7888
7926
  const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
7889
7927
  console.log(JSON.stringify(result, null, 2));
package/dist/index.js CHANGED
@@ -4235,7 +4235,7 @@ class Store {
4235
4235
  // package.json
4236
4236
  var package_default = {
4237
4237
  name: "@hasna/loops",
4238
- version: "0.4.10",
4238
+ version: "0.4.11",
4239
4239
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4240
4240
  type: "module",
4241
4241
  main: "dist/index.js",
@@ -7286,6 +7286,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
7286
7286
  }
7287
7287
 
7288
7288
  // src/lib/scheduler.ts
7289
+ function loopLane(loop) {
7290
+ return loop.target.type === "command" ? "command" : "agent";
7291
+ }
7289
7292
  function manualRunScheduledFor(loop, now = new Date) {
7290
7293
  if (loop.archivedAt)
7291
7294
  return now.toISOString();
@@ -7678,14 +7681,23 @@ function claimDueRuns(deps) {
7678
7681
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
7679
7682
  if (maxClaims === 0)
7680
7683
  return { claims, claimed, completed: [], skipped, recovered, expired };
7684
+ const laneLimits = deps.laneLimits;
7685
+ const laneClaims = { command: 0, agent: 0 };
7686
+ const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
7687
+ const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
7681
7688
  for (const loop of deps.store.dueLoops(now)) {
7682
7689
  if (claims.length >= maxClaims)
7683
7690
  break;
7691
+ const lane = loopLane(loop);
7692
+ if (laneFull(lane))
7693
+ continue;
7684
7694
  const plan = dueSlots(loop, now);
7685
7695
  let loopSkips = 0;
7686
7696
  for (const slot of plan.slots) {
7687
7697
  if (claims.length >= maxClaims)
7688
7698
  break;
7699
+ if (laneFull(lane))
7700
+ break;
7689
7701
  if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
7690
7702
  break;
7691
7703
  const run = claimSlot(deps, loop, slot);
@@ -7694,6 +7706,7 @@ function claimDueRuns(deps) {
7694
7706
  if ("loop" in run) {
7695
7707
  claims.push(run);
7696
7708
  claimed.push(run.run);
7709
+ laneClaims[lane] += 1;
7697
7710
  } else if (run.status === "skipped") {
7698
7711
  skipped.push(run);
7699
7712
  loopSkips += 1;
package/dist/lib/mode.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // package.json
3
3
  var package_default = {
4
4
  name: "@hasna/loops",
5
- version: "0.4.10",
5
+ version: "0.4.11",
6
6
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7
7
  type: "module",
8
8
  main: "dist/index.js",
@@ -7,6 +7,10 @@ export interface PrReviewRoutingDecision {
7
7
  reviewers: string[];
8
8
  selectedReviewer?: string;
9
9
  signals: string[];
10
+ /** Set when the route was skipped because the PR is definitively MERGED/CLOSED. */
11
+ freshnessSkip?: boolean;
12
+ /** Resolved PR lifecycle state (e.g. MERGED/CLOSED) when the freshness gate fired. */
13
+ prState?: string;
10
14
  }
11
15
  /** A GitHub PR reference resolvable to `owner/repo#number`. */
12
16
  export interface PrReference {
@@ -25,4 +29,16 @@ export interface PrLiveState {
25
29
  export type PrStateResolver = (ref: PrReference) => PrLiveState | undefined;
26
30
  /** Extracts a concrete owner/repo/number PR reference from route evidence text. */
27
31
  export declare function prReferenceFrom(text: string): PrReference | undefined;
32
+ /** Canonical `owner/repo#number`, lowercased for case-stable dedupe. */
33
+ export declare function prFingerprint(ref: PrReference): string;
34
+ /**
35
+ * Stable GitHub `owner/repo#number` fingerprint for a PR-subject task. The repos
36
+ * registry maps several local checkouts to one GitHub repo, so a single PR is
37
+ * minted as 2-3 duplicate todos tasks (distinct ids + distinct checkout paths).
38
+ * Keying dedupe on this fingerprint collapses them to one work item instead of
39
+ * burning a full worker cycle per checkout. Prefers an explicit PR fingerprint
40
+ * field, then a canonical PR URL / `github-pr:` handle in route evidence;
41
+ * returns undefined for tasks with no concrete PR reference (keep path/id keying).
42
+ */
43
+ export declare function prFingerprintFromTask(data: Record<string, unknown>, metadata: Record<string, unknown>): string | undefined;
28
44
  export declare function prReviewRoutingDecision(data: Record<string, unknown>, metadata: Record<string, unknown>, opts: TodosTaskRouteOptions, resolveAuthor?: PrAuthorResolver, resolveState?: PrStateResolver): PrReviewRoutingDecision;
@@ -29,6 +29,18 @@ export interface ClaimedLoopRun {
29
29
  export interface ClaimDueRunsResult extends TickResult {
30
30
  claims: ClaimedLoopRun[];
31
31
  }
32
+ /**
33
+ * Scheduler concurrency lanes. Command-target loops are typically fast
34
+ * (monitors, digests, syncs); agent/workflow-target loops are long-running
35
+ * headless workers (minutes to over an hour). They draw from separate claim
36
+ * budgets so a saturated agent lane cannot starve fast command loops (and vice
37
+ * versa) — the single shared pool let long workers monopolize every slot.
38
+ */
39
+ export type SchedulerLane = "command" | "agent";
40
+ /** The concurrency lane a loop's target belongs to. */
41
+ export declare function loopLane(loop: Loop): SchedulerLane;
42
+ /** Remaining claim budget per lane for a single `claimDueRuns` pass. */
43
+ export type LaneLimits = Partial<Record<SchedulerLane, number>>;
32
44
  export declare function manualRunScheduledFor(loop: Loop, now?: Date): string;
33
45
  export declare function shouldAdvanceManualRun(loop: Loop, scheduledFor: string, now?: Date): boolean;
34
46
  export type ManualRunSource = "ad_hoc" | "due_slot" | "retry_slot";
@@ -121,5 +133,6 @@ export declare function executeClaimedRun(deps: {
121
133
  }): Promise<LoopRun>;
122
134
  export declare function claimDueRuns(deps: SchedulerDeps & {
123
135
  maxClaims?: number;
136
+ laneLimits?: LaneLimits;
124
137
  }): ClaimDueRunsResult;
125
138
  export declare function tick(deps: SchedulerDeps): Promise<TickResult>;
package/dist/mcp/index.js CHANGED
@@ -7021,6 +7021,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
7021
7021
  }
7022
7022
 
7023
7023
  // src/lib/scheduler.ts
7024
+ function loopLane(loop) {
7025
+ return loop.target.type === "command" ? "command" : "agent";
7026
+ }
7024
7027
  function manualRunScheduledFor(loop, now = new Date) {
7025
7028
  if (loop.archivedAt)
7026
7029
  return now.toISOString();
@@ -7413,14 +7416,23 @@ function claimDueRuns(deps) {
7413
7416
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
7414
7417
  if (maxClaims === 0)
7415
7418
  return { claims, claimed, completed: [], skipped, recovered, expired };
7419
+ const laneLimits = deps.laneLimits;
7420
+ const laneClaims = { command: 0, agent: 0 };
7421
+ const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
7422
+ const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
7416
7423
  for (const loop of deps.store.dueLoops(now)) {
7417
7424
  if (claims.length >= maxClaims)
7418
7425
  break;
7426
+ const lane = loopLane(loop);
7427
+ if (laneFull(lane))
7428
+ continue;
7419
7429
  const plan = dueSlots(loop, now);
7420
7430
  let loopSkips = 0;
7421
7431
  for (const slot of plan.slots) {
7422
7432
  if (claims.length >= maxClaims)
7423
7433
  break;
7434
+ if (laneFull(lane))
7435
+ break;
7424
7436
  if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
7425
7437
  break;
7426
7438
  const run = claimSlot(deps, loop, slot);
@@ -7429,6 +7441,7 @@ function claimDueRuns(deps) {
7429
7441
  if ("loop" in run) {
7430
7442
  claims.push(run);
7431
7443
  claimed.push(run.run);
7444
+ laneClaims[lane] += 1;
7432
7445
  } else if (run.status === "skipped") {
7433
7446
  skipped.push(run);
7434
7447
  loopSkips += 1;
@@ -7468,7 +7481,7 @@ async function tick(deps) {
7468
7481
  // package.json
7469
7482
  var package_default = {
7470
7483
  name: "@hasna/loops",
7471
- version: "0.4.10",
7484
+ version: "0.4.11",
7472
7485
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7473
7486
  type: "module",
7474
7487
  main: "dist/index.js",
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@hasna/loops",
6
- version: "0.4.10",
6
+ version: "0.4.11",
7
7
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
8
8
  type: "module",
9
9
  main: "dist/index.js",
package/dist/sdk/index.js CHANGED
@@ -4235,7 +4235,7 @@ class Store {
4235
4235
  // package.json
4236
4236
  var package_default = {
4237
4237
  name: "@hasna/loops",
4238
- version: "0.4.10",
4238
+ version: "0.4.11",
4239
4239
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4240
4240
  type: "module",
4241
4241
  main: "dist/index.js",
@@ -7792,6 +7792,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
7792
7792
  }
7793
7793
 
7794
7794
  // src/lib/scheduler.ts
7795
+ function loopLane(loop) {
7796
+ return loop.target.type === "command" ? "command" : "agent";
7797
+ }
7795
7798
  function manualRunScheduledFor(loop, now = new Date) {
7796
7799
  if (loop.archivedAt)
7797
7800
  return now.toISOString();
@@ -8184,14 +8187,23 @@ function claimDueRuns(deps) {
8184
8187
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
8185
8188
  if (maxClaims === 0)
8186
8189
  return { claims, claimed, completed: [], skipped, recovered, expired };
8190
+ const laneLimits = deps.laneLimits;
8191
+ const laneClaims = { command: 0, agent: 0 };
8192
+ const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
8193
+ const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
8187
8194
  for (const loop of deps.store.dueLoops(now)) {
8188
8195
  if (claims.length >= maxClaims)
8189
8196
  break;
8197
+ const lane = loopLane(loop);
8198
+ if (laneFull(lane))
8199
+ continue;
8190
8200
  const plan = dueSlots(loop, now);
8191
8201
  let loopSkips = 0;
8192
8202
  for (const slot of plan.slots) {
8193
8203
  if (claims.length >= maxClaims)
8194
8204
  break;
8205
+ if (laneFull(lane))
8206
+ break;
8195
8207
  if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
8196
8208
  break;
8197
8209
  const run = claimSlot(deps, loop, slot);
@@ -8200,6 +8212,7 @@ function claimDueRuns(deps) {
8200
8212
  if ("loop" in run) {
8201
8213
  claims.push(run);
8202
8214
  claimed.push(run.run);
8215
+ laneClaims[lane] += 1;
8203
8216
  } else if (run.status === "skipped") {
8204
8217
  skipped.push(run);
8205
8218
  loopSkips += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.4.10",
3
+ "version": "0.4.11",
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",