@hasna/loops 0.4.10 → 0.4.12

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.
@@ -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;
@@ -1323,6 +1323,7 @@ function rowToWorkflowWorkItem(row) {
1323
1323
  subjectRef: row.subject_ref,
1324
1324
  projectKey: row.project_key ?? undefined,
1325
1325
  projectGroup: row.project_group ?? undefined,
1326
+ routeScope: row.route_scope ?? undefined,
1326
1327
  priority: row.priority,
1327
1328
  status: row.status,
1328
1329
  attempts: row.attempts,
@@ -1603,6 +1604,13 @@ class Store {
1603
1604
  this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
1604
1605
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
1605
1606
  }
1607
+ },
1608
+ {
1609
+ id: "0008_work_item_route_scope",
1610
+ apply: () => {
1611
+ this.addColumnIfMissing("workflow_work_items", "route_scope", "TEXT");
1612
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status)");
1613
+ }
1606
1614
  }
1607
1615
  ];
1608
1616
  }
@@ -1750,6 +1758,7 @@ class Store {
1750
1758
  subject_ref TEXT NOT NULL,
1751
1759
  project_key TEXT,
1752
1760
  project_group TEXT,
1761
+ route_scope TEXT,
1753
1762
  priority INTEGER NOT NULL,
1754
1763
  status TEXT NOT NULL,
1755
1764
  attempts INTEGER NOT NULL,
@@ -1767,6 +1776,13 @@ class Store {
1767
1776
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
1768
1777
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
1769
1778
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
1779
+ -- idx_workflow_work_items_scope (route_scope, status) is created ONLY by
1780
+ -- migration 0008_work_item_route_scope, never here: this baseline DDL
1781
+ -- re-runs on EVERY open (0001 is not skip-guarded), and on a pre-0008
1782
+ -- database the CREATE TABLE above is a no-op, so an index on route_scope
1783
+ -- here would execute before the column exists and crash the open
1784
+ -- ("no such column: route_scope"). New columns may be folded into the
1785
+ -- CREATE TABLE (fresh-db only); their indexes must live in the migration.
1770
1786
 
1771
1787
  CREATE TABLE IF NOT EXISTS workflow_step_runs (
1772
1788
  id TEXT PRIMARY KEY,
@@ -2558,10 +2574,10 @@ class Store {
2558
2574
  const id = genId();
2559
2575
  const status = input.status ?? "queued";
2560
2576
  this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
2561
- subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
2577
+ subject_ref, project_key, project_group, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
2562
2578
  workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
2563
2579
  VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
2564
- $projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
2580
+ $projectKey, $projectGroup, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
2565
2581
  $lastReason, $created, $updated)
2566
2582
  ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
2567
2583
  invocation_id=excluded.invocation_id,
@@ -2570,6 +2586,7 @@ class Store {
2570
2586
  subject_ref=excluded.subject_ref,
2571
2587
  project_key=excluded.project_key,
2572
2588
  project_group=excluded.project_group,
2589
+ route_scope=excluded.route_scope,
2573
2590
  priority=excluded.priority,
2574
2591
  status=CASE
2575
2592
  WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
@@ -2611,6 +2628,7 @@ class Store {
2611
2628
  $subjectRef: input.subjectRef,
2612
2629
  $projectKey: input.projectKey ?? null,
2613
2630
  $projectGroup: input.projectGroup ?? null,
2631
+ $routeScope: input.routeScope ?? null,
2614
2632
  $priority: input.priority ?? 0,
2615
2633
  $status: status,
2616
2634
  $nextAttemptAt: input.nextAttemptAt ?? null,
@@ -2648,11 +2666,21 @@ class Store {
2648
2666
  countActiveWorkflowWorkItems(args = {}) {
2649
2667
  const active = ["admitted", "running"];
2650
2668
  const placeholders = active.map(() => "?").join(",");
2651
- const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
2669
+ const routeScope = args.routeScope?.trim() || undefined;
2670
+ const global = routeScope ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND route_scope = ?`).get(...active, routeScope)?.count ?? 0 : this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
2652
2671
  const project = args.projectKey ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_key = ?`).get(...active, args.projectKey)?.count ?? 0 : 0;
2653
2672
  const projectGroup = args.projectGroup ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_group = ?`).get(...active, args.projectGroup)?.count ?? 0 : undefined;
2654
2673
  return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
2655
2674
  }
2675
+ countRunningWorkflowStepsByAuthProfile() {
2676
+ const rows = this.db.query("SELECT account_profile, COUNT(*) AS count FROM workflow_step_runs WHERE status = 'running' AND account_profile IS NOT NULL GROUP BY account_profile").all();
2677
+ const counts = {};
2678
+ for (const row of rows) {
2679
+ if (row.account_profile)
2680
+ counts[row.account_profile] = row.count;
2681
+ }
2682
+ return counts;
2683
+ }
2656
2684
  requeueWorkflowWorkItem(id, patch = {}) {
2657
2685
  const current = this.getWorkflowWorkItem(id);
2658
2686
  if (!current)
@@ -3088,6 +3116,9 @@ class Store {
3088
3116
  }
3089
3117
  input.workflow.steps.forEach((step, sequence) => {
3090
3118
  const account = step.account ?? step.target.account;
3119
+ const agentTarget = step.target.type === "agent" ? step.target : undefined;
3120
+ const resolvedProfile = account?.profile ?? agentTarget?.authProfile ?? null;
3121
+ const resolvedTool = account?.tool ?? (agentTarget?.authProfile ? agentTarget.provider : null);
3091
3122
  this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
3092
3123
  exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
3093
3124
  VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
@@ -3096,8 +3127,8 @@ class Store {
3096
3127
  $workflowRunId: runId,
3097
3128
  $stepId: step.id,
3098
3129
  $sequence: sequence,
3099
- $accountProfile: account?.profile ?? null,
3100
- $accountTool: account?.tool ?? null,
3130
+ $accountProfile: resolvedProfile,
3131
+ $accountTool: resolvedTool,
3101
3132
  $created: now,
3102
3133
  $updated: now
3103
3134
  });
@@ -6705,6 +6736,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
6705
6736
  }
6706
6737
 
6707
6738
  // src/lib/scheduler.ts
6739
+ function loopLane(loop) {
6740
+ return loop.target.type === "command" ? "command" : "agent";
6741
+ }
6708
6742
  function manualRunScheduledFor(loop, now = new Date) {
6709
6743
  if (loop.archivedAt)
6710
6744
  return now.toISOString();
@@ -7097,14 +7131,23 @@ function claimDueRuns(deps) {
7097
7131
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
7098
7132
  if (maxClaims === 0)
7099
7133
  return { claims, claimed, completed: [], skipped, recovered, expired };
7134
+ const laneLimits = deps.laneLimits;
7135
+ const laneClaims = { command: 0, agent: 0 };
7136
+ const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
7137
+ const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
7100
7138
  for (const loop of deps.store.dueLoops(now)) {
7101
7139
  if (claims.length >= maxClaims)
7102
7140
  break;
7141
+ const lane = loopLane(loop);
7142
+ if (laneFull(lane))
7143
+ continue;
7103
7144
  const plan = dueSlots(loop, now);
7104
7145
  let loopSkips = 0;
7105
7146
  for (const slot of plan.slots) {
7106
7147
  if (claims.length >= maxClaims)
7107
7148
  break;
7149
+ if (laneFull(lane))
7150
+ break;
7108
7151
  if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
7109
7152
  break;
7110
7153
  const run = claimSlot(deps, loop, slot);
@@ -7113,6 +7156,7 @@ function claimDueRuns(deps) {
7113
7156
  if ("loop" in run) {
7114
7157
  claims.push(run);
7115
7158
  claimed.push(run.run);
7159
+ laneClaims[lane] += 1;
7116
7160
  } else if (run.status === "skipped") {
7117
7161
  skipped.push(run);
7118
7162
  loopSkips += 1;
@@ -7381,13 +7425,23 @@ function intervalFromEnv() {
7381
7425
  const value = Number(raw);
7382
7426
  return Number.isFinite(value) && value > 0 ? value : undefined;
7383
7427
  }
7384
- function concurrencyFromEnv() {
7385
- const raw = process.env.LOOPS_DAEMON_CONCURRENCY;
7428
+ function positiveIntEnv(name) {
7429
+ const raw = process.env[name];
7386
7430
  if (!raw)
7387
7431
  return;
7388
7432
  const value = Number(raw);
7389
7433
  return Number.isInteger(value) && value > 0 ? value : undefined;
7390
7434
  }
7435
+ function concurrencyFromEnv() {
7436
+ return positiveIntEnv("LOOPS_DAEMON_CONCURRENCY");
7437
+ }
7438
+ var DEFAULT_COMMAND_CONCURRENCY = 4;
7439
+ var DEFAULT_AGENT_CONCURRENCY = 8;
7440
+ function resolveLaneConcurrency(opts = {}) {
7441
+ const command = Math.max(1, opts.commandConcurrency ?? positiveIntEnv("LOOPS_DAEMON_COMMAND_CONCURRENCY") ?? DEFAULT_COMMAND_CONCURRENCY);
7442
+ const agent = Math.max(1, opts.agentConcurrency ?? opts.concurrency ?? positiveIntEnv("LOOPS_DAEMON_AGENT_CONCURRENCY") ?? concurrencyFromEnv() ?? DEFAULT_AGENT_CONCURRENCY);
7443
+ return { command, agent };
7444
+ }
7391
7445
  var DAEMON_LOG_MAX_BYTES = 50 * 1024 * 1024;
7392
7446
  var DAEMON_LOG_KEEP = 2;
7393
7447
  function daemonLogLine(message) {
@@ -7433,7 +7487,7 @@ async function runDaemon(opts = {}) {
7433
7487
  const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
7434
7488
  const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
7435
7489
  const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
7436
- const concurrency = Math.max(1, opts.concurrency ?? concurrencyFromEnv() ?? 4);
7490
+ const laneConcurrency = resolveLaneConcurrency(opts);
7437
7491
  const log = opts.log ?? defaultDaemonLog;
7438
7492
  const sleep = opts.sleep ?? realSleep;
7439
7493
  const lease = store.acquireDaemonLease({
@@ -7445,12 +7499,13 @@ async function runDaemon(opts = {}) {
7445
7499
  if (!lease)
7446
7500
  throw new Error("another loops daemon holds the database lease");
7447
7501
  writePid(process.pid, pidPath);
7448
- log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
7502
+ log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId} ` + `command_concurrency=${laneConcurrency.command} agent_concurrency=${laneConcurrency.agent}`);
7449
7503
  let stopFlag = false;
7450
7504
  let leaseLost = false;
7451
7505
  let leaseEpoch = 0;
7452
7506
  let runAbort = new AbortController;
7453
7507
  const activeRuns = new Map;
7508
+ const activeByLane = { command: 0, agent: 0 };
7454
7509
  const requestStop = (message) => {
7455
7510
  stopFlag = true;
7456
7511
  if (!runAbort.signal.aborted)
@@ -7543,7 +7598,12 @@ async function runDaemon(opts = {}) {
7543
7598
  log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
7544
7599
  };
7545
7600
  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));
7601
+ const lane = loopLane(claim.loop);
7602
+ activeByLane[lane] += 1;
7603
+ const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
7604
+ activeRuns.delete(claim.run.id);
7605
+ activeByLane[lane] -= 1;
7606
+ });
7547
7607
  activeRuns.set(claim.run.id, task);
7548
7608
  };
7549
7609
  try {
@@ -7579,19 +7639,23 @@ async function runDaemon(opts = {}) {
7579
7639
  onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
7580
7640
  tickFn: async () => {
7581
7641
  ensureLease();
7582
- const available = Math.max(0, concurrency - activeRuns.size);
7642
+ const laneLimits = {
7643
+ command: Math.max(0, laneConcurrency.command - activeByLane.command),
7644
+ agent: Math.max(0, laneConcurrency.agent - activeByLane.agent)
7645
+ };
7583
7646
  const result = claimDueRuns({
7584
7647
  store,
7585
7648
  runnerId,
7586
7649
  daemonLeaseId: leaseId,
7587
7650
  beforeRun: () => ensureLease(),
7588
- maxClaims: available
7651
+ maxClaims: laneLimits.command + laneLimits.agent,
7652
+ laneLimits
7589
7653
  });
7590
7654
  for (const claim of result.claims)
7591
7655
  startClaim(claim);
7592
7656
  const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
7593
7657
  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}`);
7658
+ 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
7659
  }
7596
7660
  await reapAbandoned(result.recovered);
7597
7661
  }
@@ -7752,7 +7816,7 @@ function enableStartup(result) {
7752
7816
  // package.json
7753
7817
  var package_default = {
7754
7818
  name: "@hasna/loops",
7755
- version: "0.4.10",
7819
+ version: "0.4.12",
7756
7820
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7757
7821
  type: "module",
7758
7822
  main: "dist/index.js",
@@ -7883,7 +7947,12 @@ function packageVersion() {
7883
7947
  // src/daemon/index.ts
7884
7948
  var program = new Command;
7885
7949
  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 }));
7950
+ 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({
7951
+ intervalMs: opts.intervalMs,
7952
+ concurrency: opts.concurrency,
7953
+ commandConcurrency: opts.commandConcurrency,
7954
+ agentConcurrency: opts.agentConcurrency
7955
+ }));
7887
7956
  program.command("start").action(async () => {
7888
7957
  const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
7889
7958
  console.log(JSON.stringify(result, null, 2));
package/dist/index.js CHANGED
@@ -1321,6 +1321,7 @@ function rowToWorkflowWorkItem(row) {
1321
1321
  subjectRef: row.subject_ref,
1322
1322
  projectKey: row.project_key ?? undefined,
1323
1323
  projectGroup: row.project_group ?? undefined,
1324
+ routeScope: row.route_scope ?? undefined,
1324
1325
  priority: row.priority,
1325
1326
  status: row.status,
1326
1327
  attempts: row.attempts,
@@ -1601,6 +1602,13 @@ class Store {
1601
1602
  this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
1602
1603
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
1603
1604
  }
1605
+ },
1606
+ {
1607
+ id: "0008_work_item_route_scope",
1608
+ apply: () => {
1609
+ this.addColumnIfMissing("workflow_work_items", "route_scope", "TEXT");
1610
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status)");
1611
+ }
1604
1612
  }
1605
1613
  ];
1606
1614
  }
@@ -1748,6 +1756,7 @@ class Store {
1748
1756
  subject_ref TEXT NOT NULL,
1749
1757
  project_key TEXT,
1750
1758
  project_group TEXT,
1759
+ route_scope TEXT,
1751
1760
  priority INTEGER NOT NULL,
1752
1761
  status TEXT NOT NULL,
1753
1762
  attempts INTEGER NOT NULL,
@@ -1765,6 +1774,13 @@ class Store {
1765
1774
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
1766
1775
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
1767
1776
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
1777
+ -- idx_workflow_work_items_scope (route_scope, status) is created ONLY by
1778
+ -- migration 0008_work_item_route_scope, never here: this baseline DDL
1779
+ -- re-runs on EVERY open (0001 is not skip-guarded), and on a pre-0008
1780
+ -- database the CREATE TABLE above is a no-op, so an index on route_scope
1781
+ -- here would execute before the column exists and crash the open
1782
+ -- ("no such column: route_scope"). New columns may be folded into the
1783
+ -- CREATE TABLE (fresh-db only); their indexes must live in the migration.
1768
1784
 
1769
1785
  CREATE TABLE IF NOT EXISTS workflow_step_runs (
1770
1786
  id TEXT PRIMARY KEY,
@@ -2556,10 +2572,10 @@ class Store {
2556
2572
  const id = genId();
2557
2573
  const status = input.status ?? "queued";
2558
2574
  this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
2559
- subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
2575
+ subject_ref, project_key, project_group, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
2560
2576
  workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
2561
2577
  VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
2562
- $projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
2578
+ $projectKey, $projectGroup, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
2563
2579
  $lastReason, $created, $updated)
2564
2580
  ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
2565
2581
  invocation_id=excluded.invocation_id,
@@ -2568,6 +2584,7 @@ class Store {
2568
2584
  subject_ref=excluded.subject_ref,
2569
2585
  project_key=excluded.project_key,
2570
2586
  project_group=excluded.project_group,
2587
+ route_scope=excluded.route_scope,
2571
2588
  priority=excluded.priority,
2572
2589
  status=CASE
2573
2590
  WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
@@ -2609,6 +2626,7 @@ class Store {
2609
2626
  $subjectRef: input.subjectRef,
2610
2627
  $projectKey: input.projectKey ?? null,
2611
2628
  $projectGroup: input.projectGroup ?? null,
2629
+ $routeScope: input.routeScope ?? null,
2612
2630
  $priority: input.priority ?? 0,
2613
2631
  $status: status,
2614
2632
  $nextAttemptAt: input.nextAttemptAt ?? null,
@@ -2646,11 +2664,21 @@ class Store {
2646
2664
  countActiveWorkflowWorkItems(args = {}) {
2647
2665
  const active = ["admitted", "running"];
2648
2666
  const placeholders = active.map(() => "?").join(",");
2649
- const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
2667
+ const routeScope = args.routeScope?.trim() || undefined;
2668
+ const global = routeScope ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND route_scope = ?`).get(...active, routeScope)?.count ?? 0 : this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
2650
2669
  const project = args.projectKey ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_key = ?`).get(...active, args.projectKey)?.count ?? 0 : 0;
2651
2670
  const projectGroup = args.projectGroup ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_group = ?`).get(...active, args.projectGroup)?.count ?? 0 : undefined;
2652
2671
  return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
2653
2672
  }
