@hasna/loops 0.4.11 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ 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.12 (2026-07-05)
9
+
10
+ Drain throughput: `--max-active` is now a per-route ceiling instead of a
11
+ store-wide one, drain dispatch spreads across subscription accounts by live load,
12
+ and every step records the account it ran on.
13
+
14
+ ### Fixed
15
+
16
+ - **Route admission — per-route `--max-active`:** the global admission count was
17
+ store-wide, so the busiest router's `--max-active` became a shared ceiling that
18
+ throttled every other router (average concurrency ~1.9 while the agent lane
19
+ allowed 12; ~253 items/24h deferred as `global active workflow limit reached`).
20
+ The global count is now scoped to the route/drain that set the limit; scope
21
+ precedence is an explicit `--max-active-scope <key>` first, then the running
22
+ loop's `LOOPS_LOOP_NAME`, then the route key as the final fallback — so each
23
+ router's `--max-active` is its own ceiling.
24
+ Existing routers get correct scoping with no config change. `project` and
25
+ `project_group` counts are unchanged: they remain cross-route anti-hog caps.
26
+ - **Per-account attribution:** Codewith agent steps carry their subscription
27
+ account in `authProfile`, not an `AccountRef`, so `workflow_step_runs.account_profile`
28
+ was NULL for every drain worker and per-account burn could not be attributed.
29
+ The resolved auth profile (and provider as `account_tool`) is now recorded on
30
+ each step at run creation, and the per-role assignment is surfaced in drain
31
+ reports.
32
+
33
+ ### Added
34
+
35
+ - **Least-loaded auth-profile pool selection + `--max-per-profile`:** pooled
36
+ Codewith dispatch picked a member by a pure hash of the work item, which stacked
37
+ several concurrent workers on one account at high concurrency and tripped
38
+ provider-side 429/stream-drop limits. Live drains now count running steps per
39
+ account and place each route on the least-loaded pool member (verifier/planner
40
+ still land on a different account than the worker; the hash is the deterministic
41
+ tie-break, so a cold store reproduces the prior assignment). New
42
+ `--max-per-profile <K>` (default 2 for pools of two or more, `0` disables) defers
43
+ a route when every pool account already has `K` running steps.
44
+
45
+ Schema: additive sqlite migration `0008_work_item_route_scope` (nullable
46
+ `workflow_work_items.route_scope`; its index is created only by the migration —
47
+ never in the baseline DDL, which re-runs on every open and would crash pre-0008
48
+ databases). `SCHEMA_USER_VERSION` is intentionally not bumped so an older binary
49
+ can still open a database this migration touched (rollback-safe). Postgres gets
50
+ the equivalent additive migration `0004_work_item_route_scope`; the released,
51
+ checksummed `0002_workflows_goals` block is untouched so existing postgres
52
+ ledgers keep verifying. Upgrade coverage: a real pre-0008 (0.4.11 schema)
53
+ fixture database must open cleanly, gain the column + index, and keep its data;
54
+ released postgres migration checksums are pinned in tests.
55
+
8
56
  ## 0.4.11 (2026-07-05)
9
57
 
10
58
  Scheduler fairness and PR-route hygiene: fast command loops no longer starve
package/README.md CHANGED
@@ -594,8 +594,15 @@ This is the intended deterministic-to-agentic path: a producer creates a todos
594
594
  task, `@hasna/events` delivers `task.created`, OpenLoops records the invocation
595
595
  and admission item, OpenLoops creates a worker/verifier workflow when admitted,
596
596
  and the workflow updates todos with evidence. Use account pools so worker and
