@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.
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.10",
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",
@@ -6969,6 +7000,9 @@ function buildHealthReport(store, opts = {}) {
6969
7000
  }
6970
7001
 
6971
7002
  // src/lib/scheduler.ts
7003
+ function loopLane(loop) {
7004
+ return loop.target.type === "command" ? "command" : "agent";
7005
+ }
6972
7006
  function manualRunScheduledFor(loop, now = new Date) {
6973
7007
  if (loop.archivedAt)
6974
7008
  return now.toISOString();
@@ -7361,14 +7395,23 @@ function claimDueRuns(deps) {
7361
7395
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
7362
7396
  if (maxClaims === 0)
7363
7397
  return { claims, claimed, completed: [], skipped, recovered, expired };
7398
+ const laneLimits = deps.laneLimits;
7399
+ const laneClaims = { command: 0, agent: 0 };
7400
+ const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
7401
+ const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
7364
7402
  for (const loop of deps.store.dueLoops(now)) {
7365
7403
  if (claims.length >= maxClaims)
7366
7404
  break;
7405
+ const lane = loopLane(loop);
7406
+ if (laneFull(lane))
7407
+ continue;
7367
7408
  const plan = dueSlots(loop, now);
7368
7409
  let loopSkips = 0;
7369
7410
  for (const slot of plan.slots) {
7370
7411
  if (claims.length >= maxClaims)
7371
7412
  break;
7413
+ if (laneFull(lane))
7414
+ break;
7372
7415
  if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
7373
7416
  break;
7374
7417
  const run = claimSlot(deps, loop, slot);
@@ -7377,6 +7420,7 @@ function claimDueRuns(deps) {
7377
7420
  if ("loop" in run) {
7378
7421
  claims.push(run);
7379
7422
  claimed.push(run.run);
7423
+ laneClaims[lane] += 1;
7380
7424
  } else if (run.status === "skipped") {
7381
7425
  skipped.push(run);
7382
7426
  loopSkips += 1;
@@ -7648,13 +7692,23 @@ function intervalFromEnv() {
7648
7692
  const value = Number(raw);
7649
7693
  return Number.isFinite(value) && value > 0 ? value : undefined;
7650
7694
  }
7651
- function concurrencyFromEnv() {
7652
- const raw = process.env.LOOPS_DAEMON_CONCURRENCY;
7695
+ function positiveIntEnv(name) {
7696
+ const raw = process.env[name];
7653
7697
  if (!raw)
7654
7698
  return;
7655
7699
  const value = Number(raw);
7656
7700
  return Number.isInteger(value) && value > 0 ? value : undefined;
7657
7701
  }
7702
+ function concurrencyFromEnv() {
7703
+ return positiveIntEnv("LOOPS_DAEMON_CONCURRENCY");
7704
+ }
7705
+ var DEFAULT_COMMAND_CONCURRENCY = 4;
7706
+ var DEFAULT_AGENT_CONCURRENCY = 8;
7707
+ function resolveLaneConcurrency(opts = {}) {
7708
+ const command = Math.max(1, opts.commandConcurrency ?? positiveIntEnv("LOOPS_DAEMON_COMMAND_CONCURRENCY") ?? DEFAULT_COMMAND_CONCURRENCY);
7709
+ const agent = Math.max(1, opts.agentConcurrency ?? opts.concurrency ?? positiveIntEnv("LOOPS_DAEMON_AGENT_CONCURRENCY") ?? concurrencyFromEnv() ?? DEFAULT_AGENT_CONCURRENCY);
7710
+ return { command, agent };
7711
+ }
7658
7712
  var DAEMON_LOG_MAX_BYTES = 50 * 1024 * 1024;
7659
7713
  var DAEMON_LOG_KEEP = 2;
7660
7714
  function daemonLogLine(message) {
@@ -7700,7 +7754,7 @@ async function runDaemon(opts = {}) {
7700
7754
  const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
7701
7755
  const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
7702
7756
  const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
7703
- const concurrency = Math.max(1, opts.concurrency ?? concurrencyFromEnv() ?? 4);
7757
+ const laneConcurrency = resolveLaneConcurrency(opts);
7704
7758
  const log = opts.log ?? defaultDaemonLog;
7705
7759
  const sleep = opts.sleep ?? realSleep;
7706
7760
  const lease = store.acquireDaemonLease({
@@ -7712,12 +7766,13 @@ async function runDaemon(opts = {}) {
7712
7766
  if (!lease)
7713
7767
  throw new Error("another loops daemon holds the database lease");
7714
7768
  writePid(process.pid, pidPath);
7715
- log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
7769
+ log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId} ` + `command_concurrency=${laneConcurrency.command} agent_concurrency=${laneConcurrency.agent}`);
7716
7770
  let stopFlag = false;
7717
7771
  let leaseLost = false;
7718
7772
  let leaseEpoch = 0;
7719
7773
  let runAbort = new AbortController;
7720
7774
  const activeRuns = new Map;
7775
+ const activeByLane = { command: 0, agent: 0 };
7721
7776
  const requestStop = (message) => {
7722
7777
  stopFlag = true;
7723
7778
  if (!runAbort.signal.aborted)
@@ -7810,7 +7865,12 @@ async function runDaemon(opts = {}) {
7810
7865
  log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
7811
7866
  };
7812
7867
  const startClaim = (claim) => {
7813
- const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => activeRuns.delete(claim.run.id));
7868
+ const lane = loopLane(claim.loop);
7869
+ activeByLane[lane] += 1;
7870
+ const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
7871
+ activeRuns.delete(claim.run.id);
7872
+ activeByLane[lane] -= 1;
7873
+ });
7814
7874
  activeRuns.set(claim.run.id, task);
7815
7875
  };
7816
7876
  try {
@@ -7846,19 +7906,23 @@ async function runDaemon(opts = {}) {
7846
7906
  onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
7847
7907
  tickFn: async () => {
7848
7908
  ensureLease();
7849
- const available = Math.max(0, concurrency - activeRuns.size);
7909
+ const laneLimits = {
7910
+ command: Math.max(0, laneConcurrency.command - activeByLane.command),
7911
+ agent: Math.max(0, laneConcurrency.agent - activeByLane.agent)
7912
+ };
7850
7913
  const result = claimDueRuns({
7851
7914
  store,
7852
7915
  runnerId,
7853
7916
  daemonLeaseId: leaseId,
7854
7917
  beforeRun: () => ensureLease(),
7855
- maxClaims: available
7918
+ maxClaims: laneLimits.command + laneLimits.agent,
7919
+ laneLimits
7856
7920
  });
7857
7921
  for (const claim of result.claims)
7858
7922
  startClaim(claim);
7859
7923
  const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
7860
7924
  if (changed > 0) {
7861
- log(`tick claimed=${result.claims.length} active=${activeRuns.size} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
7925
+ log(`tick claimed=${result.claims.length} active=${activeRuns.size} (command=${activeByLane.command} agent=${activeByLane.agent}) skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
7862
7926
  }
7863
7927
  await reapAbandoned(result.recovered);
7864
7928
  }
@@ -9431,6 +9495,79 @@ function prHandoffCommand(opts) {
9431
9495
  ].join(`
9432
9496
  `);
9433
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
+ }
9434
9571
  // src/lib/templates-custom.ts
9435
9572
  import { existsSync as existsSync6, lstatSync as lstatSync2, mkdirSync as mkdirSync7, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
9436
9573
  import { join as join6, resolve as resolve3 } from "path";
@@ -9826,25 +9963,13 @@ function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
9826
9963
  throw new Error(`${label} must be a positive integer number of milliseconds`);
9827
9964
  return value;
9828
9965
  }
9829
- function stableIndex(seed, size) {
9830
- let hash = 2166136261;
9831
- for (let i = 0;i < seed.length; i += 1) {
9832
- hash ^= seed.charCodeAt(i);
9833
- hash = Math.imul(hash, 16777619);
9834
- }
9835
- return Math.abs(hash >>> 0) % size;
9836
- }
9837
9966
  function rolePoolValue(pool, seed, role) {
9838
9967
  if (!pool?.length)
9839
9968
  return;
9840
9969
  const workerIndex = stableIndex(seed, pool.length);
9841
- if (role === "worker" || pool.length === 1)
9970
+ if (pool.length === 1)
9842
9971
  return pool[workerIndex];
9843
- if (role === "verifier")
9844
- return pool[(workerIndex + 1) % pool.length];
9845
- if (role === "planner")
9846
- return pool[(workerIndex + 2) % pool.length];
9847
- return pool[(workerIndex + 3) % pool.length];
9972
+ return pool[(workerIndex + poolRoleOffset(role)) % pool.length];
9848
9973
  }
9849
9974
  function authProfileForRole(input, role, seed) {
9850
9975
  if (role === "triage" && input.triageAuthProfile)
@@ -10014,7 +10139,8 @@ function agentTarget(input, prompt, role, seed, plan) {
10014
10139
  allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
10015
10140
  routing: {
10016
10141
  projectPath: input.routeProjectPath ?? input.projectPath,
10017
- ...input.projectGroup ? { projectGroup: input.projectGroup } : {}
10142
+ ...input.projectGroup ? { projectGroup: input.projectGroup } : {},
10143
+ role
10018
10144
  },
10019
10145
  account: accountForRole(input, role, seed),
10020
10146
  timeoutMs: agentTimeoutMs(input),
@@ -11513,6 +11639,37 @@ function prReferenceFrom(text) {
11513
11639
  return { owner: short[1], repo: short[2], number: Number(short[3]) };
11514
11640
  return;
11515
11641
  }
11642
+ var PR_FINGERPRINT_FIELDS = [
11643
+ "pr_fingerprint",
11644
+ "prFingerprint",
11645
+ "github_pr",
11646
+ "githubPr",
11647
+ "github_pr_fingerprint",
11648
+ "githubPrFingerprint",
11649
+ "pull_request_fingerprint",
11650
+ "pullRequestFingerprint"
11651
+ ];
11652
+ function prFingerprint(ref) {
11653
+ return `${ref.owner.toLowerCase()}/${ref.repo.toLowerCase()}#${ref.number}`;
11654
+ }
11655
+ function prFingerprintFromField(value) {
11656
+ if (!value)
11657
+ return;
11658
+ const match = /([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)/.exec(value);
11659
+ return match ? prFingerprint({ owner: match[1], repo: match[2], number: Number(match[3]) }) : undefined;
11660
+ }
11661
+ function prFingerprintFromTask(data, metadata) {
11662
+ const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
11663
+ for (const field of PR_FINGERPRINT_FIELDS) {
11664
+ for (const value of routeFieldValues(records, field)) {
11665
+ const fingerprint = prFingerprintFromField(value);
11666
+ if (fingerprint)
11667
+ return fingerprint;
11668
+ }
11669
+ }
11670
+ const ref = prReferenceFrom(routeEvidenceText(records));
11671
+ return ref ? prFingerprint(ref) : undefined;
11672
+ }
11516
11673
  function ghAuthorResolver(ref) {
11517
11674
  const result = spawnSync7("gh", ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", "author", "-q", ".author.login"], { encoding: "utf8", timeout: 20000 });
11518
11675
  if (result.error || result.status !== 0)
@@ -11588,7 +11745,9 @@ function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorR
11588
11745
  allowed: false,
11589
11746
  reason: `PR is already ${prState.toLowerCase()}; skipping merge/review route (freshness gate)`,
11590
11747
  reviewers: [],
11591
- signals: [...signals, "pr-not-open"]
11748
+ signals: [...signals, "pr-not-open"],
11749
+ freshnessSkip: true,
11750
+ prState
11592
11751
  };
11593
11752
  }
11594
11753
  const reviewers = githubLogins([
@@ -11685,7 +11844,8 @@ function routeProjectGroup(optsGroup, data, metadata) {
11685
11844
  function routeThrottleDecision(store, args) {
11686
11845
  const projectPath = normalizeRoutePath(args.projectPath) ?? resolve6(args.projectPath);
11687
11846
  const projectGroup = args.projectGroup?.trim() || undefined;
11688
- const counts = store.countActiveWorkflowWorkItems({ projectKey: projectPath, projectGroup });
11847
+ const routeScope = args.routeScope?.trim() || undefined;
11848
+ const counts = store.countActiveWorkflowWorkItems({ projectKey: projectPath, projectGroup, routeScope });
11689
11849
  const base = {
11690
11850
  projectPath,
11691
11851
  ...projectGroup ? { projectGroup } : {},
@@ -11847,6 +12007,33 @@ async function readEventEnvelopeInput(opts = {}) {
11847
12007
  throw new ValidationError("event.source is required");
11848
12008
  return event;
11849
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
+ }
11850
12037
  function dedupedRoutePrint(plan, outcome) {
11851
12038
  return {
11852
12039
  kind: "deduped",
@@ -11876,6 +12063,7 @@ function routeEvent(plan) {
11876
12063
  subjectRef: plan.subjectRef,
11877
12064
  projectKey: plan.routeProjectPath,
11878
12065
  projectGroup: plan.projectGroup,
12066
+ routeScope: plan.routeScope,
11879
12067
  priority: 0,
11880
12068
  status: "queued"
11881
12069
  };
@@ -11915,6 +12103,7 @@ function routeEvent(plan) {
11915
12103
  const workflowPreflightSpec = workflowSpecForPreflight(workflowBody, "event-preflight");
11916
12104
  generatedRouteSandboxPreflight(workflowPreflightSpec);
11917
12105
  const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, plan.workflowContext, {}) : undefined;
12106
+ let poolAssignment;
11918
12107
  const outcome = store.writeTransaction(() => {
11919
12108
  const invocation = store.createWorkflowInvocation(plan.invocationInput);
11920
12109
  const existingItem = store.findWorkflowWorkItem(plan.routeKey, idempotencyKey);
@@ -11925,12 +12114,39 @@ function routeEvent(plan) {
11925
12114
  return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
11926
12115
  }
11927
12116
  }
11928
- 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;
11929
12145
  const workItem = store.upsertWorkflowWorkItem({
11930
12146
  ...workItemInput,
11931
12147
  invocationId: invocation.id,
11932
- status: throttle && !throttle.allowed ? "deferred" : "queued",
11933
- lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
12148
+ status: deferred ? "deferred" : "queued",
12149
+ lastReason: deferred ? deferReason : undefined
11934
12150
  });
11935
12151
  const requeue = workItem.attempts > 0 && workItem.status === "queued" ? {
11936
12152
  previousWorkItemId: workItem.id,
@@ -11938,8 +12154,16 @@ function routeEvent(plan) {
11938
12154
  reason: workItem.lastReason
11939
12155
  } : undefined;
11940
12156
  const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, plan.invocationInput);
11941
- if (throttle && !throttle.allowed)
11942
- 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
+ }
11943
12167
  const workflow = routeWorkflowForStorage(store, workflowBody);
11944
12168
  const loop = store.createLoop({
11945
12169
  ...loopInput,
@@ -11997,6 +12221,7 @@ function routeEvent(plan) {
11997
12221
  loop: publicLoop(outcome.loop),
11998
12222
  requeue: outcome.requeue,
11999
12223
  throttle: outcome.throttle,
12224
+ ...poolAssignment && !poolAssignment.deferred && Object.keys(poolAssignment.profiles).length ? { accountProfiles: poolAssignment.profiles, routeScope: plan.routeScope } : {},
12000
12225
  sandboxPreflight,
12001
12226
  preflight
12002
12227
  },
@@ -12045,7 +12270,8 @@ function routeTodosTaskEvent(event, opts) {
12045
12270
  const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
12046
12271
  const throttleLimits = routeThrottleLimitsFromOpts(opts);
12047
12272
  const sourceProjectIdempotencyPrefix = sourceTodosProjectPath ? normalizeRoutePath(sourceTodosProjectPath) ?? resolve7(sourceTodosProjectPath) : undefined;
12048
- const idempotencyKey = sourceProjectIdempotencyPrefix ? `todos-task:${sourceProjectIdempotencyPrefix}:${taskId}` : `todos-task:${taskId}`;
12273
+ const prFingerprint2 = prFingerprintFromTask(data, metadata);
12274
+ const idempotencyKey = prFingerprint2 ? `todos-task:pr:${prFingerprint2}` : sourceProjectIdempotencyPrefix ? `todos-task:${sourceProjectIdempotencyPrefix}:${taskId}` : `todos-task:${taskId}`;
12049
12275
  const idempotencySuffix = stableSuffix(idempotencyKey);
12050
12276
  const namePrefix = opts.namePrefix ?? "event:todos-task";
12051
12277
  const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
@@ -12077,7 +12303,8 @@ function routeTodosTaskEvent(event, opts) {
12077
12303
  event,
12078
12304
  taskId,
12079
12305
  routeError: true,
12080
- prReviewRouting
12306
+ prReviewRouting,
12307
+ ...prReviewRouting.freshnessSkip ? { freshnessSkip: true, prState: prReviewRouting.prState } : {}
12081
12308
  },
12082
12309
  human: `skipped task ${taskId}: ${prReviewRouting.reason}`
12083
12310
  };
@@ -12178,6 +12405,8 @@ function routeTodosTaskEvent(event, opts) {
12178
12405
  invocationInput,
12179
12406
  routeProjectPath,
12180
12407
  projectGroup,
12408
+ routeScope: resolveRouteScope(opts, "todos-task"),
12409
+ poolRouting: buildPoolRoutingPlan(opts, provider, providerRouting.authProfilePool, workflowBody, taskId),
12181
12410
  subjectRef: taskId,
12182
12411
  loopName,
12183
12412
  loopDescription: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
@@ -12291,6 +12520,8 @@ function routeGenericEvent(event, opts) {
12291
12520
  invocationInput,
12292
12521
  routeProjectPath,
12293
12522
  projectGroup,
12523
+ routeScope: resolveRouteScope(opts, "generic-event"),
12524
+ poolRouting: buildPoolRoutingPlan(opts, provider, providerRouting.authProfilePool, workflowBody, `${event.source}:${event.type}:${event.id}`),
12294
12525
  subjectRef: stringField(event.subject) ?? event.id,
12295
12526
  loopName,
12296
12527
  loopDescription: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
@@ -12494,6 +12725,8 @@ function compactDrainResult(result) {
12494
12725
  workflowId: stringField(workflow?.id),
12495
12726
  workflowName: stringField(workflow?.name),
12496
12727
  providerRouting,
12728
+ accountProfiles: objectField2(value.accountProfiles),
12729
+ routeScope: stringField(value.routeScope),
12497
12730
  requeue,
12498
12731
  queuedAtSource: value.queuedAtSource,
12499
12732
  fatal: value.fatal === true ? true : undefined
@@ -12638,6 +12871,31 @@ function markInvalidDrainTaskNonRouteable(sourceTodosProject, task, reason) {
12638
12871
  untagAutoRoute: todosMutationSummary(untagResult)
12639
12872
  };
12640
12873
  }
12874
+ function isFreshnessSkip(result) {
12875
+ return result.kind === "skipped" && result.value.freshnessSkip === true;
12876
+ }
12877
+ function closeFreshnessSkippedTask(sourceTodosProject, task, reason) {
12878
+ const taskId = taskField(task, ["id", "task_id", "taskId"]);
12879
+ if (!taskId)
12880
+ return { attempted: false, reason: "task id missing" };
12881
+ const comment = `OpenLoops freshness gate closed this task: ${reason}. The referenced PR is already merged/closed, so the ` + `merge/review route will not dispatch a worker. Marked done and removed auto:route/route:enabled so drains stop re-skipping it.`;
12882
+ const commentResult = runLocalCommand("todos", ["--project", sourceTodosProject, "comment", taskId, comment], { timeoutMs: 30000 });
12883
+ const doneResult = runLocalCommand("todos", ["--project", sourceTodosProject, "done", taskId, "--notes", "PR already merged/closed; closed by OpenLoops freshness gate"], { timeoutMs: 30000 });
12884
+ const untagAutoRoute = runLocalCommand("todos", ["--project", sourceTodosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
12885
+ const untagRouteEnabled = runLocalCommand("todos", ["--project", sourceTodosProject, "untag", taskId, "route:enabled"], { timeoutMs: 30000 });
12886
+ const leftQueue = doneResult.ok || untagAutoRoute.ok || untagRouteEnabled.ok;
12887
+ return {
12888
+ ok: leftQueue,
12889
+ attempted: true,
12890
+ taskId,
12891
+ action: "freshness-close",
12892
+ error: leftQueue ? undefined : "task could not be closed or untagged; inspect per-command results",
12893
+ comment: todosMutationSummary(commentResult),
12894
+ done: todosMutationSummary(doneResult),
12895
+ untagAutoRoute: todosMutationSummary(untagAutoRoute),
12896
+ untagRouteEnabled: todosMutationSummary(untagRouteEnabled)
12897
+ };
12898
+ }
12641
12899
  function drainTodosTaskRoutes(opts) {
12642
12900
  const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
12643
12901
  const todosProject = opts.todosProject ?? defaultLoopsProject();
@@ -12679,6 +12937,12 @@ function drainTodosTaskRoutes(opts) {
12679
12937
  result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { fatal: true });
12680
12938
  }
12681
12939
  }
12940
+ if (!opts.dryRun && isFreshnessSkip(result)) {
12941
+ const sourceTaskProject = opts.todosProjectsFromRegistry ? taskField(task, ["source_project_path", "sourceProjectPath"]) ?? todosProject : todosProject;
12942
+ const reason = stringField(result.value.reason) ?? "PR already merged/closed (freshness gate)";
12943
+ const sourceTaskUpdate = closeFreshnessSkippedTask(sourceTaskProject, task, reason);
12944
+ result = { ...result, value: { ...result.value, sourceTaskUpdate } };
12945
+ }
12682
12946
  results.push(result);
12683
12947
  if (result.kind === "created")
12684
12948
  created += 1;
@@ -12704,6 +12968,7 @@ function drainTodosTaskRoutes(opts) {
12704
12968
  deduped: results.filter((result) => result.kind === "deduped").length,
12705
12969
  throttled: results.filter((result) => result.kind === "throttled").length,
12706
12970
  skipped: results.filter((result) => result.kind === "skipped").length,
12971
+ freshnessClosed: results.filter((result) => isFreshnessSkip(result) && result.value.sourceTaskUpdate?.attempted === true).length,
12707
12972
  fatal: results.filter((result) => result.value.fatal === true).length,
12708
12973
  maxDispatch,
12709
12974
  source: "todos ready",
@@ -12732,6 +12997,7 @@ function drainTodosTaskRoutes(opts) {
12732
12997
  deduped: report.deduped,
12733
12998
  throttled: report.throttled,
12734
12999
  skipped: report.skipped,
13000
+ freshnessClosed: report.freshnessClosed,
12735
13001
  fatal: report.fatal,
12736
13002
  maxDispatch: report.maxDispatch,
12737
13003
  source: report.source,
@@ -12741,7 +13007,7 @@ function drainTodosTaskRoutes(opts) {
12741
13007
  } : { ...report, evidencePath };
12742
13008
  return {
12743
13009
  value,
12744
- human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped} fatal=${report.fatal}`
13010
+ human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped} freshnessClosed=${report.freshnessClosed} fatal=${report.fatal}`
12745
13011
  };
12746
13012
  }
12747
13013
  // src/lib/route/route-tasks.ts
@@ -13126,6 +13392,18 @@ var AGENT_ROUTING_OPTION_SPECS = [
13126
13392
  kind: "value",
13127
13393
  description: "skip creating a workflow when this many active routed workflows already exist for the project group"
13128
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
+ },
13129
13407
  { flags: "--worktree-mode <mode>", key: "worktreeMode", kind: "value", description: "worktree isolation mode: auto, required, off, or main", defaultValue: "auto" },
13130
13408
  { flags: "--worktree-root <path>", key: "worktreeRoot", kind: "value", description: "base directory for OpenLoops-managed git worktrees" },
13131
13409
  { flags: "--worktree-branch-prefix <prefix>", key: "worktreeBranchPrefix", kind: "value", description: "branch prefix for generated worktrees", defaultValue: "openloops" },