2673
+ countRunningWorkflowStepsByAuthProfile() {
2674
+ const rows = this.db.query("SELECT account_profile, COUNT(*) AS count FROM workflow_step_runs WHERE status = 'running' AND account_profile IS NOT NULL GROUP BY account_profile").all();
2675
+ const counts = {};
2676
+ for (const row of rows) {
2677
+ if (row.account_profile)
2678
+ counts[row.account_profile] = row.count;
2679
+ }
2680
+ return counts;
2681
+ }
2654
2682
  requeueWorkflowWorkItem(id, patch = {}) {
2655
2683
  const current = this.getWorkflowWorkItem(id);
2656
2684
  if (!current)
@@ -3086,6 +3114,9 @@ class Store {
3086
3114
  }
3087
3115
  input.workflow.steps.forEach((step, sequence) => {
3088
3116
  const account = step.account ?? step.target.account;
3117
+ const agentTarget = step.target.type === "agent" ? step.target : undefined;
3118
+ const resolvedProfile = account?.profile ?? agentTarget?.authProfile ?? null;
3119
+ const resolvedTool = account?.tool ?? (agentTarget?.authProfile ? agentTarget.provider : null);
3089
3120
  this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
3090
3121
  exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
3091
3122
  VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
@@ -3094,8 +3125,8 @@ class Store {
3094
3125
  $workflowRunId: runId,
3095
3126
  $stepId: step.id,
3096
3127
  $sequence: sequence,
3097
- $accountProfile: account?.profile ?? null,
3098
- $accountTool: account?.tool ?? null,
3128
+ $accountProfile: resolvedProfile,
3129
+ $accountTool: resolvedTool,
3099
3130
  $created: now,
3100
3131
  $updated: now
3101
3132
  });
@@ -4235,7 +4266,7 @@ class Store {
4235
4266
  // package.json
4236
4267
  var package_default = {
4237
4268
  name: "@hasna/loops",
4238
- version: "0.4.10",
4269
+ version: "0.4.12",
4239
4270
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4240
4271
  type: "module",
4241
4272
  main: "dist/index.js",
@@ -7286,6 +7317,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
7286
7317
  }
7287
7318
 
7288
7319
  // src/lib/scheduler.ts
7320
+ function loopLane(loop) {
7321
+ return loop.target.type === "command" ? "command" : "agent";
7322
+ }
7289
7323
  function manualRunScheduledFor(loop, now = new Date) {
7290
7324
  if (loop.archivedAt)
7291
7325
  return now.toISOString();
@@ -7678,14 +7712,23 @@ function claimDueRuns(deps) {
7678
7712
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
7679
7713
  if (maxClaims === 0)
7680
7714
  return { claims, claimed, completed: [], skipped, recovered, expired };
7715
+ const laneLimits = deps.laneLimits;
7716
+ const laneClaims = { command: 0, agent: 0 };
7717
+ const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
7718
+ const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
7681
7719
  for (const loop of deps.store.dueLoops(now)) {
7682
7720
  if (claims.length >= maxClaims)
7683
7721
  break;
7722
+ const lane = loopLane(loop);
7723
+ if (laneFull(lane))
7724
+ continue;
7684
7725
  const plan = dueSlots(loop, now);
7685
7726
  let loopSkips = 0;
7686
7727
  for (const slot of plan.slots) {
7687
7728
  if (claims.length >= maxClaims)
7688
7729
  break;
7730
+ if (laneFull(lane))
7731
+ break;
7689
7732
  if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
7690
7733
  break;
7691
7734
  const run = claimSlot(deps, loop, slot);
@@ -7694,6 +7737,7 @@ function claimDueRuns(deps) {
7694
7737
  if ("loop" in run) {
7695
7738
  claims.push(run);
7696
7739
  claimed.push(run.run);
7740
+ laneClaims[lane] += 1;
7697
7741
  } else if (run.status === "skipped") {
7698
7742
  skipped.push(run);
7699
7743
  loopSkips += 1;
@@ -9533,6 +9577,10 @@ CREATE TABLE IF NOT EXISTS audit_events (
9533
9577
  );
9534
9578
  CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events(subject_type, subject_id, created_at DESC);
9535
9579
  CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action, created_at DESC);
9580
+ `),
9581
+ migration("0004_work_item_route_scope", `
9582
+ ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS route_scope TEXT;
9583
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status);
9536
9584
  `)
9537
9585
  ]);
9538
9586
 
@@ -10187,6 +10235,79 @@ function prHandoffCommand(opts) {
10187
10235
  ].join(`
10188
10236
  `);
10189
10237
  }
10238
+
10239
+ // src/lib/route/profile-pool.ts
10240
+ function stableIndex(seed, size) {
10241
+ let hash = 2166136261;
10242
+ for (let i = 0;i < seed.length; i += 1) {
10243
+ hash ^= seed.charCodeAt(i);
10244
+ hash = Math.imul(hash, 16777619);
10245
+ }
10246
+ return Math.abs(hash >>> 0) % size;
10247
+ }
10248
+ function poolRoleOffset(role) {
10249
+ if (role === "worker")
10250
+ return 0;
10251
+ if (role === "verifier")
10252
+ return 1;
10253
+ if (role === "planner")
10254
+ return 2;
10255
+ return 3;
10256
+ }
10257
+ function selectLeastLoadedProfile(pool, loadCounts, anchor, exclude) {
10258
+ const size = pool.length;
10259
+ let best;
10260
+ let bestExcluded = true;
10261
+ let bestLoad = Number.POSITIVE_INFINITY;
10262
+ for (let k = 0;k < size; k += 1) {
10263
+ const profile = pool[(anchor + k) % size];
10264
+ const excluded = exclude.has(profile);
10265
+ const load = loadCounts[profile] ?? 0;
10266
+ if (best === undefined) {
10267
+ best = profile;
10268
+ bestExcluded = excluded;
10269
+ bestLoad = load;
10270
+ continue;
10271
+ }
10272
+ const better = excluded === bestExcluded ? load < bestLoad : !excluded && bestExcluded;
10273
+ if (better) {
10274
+ best = profile;
10275
+ bestExcluded = excluded;
10276
+ bestLoad = load;
10277
+ }
10278
+ }
10279
+ return best;
10280
+ }
10281
+ var ROLE_PRIORITY = ["worker", "verifier", "planner", "triage"];
10282
+ function assignPoolAuthProfiles(input) {
10283
+ const pool = input.pool.filter((entry) => entry.trim().length > 0);
10284
+ if (pool.length === 0)
10285
+ return { profiles: {}, deferred: false, minLoad: 0 };
10286
+ const minLoad = pool.reduce((min, profile) => Math.min(min, input.loadCounts[profile] ?? 0), Number.POSITIVE_INFINITY);
10287
+ const guard = input.maxPerProfile !== undefined && input.maxPerProfile > 0;
10288
+ if (guard && minLoad >= input.maxPerProfile) {
10289
+ return {
10290
+ profiles: {},
10291
+ deferred: true,
10292
+ minLoad,
10293
+ reason: `per-profile active limit reached (all ${pool.length} pool members >= ${input.maxPerProfile} running; min ${minLoad})`
10294
+ };
10295
+ }
10296
+ const workerIndex = stableIndex(input.seed, pool.length);
10297
+ const rolesToAssign = ROLE_PRIORITY.filter((role) => input.roles.includes(role));
10298
+ for (const role of input.roles)
10299
+ if (!rolesToAssign.includes(role))
10300
+ rolesToAssign.push(role);
10301
+ const profiles = {};
10302
+ const taken = new Set;
10303
+ for (const role of rolesToAssign) {
10304
+ const anchor = (workerIndex + poolRoleOffset(role)) % pool.length;
10305
+ const profile = selectLeastLoadedProfile(pool, input.loadCounts, anchor, taken);
10306
+ profiles[role] = profile;
10307
+ taken.add(profile);
10308
+ }
10309
+ return { profiles, deferred: false, minLoad: Number.isFinite(minLoad) ? minLoad : 0 };
10310
+ }
10190
10311
  // src/lib/templates-custom.ts
10191
10312
  import { existsSync as existsSync5, lstatSync as lstatSync2, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
10192
10313
  import { join as join6, resolve as resolve3 } from "path";
@@ -10582,25 +10703,13 @@ function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
10582
10703
  throw new Error(`${label} must be a positive integer number of milliseconds`);
10583
10704
  return value;
10584
10705
  }
10585
- function stableIndex(seed, size) {
10586
- let hash = 2166136261;
10587
- for (let i = 0;i < seed.length; i += 1) {
10588
- hash ^= seed.charCodeAt(i);
10589
- hash = Math.imul(hash, 16777619);
10590
- }
10591
- return Math.abs(hash >>> 0) % size;
10592
- }
10593
10706
  function rolePoolValue(pool, seed, role) {
10594
10707
  if (!pool?.length)
10595
10708
  return;
10596
10709
  const workerIndex = stableIndex(seed, pool.length);
10597
- if (role === "worker" || pool.length === 1)
10710
+ if (pool.length === 1)
10598
10711
  return pool[workerIndex];
10599
- if (role === "verifier")
10600
- return pool[(workerIndex + 1) % pool.length];
10601
- if (role === "planner")
10602
- return pool[(workerIndex + 2) % pool.length];
10603
- return pool[(workerIndex + 3) % pool.length];
10712
+ return pool[(workerIndex + poolRoleOffset(role)) % pool.length];
10604
10713
  }
10605
10714
  function authProfileForRole(input, role, seed) {
10606
10715
  if (role === "triage" && input.triageAuthProfile)
@@ -10770,7 +10879,8 @@ function agentTarget(input, prompt, role, seed, plan) {
10770
10879
  allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
10771
10880
  routing: {
10772
10881
  projectPath: input.routeProjectPath ?? input.projectPath,
10773
- ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
10882
+ ...input.projectGroup ? { projectGroup: input.projectGroup } : {},
10883
+ role
10774
10884
  },
10775
10885
  account: accountForRole(input, role, seed),
10776
10886
  timeoutMs: agentTimeoutMs(input),
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.12",
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;