597
- verifier steps do not burn the same profile; OpenLoops picks deterministically
598
- and uses a different verifier profile when the pool has at least two entries.
597
+ verifier steps do not burn the same profile. For live Codewith drains, OpenLoops
598
+ spreads each route to the **least-loaded** pool account (counting running steps
599
+ per account) and still keeps the verifier on a different profile than the worker;
600
+ the deterministic hash is the tie-break, so a cold store reproduces the previous
601
+ assignment exactly. `--max-per-profile <K>` (default 2 for pools of two or more,
602
+ `0` to disable) defers a route when every pool account already has `K` running
603
+ steps — the guardrail that keeps higher concurrency from stacking on one
604
+ subscription and tripping provider-side rate limits. The chosen account per role
605
+ is recorded on each step (`account_profile`) and surfaced in drain reports.
599
606
  Use `--dry-run` to inspect the rendered invocation, work item, workflow, and
600
607
  loop input without storing anything.
601
608
 
@@ -645,11 +652,21 @@ loops routes schedule todos-task oss-task-route-drain \
645
652
  --max-active-per-project 1 \
646
653
  --max-active-per-project-group 4 \
647
654
  --max-active 12 \
655
+ --max-per-profile 2 \
648
656
  --worktree-mode required \
649
657
  --evidence-dir "$HOME/.hasna/loops/reports/oss-task-route-drain" \
650
658
  --compact
651
659
  ```
652
660
 
661
+ `--max-active` counts active routed workflows **per route**. Scope precedence:
662
+ an explicit `--max-active-scope <key>` wins, else the running loop's
663
+ `LOOPS_LOOP_NAME`, else the route key — so each router's limit is its own
664
+ ceiling rather than a store-wide total shared by every router.
665
+ `--max-active-per-project` and `--max-active-per-project-group` remain cross-route
666
+ anti-hog caps counted over all routes. Raise a router's `--max-active`
667
+ deliberately once counting is per-route; keep `--max-per-profile` set so the
668
+ extra concurrency spreads across subscription accounts.
669
+
653
670
  Only route tasks that explicitly opt in with `auto:route`, `route_enabled=true`,
654
671
  or `automation.allowed=true`. Use Codewith account pools, required worktrees,
655
672
  max-active throttles, and bounded evidence directories. Do not dispatch or paste
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.11",
6
+ version: "0.4.12",
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
@@ -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
  });
@@ -4237,7 +4268,7 @@ class Store {
4237
4268
  // package.json
4238
4269
  var package_default = {
4239
4270
  name: "@hasna/loops",
4240
- version: "0.4.11",
4271
+ version: "0.4.12",
4241
4272
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4242
4273
  type: "module",
4243
4274
  main: "dist/index.js",
@@ -9464,6 +9495,79 @@ function prHandoffCommand(opts) {
9464
9495
  ].join(`
