@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/CHANGELOG.md +93 -0
- package/README.md +19 -2
- package/dist/api/index.js +1 -1
- package/dist/cli/index.js +317 -39
- package/dist/daemon/daemon.d.ts +18 -0
- package/dist/daemon/index.js +84 -15
- package/dist/index.js +131 -21
- package/dist/lib/mode.js +1 -1
- package/dist/lib/route/pr-review.d.ts +16 -0
- package/dist/lib/route/profile-pool.d.ts +60 -0
- package/dist/lib/route/throttle.d.ts +1 -0
- package/dist/lib/route/types.d.ts +2 -0
- package/dist/lib/scheduler.d.ts +13 -0
- package/dist/lib/storage/index.js +40 -5
- package/dist/lib/storage/postgres-schema.js +4 -0
- package/dist/lib/storage/postgres.js +4 -0
- package/dist/lib/storage/sqlite.js +36 -5
- package/dist/lib/store.d.ts +11 -0
- package/dist/lib/store.js +36 -5
- package/dist/mcp/index.js +50 -6
- package/dist/runner/index.js +1 -1
- package/dist/sdk/index.js +50 -6
- package/dist/types.d.ts +10 -0
- package/package.json +1 -1
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
|
|
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:
|
|
3100
|
-
$accountTool:
|
|
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.
|
|
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
|
|
7652
|
-
const raw = process.env
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 (
|
|
9970
|
+
if (pool.length === 1)
|
|
9842
9971
|
return pool[workerIndex];
|
|
9843
|
-
|
|
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
|
|
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, {
|
|
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:
|
|
11933
|
-
lastReason:
|
|
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 (
|
|
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
|
|
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" },
|