@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/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.11",
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",
@@ -9546,6 +9577,10 @@ CREATE TABLE IF NOT EXISTS audit_events (
9546
9577
  );
9547
9578
  CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events(subject_type, subject_id, created_at DESC);
9548
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);
9549
9584
  `)
9550
9585
  ]);
9551
9586
 
@@ -10200,6 +10235,79 @@ function prHandoffCommand(opts) {
10200
10235
  ].join(`
10201
10236
  `);
10202
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
+ }
10203
10311
  // src/lib/templates-custom.ts
10204
10312
  import { existsSync as existsSync5, lstatSync as lstatSync2, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
10205
10313
  import { join as join6, resolve as resolve3 } from "path";
@@ -10595,25 +10703,13 @@ function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
10595
10703
  throw new Error(`${label} must be a positive integer number of milliseconds`);
10596
10704
  return value;
10597
10705
  }
10598
- function stableIndex(seed, size) {
10599
- let hash = 2166136261;
10600
- for (let i = 0;i < seed.length; i += 1) {
10601
- hash ^= seed.charCodeAt(i);
10602
- hash = Math.imul(hash, 16777619);
10603
- }
10604
- return Math.abs(hash >>> 0) % size;
10605
- }
10606
10706
  function rolePoolValue(pool, seed, role) {
10607
10707
  if (!pool?.length)
10608
10708
  return;
10609
10709
  const workerIndex = stableIndex(seed, pool.length);
10610
- if (role === "worker" || pool.length === 1)
10710
+ if (pool.length === 1)
10611
10711
  return pool[workerIndex];
10612
- if (role === "verifier")
10613
- return pool[(workerIndex + 1) % pool.length];
10614
- if (role === "planner")
10615
- return pool[(workerIndex + 2) % pool.length];
10616
- return pool[(workerIndex + 3) % pool.length];
10712
+ return pool[(workerIndex + poolRoleOffset(role)) % pool.length];
10617
10713
  }
10618
10714
  function authProfileForRole(input, role, seed) {
10619
10715
  if (role === "triage" && input.triageAuthProfile)
@@ -10783,7 +10879,8 @@ function agentTarget(input, prompt, role, seed, plan) {
10783
10879
  allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
10784
10880
  routing: {
10785
10881
  projectPath: input.routeProjectPath ?? input.projectPath,
10786
- ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
10882
+ ...input.projectGroup ? { projectGroup: input.projectGroup } : {},
10883
+ role
10787
10884
  },
10788
10885
  account: accountForRole(input, role, seed),
10789
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.11",
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",
@@ -0,0 +1,60 @@
1
+ import type { AgentWorkflowRole } from "../template-kit.js";
2
+ /**
3
+ * Least-loaded auth-profile pool selection + a `--max-per-profile` admission
4
+ * guard. Drain dispatch used to pick a pool member by a pure FNV-1a hash of the
5
+ * work-item seed (worker=pool[i], verifier=i+1, planner=i+2), with no awareness
6
+ * of how many runs each subscription account was already carrying. At high
7
+ * concurrency that stacked several concurrent workers on one ChatGPT account and
8
+ * tripped the provider-side 429/stream-drop wall. This module spreads work to
9
+ * the least-loaded account instead, and defers a route when every pool member is
10
+ * already saturated.
11
+ *
12
+ * The hash order is preserved as the deterministic tie-break: with equal load
13
+ * counts (e.g. a cold store) the selection is byte-for-byte identical to the old
14
+ * behaviour, so it is fully reproducible and does not perturb rendered-template
15
+ * snapshots.
16
+ */
17
+ /** FNV-1a index into a pool of `size`. Shared by the deterministic default
18
+ * (templates) and the least-loaded tie-break so the two never drift. */
19
+ export declare function stableIndex(seed: string, size: number): number;
20
+ /** Historical per-role offset from the worker index (worker+0, verifier+1,
21
+ * planner+2, everything else +3). Kept identical to the original rolePoolValue
22
+ * so equal-load selection reproduces the legacy assignment. */
23
+ export declare function poolRoleOffset(role: AgentWorkflowRole): number;
24
+ /**
25
+ * Pick the least-loaded pool member. Selection key, minimised lexicographically:
26
+ * 1. not in `exclude` before excluded (so verifier/planner land on a DIFFERENT
27
+ * profile than the worker whenever the pool is large enough),
28
+ * 2. lower running-step load,
29
+ * 3. deterministic order starting at `anchor` (first encountered wins ties).
30
+ * When every member is excluded (pool smaller than the number of roles) the
31
+ * exclusion is ignored and pure least-loaded/tie-break applies.
32
+ */
33
+ export declare function selectLeastLoadedProfile(pool: string[], loadCounts: Record<string, number>, anchor: number, exclude: ReadonlySet<string>): string;
34
+ export interface PoolAuthProfileAssignmentInput {
35
+ pool: string[];
36
+ seed: string;
37
+ loadCounts: Record<string, number>;
38
+ /** Defer the whole route when EVERY pool member already has >= K running
39
+ * steps. Undefined disables the guard (spread only, never defer). */
40
+ maxPerProfile?: number;
41
+ /** Roles present in the workflow that draw from the pool. */
42
+ roles: AgentWorkflowRole[];
43
+ }
44
+ export interface PoolAuthProfileAssignment {
45
+ /** Chosen auth profile per role. Empty when deferred. */
46
+ profiles: Partial<Record<AgentWorkflowRole, string>>;
47
+ /** True when the max-per-profile guard fired: every pool member is saturated. */
48
+ deferred: boolean;
49
+ reason?: string;
50
+ /** Minimum running-step load across the pool at decision time (attribution). */
51
+ minLoad: number;
52
+ }
53
+ /**
54
+ * Assign one pool member per role: the worker takes the globally least-loaded
55
+ * account; verifier/planner/triage each take the least-loaded account not
56
+ * already claimed by an earlier role (so reviews run on a different subscription
57
+ * than the work they review). Returns `deferred` when the max-per-profile guard
58
+ * fires so the caller can hold the route until an account frees up.
59
+ */
60
+ export declare function assignPoolAuthProfiles(input: PoolAuthProfileAssignmentInput): PoolAuthProfileAssignment;
@@ -28,6 +28,7 @@ export declare function routeProjectGroup(optsGroup: string | undefined, data: R
28
28
  export declare function routeThrottleDecision(store: Store, args: {
29
29
  projectPath: string;
30
30
  projectGroup?: string;
31
+ routeScope?: string;
31
32
  limits: RouteThrottleLimits;
32
33
  }): RouteThrottleDecision;
33
34
  export declare function routeThrottleDryRunPreview(args: {
@@ -32,6 +32,8 @@ export interface TodosTaskRouteOptions {
32
32
  maxActive?: string;
33
33
  maxActivePerProject?: string;
34
34
  maxActivePerProjectGroup?: string;
35
+ maxActiveScope?: string;
36
+ maxPerProfile?: string;
35
37
  worktreeMode?: string;
36
38
  worktreeRoot?: string;
37
39
  worktreeBranchPrefix?: string;
@@ -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
  });
@@ -4755,6 +4786,10 @@ CREATE TABLE IF NOT EXISTS audit_events (
4755
4786
  );
4756
4787
  CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events(subject_type, subject_id, created_at DESC);
4757
4788
  CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action, created_at DESC);
4789
+ `),
4790
+ migration("0004_work_item_route_scope", `
4791
+ ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS route_scope TEXT;
4792
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status);
4758
4793
  `)