9465
9496
  `);
9466
9497
  }
9498
+
9499
+ // src/lib/route/profile-pool.ts
9500
+ function stableIndex(seed, size) {
9501
+ let hash = 2166136261;
9502
+ for (let i = 0;i < seed.length; i += 1) {
9503
+ hash ^= seed.charCodeAt(i);
9504
+ hash = Math.imul(hash, 16777619);
9505
+ }
9506
+ return Math.abs(hash >>> 0) % size;
9507
+ }
9508
+ function poolRoleOffset(role) {
9509
+ if (role === "worker")
9510
+ return 0;
9511
+ if (role === "verifier")
9512
+ return 1;
9513
+ if (role === "planner")
9514
+ return 2;
9515
+ return 3;
9516
+ }
9517
+ function selectLeastLoadedProfile(pool, loadCounts, anchor, exclude) {
9518
+ const size = pool.length;
9519
+ let best;
9520
+ let bestExcluded = true;
9521
+ let bestLoad = Number.POSITIVE_INFINITY;
9522
+ for (let k = 0;k < size; k += 1) {
9523
+ const profile = pool[(anchor + k) % size];
9524
+ const excluded = exclude.has(profile);
9525
+ const load = loadCounts[profile] ?? 0;
9526
+ if (best === undefined) {
9527
+ best = profile;
9528
+ bestExcluded = excluded;
9529
+ bestLoad = load;
9530
+ continue;
9531
+ }
9532
+ const better = excluded === bestExcluded ? load < bestLoad : !excluded && bestExcluded;
9533
+ if (better) {
9534
+ best = profile;
9535
+ bestExcluded = excluded;
9536
+ bestLoad = load;
9537
+ }
9538
+ }
9539
+ return best;
9540
+ }
9541
+ var ROLE_PRIORITY = ["worker", "verifier", "planner", "triage"];
9542
+ function assignPoolAuthProfiles(input) {
9543
+ const pool = input.pool.filter((entry) => entry.trim().length > 0);
9544
+ if (pool.length === 0)
9545
+ return { profiles: {}, deferred: false, minLoad: 0 };
9546
+ const minLoad = pool.reduce((min, profile) => Math.min(min, input.loadCounts[profile] ?? 0), Number.POSITIVE_INFINITY);
9547
+ const guard = input.maxPerProfile !== undefined && input.maxPerProfile > 0;
9548
+ if (guard && minLoad >= input.maxPerProfile) {
9549
+ return {
9550
+ profiles: {},
9551
+ deferred: true,
9552
+ minLoad,
9553
+ reason: `per-profile active limit reached (all ${pool.length} pool members >= ${input.maxPerProfile} running; min ${minLoad})`
9554
+ };
9555
+ }
9556
+ const workerIndex = stableIndex(input.seed, pool.length);
9557
+ const rolesToAssign = ROLE_PRIORITY.filter((role) => input.roles.includes(role));
9558
+ for (const role of input.roles)
9559
+ if (!rolesToAssign.includes(role))
9560
+ rolesToAssign.push(role);
9561
+ const profiles = {};
9562
+ const taken = new Set;
9563
+ for (const role of rolesToAssign) {
9564
+ const anchor = (workerIndex + poolRoleOffset(role)) % pool.length;
9565
+ const profile = selectLeastLoadedProfile(pool, input.loadCounts, anchor, taken);
9566
+ profiles[role] = profile;
9567
+ taken.add(profile);
9568
+ }
9569
+ return { profiles, deferred: false, minLoad: Number.isFinite(minLoad) ? minLoad : 0 };
9570
+ }
9467
9571
  // src/lib/templates-custom.ts
9468
9572
  import { existsSync as existsSync6, lstatSync as lstatSync2, mkdirSync as mkdirSync7, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
9469
9573
  import { join as join6, resolve as resolve3 } from "path";
@@ -9859,25 +9963,13 @@ function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
9859
9963
  throw new Error(`${label} must be a positive integer number of milliseconds`);
9860
9964
  return value;
9861
9965
  }
9862
- function stableIndex(seed, size) {
9863
- let hash = 2166136261;
9864
- for (let i = 0;i < seed.length; i += 1) {
9865
- hash ^= seed.charCodeAt(i);
9866
- hash = Math.imul(hash, 16777619);
9867
- }
9868
- return Math.abs(hash >>> 0) % size;
9869
- }
9870
9966
  function rolePoolValue(pool, seed, role) {
9871
9967
  if (!pool?.length)
9872
9968
  return;
9873
9969
  const workerIndex = stableIndex(seed, pool.length);
9874
- if (role === "worker" || pool.length === 1)
9970
+ if (pool.length === 1)
9875
9971
  return pool[workerIndex];
9876
- if (role === "verifier")
9877
- return pool[(workerIndex + 1) % pool.length];
9878
- if (role === "planner")
9879
- return pool[(workerIndex + 2) % pool.length];
9880
- return pool[(workerIndex + 3) % pool.length];
9972
+ return pool[(workerIndex + poolRoleOffset(role)) % pool.length];
9881
9973
  }
9882
9974
  function authProfileForRole(input, role, seed) {
9883
9975
  if (role === "triage" && input.triageAuthProfile)
@@ -10047,7 +10139,8 @@ function agentTarget(input, prompt, role, seed, plan) {
10047
10139
  allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
10048
10140
  routing: {
10049
10141
  projectPath: input.routeProjectPath ?? input.projectPath,
10050
- ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
10142
+ ...input.projectGroup ? { projectGroup: input.projectGroup } : {},
10143
+ role
10051
10144
  },
10052
10145
  account: accountForRole(input, role, seed),
10053
10146
  timeoutMs: agentTimeoutMs(input),
@@ -11751,7 +11844,8 @@ function routeProjectGroup(optsGroup, data, metadata) {
11751
11844
  function routeThrottleDecision(store, args) {
11752
11845
  const projectPath = normalizeRoutePath(args.projectPath) ?? resolve6(args.projectPath);
11753
11846
  const projectGroup = args.projectGroup?.trim() || undefined;
11754
- const counts = store.countActiveWorkflowWorkItems({ projectKey: projectPath, projectGroup });
11847
+ const routeScope = args.routeScope?.trim() || undefined;
11848
+ const counts = store.countActiveWorkflowWorkItems({ projectKey: projectPath, projectGroup, routeScope });
11755
11849
  const base = {
11756
11850
  projectPath,
11757
11851
  ...projectGroup ? { projectGroup } : {},
@@ -11913,6 +12007,33 @@ async function readEventEnvelopeInput(opts = {}) {
11913
12007
  throw new ValidationError("event.source is required");
11914
12008
  return event;
11915
12009
  }
12010
+ function resolveRouteScope(opts, routeKey) {
12011
+ return opts.maxActiveScope?.trim() || process.env.LOOPS_LOOP_NAME?.trim() || routeKey || undefined;
12012
+ }
12013
+ function buildPoolRoutingPlan(opts, provider, authProfilePool, workflowBody, seed) {
12014
+ if (provider !== "codewith")
12015
+ return;
12016
+ const pool = (authProfilePool ?? []).filter((entry) => entry.trim().length > 0);
12017
+ if (pool.length < 2)
12018
+ return;
12019
+ const pinned = Boolean(opts.triageAuthProfile || opts.plannerAuthProfile || opts.workerAuthProfile || opts.verifierAuthProfile);
12020
+ if (pinned)
12021
+ return;
12022
+ const rolesByStepId = {};
12023
+ for (const step of workflowBody.steps) {
12024
+ const target = step.target;
12025
+ if (target.type !== "agent" || target.provider !== "codewith")
12026
+ continue;
12027
+ const role = target.routing?.role;
12028
+ if (role)
12029
+ rolesByStepId[step.id] = role;
12030
+ }
12031
+ if (Object.keys(rolesByStepId).length === 0)
12032
+ return;
12033
+ const explicitMax = nonNegativeInteger(opts.maxPerProfile, "--max-per-profile");
12034
+ const maxPerProfile = explicitMax ?? 2;
12035
+ return { pool, seed, maxPerProfile: maxPerProfile > 0 ? maxPerProfile : undefined, rolesByStepId };
12036
+ }
11916
12037
  function dedupedRoutePrint(plan, outcome) {
11917
12038
  return {
11918
12039
  kind: "deduped",
@@ -11942,6 +12063,7 @@ function routeEvent(plan) {
11942
12063
  subjectRef: plan.subjectRef,
11943
12064
  projectKey: plan.routeProjectPath,
11944
12065
  projectGroup: plan.projectGroup,
12066
+ routeScope: plan.routeScope,
11945
12067
  priority: 0,
11946
12068
  status: "queued"
11947
12069
  };
@@ -11981,6 +12103,7 @@ function routeEvent(plan) {
11981
12103
  const workflowPreflightSpec = workflowSpecForPreflight(workflowBody, "event-preflight");
11982
12104
  generatedRouteSandboxPreflight(workflowPreflightSpec);
11983
12105
  const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, plan.workflowContext, {}) : undefined;
12106
+ let poolAssignment;
11984
12107
  const outcome = store.writeTransaction(() => {
11985
12108
  const invocation = store.createWorkflowInvocation(plan.invocationInput);
11986
12109
  const existingItem = store.findWorkflowWorkItem(plan.routeKey, idempotencyKey);
@@ -11991,12 +12114,39 @@ function routeEvent(plan) {
11991
12114
  return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
11992
12115
  }
11993
12116
  }
11994
- const throttle = hasThrottleLimits(plan.throttleLimits) ? routeThrottleDecision(store, { projectPath: plan.routeProjectPath, projectGroup: plan.projectGroup, limits: plan.throttleLimits }) : undefined;
12117
+ const throttle = hasThrottleLimits(plan.throttleLimits) ? routeThrottleDecision(store, {
12118
+ projectPath: plan.routeProjectPath,
12119
+ projectGroup: plan.projectGroup,
12120
+ routeScope: plan.routeScope,
12121
+ limits: plan.throttleLimits
12122
+ }) : undefined;
12123
+ if (plan.poolRouting) {
12124
+ const loadCounts = store.countRunningWorkflowStepsByAuthProfile();
12125
+ poolAssignment = assignPoolAuthProfiles({
12126
+ pool: plan.poolRouting.pool,
12127
+ seed: plan.poolRouting.seed,
12128
+ loadCounts,
12129
+ maxPerProfile: plan.poolRouting.maxPerProfile,
12130
+ roles: Object.values(plan.poolRouting.rolesByStepId)
12131
+ });
12132
+ }
12133
+ const activeThrottled = Boolean(throttle && !throttle.allowed);
12134
+ const poolDeferred = Boolean(poolAssignment?.deferred);
12135
+ const deferred = activeThrottled || poolDeferred;
12136
+ const deferReason = activeThrottled ? throttle.reason : poolAssignment?.reason;
12137
+ const effectiveThrottle = activeThrottled ? throttle : poolDeferred ? {
12138
+ allowed: false,
12139
+ reason: deferReason,
12140
+ projectPath: plan.routeProjectPath,
12141
+ ...plan.projectGroup ? { projectGroup: plan.projectGroup } : {},
12142
+ limits: plan.throttleLimits,
12143
+ counts: throttle?.counts ?? { global: 0, project: 0 }
12144
+ } : throttle;
11995
12145
  const workItem = store.upsertWorkflowWorkItem({
11996
12146
  ...workItemInput,
11997
12147
  invocationId: invocation.id,
11998
- status: throttle && !throttle.allowed ? "deferred" : "queued",
11999
- lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
12148
+ status: deferred ? "deferred" : "queued",
12149
+ lastReason: deferred ? deferReason : undefined
12000
12150
  });
12001
12151
  const requeue = workItem.attempts > 0 && workItem.status === "queued" ? {
12002
12152
  previousWorkItemId: workItem.id,
@@ -12004,8 +12154,16 @@ function routeEvent(plan) {
12004
12154
  reason: workItem.lastReason
12005
12155
  } : undefined;
12006
12156
  const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, plan.invocationInput);
12007
- if (throttle && !throttle.allowed)
12008
- return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle };
12157
+ if (deferred)
12158
+ return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle: effectiveThrottle };
12159
+ if (poolAssignment && !poolAssignment.deferred) {
12160
+ for (const step of workflowBody.steps) {
12161
+ const role = plan.poolRouting?.rolesByStepId[step.id];
12162
+ const chosen = role ? poolAssignment.profiles[role] : undefined;
12163
+ if (chosen && step.target.type === "agent")
12164
+ step.target.authProfile = chosen;
12165
+ }
12166
+ }
12009
12167
  const workflow = routeWorkflowForStorage(store, workflowBody);
12010
12168
  const loop = store.createLoop({
12011
12169
  ...loopInput,
@@ -12063,6 +12221,7 @@ function routeEvent(plan) {
12063
12221
  loop: publicLoop(outcome.loop),
12064
12222
  requeue: outcome.requeue,
12065
12223
  throttle: outcome.throttle,
12224
+ ...poolAssignment && !poolAssignment.deferred && Object.keys(poolAssignment.profiles).length ? { accountProfiles: poolAssignment.profiles, routeScope: plan.routeScope } : {},
12066
12225
  sandboxPreflight,
12067
12226
  preflight
12068
12227
  },
@@ -12246,6 +12405,8 @@ function routeTodosTaskEvent(event, opts) {
12246
12405
  invocationInput,
12247
12406
  routeProjectPath,
12248
12407
  projectGroup,
12408
+ routeScope: resolveRouteScope(opts, "todos-task"),
12409
+ poolRouting: buildPoolRoutingPlan(opts, provider, providerRouting.authProfilePool, workflowBody, taskId),
12249
12410
  subjectRef: taskId,
12250
12411
  loopName,
12251
12412
  loopDescription: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
@@ -12359,6 +12520,8 @@ function routeGenericEvent(event, opts) {
12359
12520
  invocationInput,
12360
12521
  routeProjectPath,
12361
12522
  projectGroup,
12523
+ routeScope: resolveRouteScope(opts, "generic-event"),
12524
+ poolRouting: buildPoolRoutingPlan(opts, provider, providerRouting.authProfilePool, workflowBody, `${event.source}:${event.type}:${event.id}`),
12362
12525
  subjectRef: stringField(event.subject) ?? event.id,
12363
12526
  loopName,
12364
12527
  loopDescription: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
@@ -12562,6 +12725,8 @@ function compactDrainResult(result) {
12562
12725
  workflowId: stringField(workflow?.id),
12563
12726
  workflowName: stringField(workflow?.name),
12564
12727
  providerRouting,
12728
+ accountProfiles: objectField2(value.accountProfiles),
12729
+ routeScope: stringField(value.routeScope),
12565
12730
  requeue,
12566
12731
  queuedAtSource: value.queuedAtSource,
12567
12732
  fatal: value.fatal === true ? true : undefined
@@ -13227,6 +13392,18 @@ var AGENT_ROUTING_OPTION_SPECS = [
13227
13392
  kind: "value",
13228
13393
  description: "skip creating a workflow when this many active routed workflows already exist for the project group"
13229
13394
  },
13395
+ {
13396
+ flags: "--max-active-scope <key>",
13397
+ key: "maxActiveScope",
13398
+ kind: "value",
13399
+ description: "scope --max-active counting to this route/drain identity (defaults to the LOOPS_LOOP_NAME of the running loop, else the route key) so each drain's --max-active is its own ceiling instead of a store-wide one"
13400
+ },
13401
+ {
13402
+ flags: "--max-per-profile <n>",
13403
+ key: "maxPerProfile",
13404
+ kind: "value",
13405
+ description: "for codewith auth-profile pools, spread dispatch to the least-loaded account and defer when every pool member already has this many running steps (default 2 for pools of 2+; 0 disables the guard)"
13406
+ },
13230
13407
  { flags: "--worktree-mode <mode>", key: "worktreeMode", kind: "value", description: "worktree isolation mode: auto, required, off, or main", defaultValue: "auto" },
13231
13408
  { flags: "--worktree-root <path>", key: "worktreeRoot", kind: "value", description: "base directory for OpenLoops-managed git worktrees" },
13232
13409
  { flags: "--worktree-branch-prefix <prefix>", key: "worktreeBranchPrefix", kind: "value", description: "branch prefix for generated worktrees", defaultValue: "openloops" },
@@ -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
  });
@@ -7785,7 +7816,7 @@ function enableStartup(result) {
7785
7816
  // package.json
7786
7817
  var package_default = {
7787
7818
  name: "@hasna/loops",
7788
- version: "0.4.11",
7819
+ version: "0.4.12",
7789
7820
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7790
7821
  type: "module",
7791
7822
  main: "dist/index.js",