4759
4794
  ]);
4760
4795
 
@@ -319,6 +319,10 @@ CREATE TABLE IF NOT EXISTS audit_events (
319
319
  );
320
320
  CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events(subject_type, subject_id, created_at DESC);
321
321
  CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action, created_at DESC);
322
+ `),
323
+ migration("0004_work_item_route_scope", `
324
+ ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS route_scope TEXT;
325
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status);
322
326
  `)
323
327
  ]);
324
328
  export {
@@ -319,6 +319,10 @@ CREATE TABLE IF NOT EXISTS audit_events (
319
319
  );
320
320
  CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events(subject_type, subject_id, created_at DESC);
321
321
  CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action, created_at DESC);
322
+ `),
323
+ migration("0004_work_item_route_scope", `
324
+ ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS route_scope TEXT;
325
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status);
322
326
  `)
323
327
  ]);
324
328
 
@@ -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
  });
@@ -218,11 +218,22 @@ export declare class Store {
218
218
  countActiveWorkflowWorkItems(args?: {
219
219
  projectKey?: string;
220
220
  projectGroup?: string;
221
+ routeScope?: string;
221
222
  }): {
222
223
  global: number;
223
224
  project: number;
224
225
  projectGroup?: number;
225
226
  };
227
+ /**
228
+ * Number of currently-running workflow steps per resolved auth profile
229
+ * (account_profile). Drives least-loaded pool selection and the
230
+ * `--max-per-profile` guard so concurrency spreads across subscription
231
+ * accounts instead of stacking on one (the provider-side 429 wall). Only
232
+ * `running` steps are counted: within a workflow steps run sequentially, so a
233
+ * profile's running count is the number of concurrent workflows executing a
234
+ * step on that account right now — exactly the concurrency to bound.
235
+ */
236
+ countRunningWorkflowStepsByAuthProfile(): Record<string, number>;
226
237
  requeueWorkflowWorkItem(id: string, patch?: {
227
238
  reason?: string;
228
239
  }): WorkflowWorkItem;