@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/daemon/daemon.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { type SchedulerLane } from "../lib/scheduler.js";
|
|
1
2
|
import { Store } from "../lib/store.js";
|
|
2
3
|
import type { ExecutorResult, Loop, LoopRun } from "../types.js";
|
|
3
4
|
export interface RunDaemonOptions {
|
|
4
5
|
intervalMs?: number;
|
|
5
6
|
leaseTtlMs?: number;
|
|
7
|
+
/** Legacy single-pool knob; back-compat alias for the agent/workflow lane budget. */
|
|
6
8
|
concurrency?: number;
|
|
9
|
+
/** Claim budget for command-target loops (fast loops). Default 4. */
|
|
10
|
+
commandConcurrency?: number;
|
|
11
|
+
/** Claim budget for agent/workflow-target loops (long workers). Default 8; `concurrency`/LOOPS_DAEMON_CONCURRENCY still set it. */
|
|
12
|
+
agentConcurrency?: number;
|
|
7
13
|
store?: Store;
|
|
8
14
|
pidPath?: string;
|
|
9
15
|
execute?: (loop: Loop, run: LoopRun) => Promise<ExecutorResult>;
|
|
@@ -13,6 +19,18 @@ export interface RunDaemonOptions {
|
|
|
13
19
|
signal?: AbortSignal;
|
|
14
20
|
reapGraceMs?: number;
|
|
15
21
|
}
|
|
22
|
+
export declare const DEFAULT_COMMAND_CONCURRENCY = 4;
|
|
23
|
+
export declare const DEFAULT_AGENT_CONCURRENCY = 8;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the two separated concurrency lanes. Command loops (fast) and
|
|
26
|
+
* agent/workflow loops (long workers) get independent budgets so a saturated
|
|
27
|
+
* agent lane cannot starve command loops. Precedence per lane is explicit opt >
|
|
28
|
+
* lane env > legacy knob > default. The legacy `concurrency` opt and
|
|
29
|
+
* `LOOPS_DAEMON_CONCURRENCY` env stay wired to the agent lane (the dominant
|
|
30
|
+
* consumer / historical "total" knob), so existing deployments keep their
|
|
31
|
+
* heavy-lane capacity and simply gain a reserved command lane on top.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveLaneConcurrency(opts?: Pick<RunDaemonOptions, "concurrency" | "commandConcurrency" | "agentConcurrency">): Record<SchedulerLane, number>;
|
|
16
34
|
export declare const DAEMON_LOG_MAX_BYTES: number;
|
|
17
35
|
export declare const DAEMON_LOG_KEEP = 2;
|
|
18
36
|
export declare function daemonLogLine(message: string): string;
|
package/dist/daemon/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
|
});
|
|
@@ -6705,6 +6736,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
6705
6736
|
}
|
|
6706
6737
|
|
|
6707
6738
|
// src/lib/scheduler.ts
|
|
6739
|
+
function loopLane(loop) {
|
|
6740
|
+
return loop.target.type === "command" ? "command" : "agent";
|
|
6741
|
+
}
|
|
6708
6742
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
6709
6743
|
if (loop.archivedAt)
|
|
6710
6744
|
return now.toISOString();
|
|
@@ -7097,14 +7131,23 @@ function claimDueRuns(deps) {
|
|
|
7097
7131
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
7098
7132
|
if (maxClaims === 0)
|
|
7099
7133
|
return { claims, claimed, completed: [], skipped, recovered, expired };
|
|
7134
|
+
const laneLimits = deps.laneLimits;
|
|
7135
|
+
const laneClaims = { command: 0, agent: 0 };
|
|
7136
|
+
const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
|
|
7137
|
+
const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
|
|
7100
7138
|
for (const loop of deps.store.dueLoops(now)) {
|
|
7101
7139
|
if (claims.length >= maxClaims)
|
|
7102
7140
|
break;
|
|
7141
|
+
const lane = loopLane(loop);
|
|
7142
|
+
if (laneFull(lane))
|
|
7143
|
+
continue;
|
|
7103
7144
|
const plan = dueSlots(loop, now);
|
|
7104
7145
|
let loopSkips = 0;
|
|
7105
7146
|
for (const slot of plan.slots) {
|
|
7106
7147
|
if (claims.length >= maxClaims)
|
|
7107
7148
|
break;
|
|
7149
|
+
if (laneFull(lane))
|
|
7150
|
+
break;
|
|
7108
7151
|
if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
|
|
7109
7152
|
break;
|
|
7110
7153
|
const run = claimSlot(deps, loop, slot);
|
|
@@ -7113,6 +7156,7 @@ function claimDueRuns(deps) {
|
|
|
7113
7156
|
if ("loop" in run) {
|
|
7114
7157
|
claims.push(run);
|
|
7115
7158
|
claimed.push(run.run);
|
|
7159
|
+
laneClaims[lane] += 1;
|
|
7116
7160
|
} else if (run.status === "skipped") {
|
|
7117
7161
|
skipped.push(run);
|
|
7118
7162
|
loopSkips += 1;
|
|
@@ -7381,13 +7425,23 @@ function intervalFromEnv() {
|
|
|
7381
7425
|
const value = Number(raw);
|
|
7382
7426
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
7383
7427
|
}
|
|
7384
|
-
function
|
|
7385
|
-
const raw = process.env
|
|
7428
|
+
function positiveIntEnv(name) {
|
|
7429
|
+
const raw = process.env[name];
|
|
7386
7430
|
if (!raw)
|
|
7387
7431
|
return;
|
|
7388
7432
|
const value = Number(raw);
|
|
7389
7433
|
return Number.isInteger(value) && value > 0 ? value : undefined;
|
|
7390
7434
|
}
|
|
7435
|
+
function concurrencyFromEnv() {
|
|
7436
|
+
return positiveIntEnv("LOOPS_DAEMON_CONCURRENCY");
|
|
7437
|
+
}
|
|
7438
|
+
var DEFAULT_COMMAND_CONCURRENCY = 4;
|
|
7439
|
+
var DEFAULT_AGENT_CONCURRENCY = 8;
|
|
7440
|
+
function resolveLaneConcurrency(opts = {}) {
|
|
7441
|
+
const command = Math.max(1, opts.commandConcurrency ?? positiveIntEnv("LOOPS_DAEMON_COMMAND_CONCURRENCY") ?? DEFAULT_COMMAND_CONCURRENCY);
|
|
7442
|
+
const agent = Math.max(1, opts.agentConcurrency ?? opts.concurrency ?? positiveIntEnv("LOOPS_DAEMON_AGENT_CONCURRENCY") ?? concurrencyFromEnv() ?? DEFAULT_AGENT_CONCURRENCY);
|
|
7443
|
+
return { command, agent };
|
|
7444
|
+
}
|
|
7391
7445
|
var DAEMON_LOG_MAX_BYTES = 50 * 1024 * 1024;
|
|
7392
7446
|
var DAEMON_LOG_KEEP = 2;
|
|
7393
7447
|
function daemonLogLine(message) {
|
|
@@ -7433,7 +7487,7 @@ async function runDaemon(opts = {}) {
|
|
|
7433
7487
|
const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
|
|
7434
7488
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
7435
7489
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
7436
|
-
const
|
|
7490
|
+
const laneConcurrency = resolveLaneConcurrency(opts);
|
|
7437
7491
|
const log = opts.log ?? defaultDaemonLog;
|
|
7438
7492
|
const sleep = opts.sleep ?? realSleep;
|
|
7439
7493
|
const lease = store.acquireDaemonLease({
|
|
@@ -7445,12 +7499,13 @@ async function runDaemon(opts = {}) {
|
|
|
7445
7499
|
if (!lease)
|
|
7446
7500
|
throw new Error("another loops daemon holds the database lease");
|
|
7447
7501
|
writePid(process.pid, pidPath);
|
|
7448
|
-
log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
|
|
7502
|
+
log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId} ` + `command_concurrency=${laneConcurrency.command} agent_concurrency=${laneConcurrency.agent}`);
|
|
7449
7503
|
let stopFlag = false;
|
|
7450
7504
|
let leaseLost = false;
|
|
7451
7505
|
let leaseEpoch = 0;
|
|
7452
7506
|
let runAbort = new AbortController;
|
|
7453
7507
|
const activeRuns = new Map;
|
|
7508
|
+
const activeByLane = { command: 0, agent: 0 };
|
|
7454
7509
|
const requestStop = (message) => {
|
|
7455
7510
|
stopFlag = true;
|
|
7456
7511
|
if (!runAbort.signal.aborted)
|
|
@@ -7543,7 +7598,12 @@ async function runDaemon(opts = {}) {
|
|
|
7543
7598
|
log(`run ${finalRun.id} ${finalRun.status} loop=${claim.loop.id}`);
|
|
7544
7599
|
};
|
|
7545
7600
|
const startClaim = (claim) => {
|
|
7546
|
-
const
|
|
7601
|
+
const lane = loopLane(claim.loop);
|
|
7602
|
+
activeByLane[lane] += 1;
|
|
7603
|
+
const task = executeDaemonRun(claim).catch((err) => log(`run ${claim.run.id} error: ${err instanceof Error ? err.message : String(err)}`)).finally(() => {
|
|
7604
|
+
activeRuns.delete(claim.run.id);
|
|
7605
|
+
activeByLane[lane] -= 1;
|
|
7606
|
+
});
|
|
7547
7607
|
activeRuns.set(claim.run.id, task);
|
|
7548
7608
|
};
|
|
7549
7609
|
try {
|
|
@@ -7579,19 +7639,23 @@ async function runDaemon(opts = {}) {
|
|
|
7579
7639
|
onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
|
|
7580
7640
|
tickFn: async () => {
|
|
7581
7641
|
ensureLease();
|
|
7582
|
-
const
|
|
7642
|
+
const laneLimits = {
|
|
7643
|
+
command: Math.max(0, laneConcurrency.command - activeByLane.command),
|
|
7644
|
+
agent: Math.max(0, laneConcurrency.agent - activeByLane.agent)
|
|
7645
|
+
};
|
|
7583
7646
|
const result = claimDueRuns({
|
|
7584
7647
|
store,
|
|
7585
7648
|
runnerId,
|
|
7586
7649
|
daemonLeaseId: leaseId,
|
|
7587
7650
|
beforeRun: () => ensureLease(),
|
|
7588
|
-
maxClaims:
|
|
7651
|
+
maxClaims: laneLimits.command + laneLimits.agent,
|
|
7652
|
+
laneLimits
|
|
7589
7653
|
});
|
|
7590
7654
|
for (const claim of result.claims)
|
|
7591
7655
|
startClaim(claim);
|
|
7592
7656
|
const changed = result.claims.length + result.skipped.length + result.recovered.length + result.expired.length;
|
|
7593
7657
|
if (changed > 0) {
|
|
7594
|
-
log(`tick claimed=${result.claims.length} active=${activeRuns.size} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
|
|
7658
|
+
log(`tick claimed=${result.claims.length} active=${activeRuns.size} (command=${activeByLane.command} agent=${activeByLane.agent}) skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
|
|
7595
7659
|
}
|
|
7596
7660
|
await reapAbandoned(result.recovered);
|
|
7597
7661
|
}
|
|
@@ -7752,7 +7816,7 @@ function enableStartup(result) {
|
|
|
7752
7816
|
// package.json
|
|
7753
7817
|
var package_default = {
|
|
7754
7818
|
name: "@hasna/loops",
|
|
7755
|
-
version: "0.4.
|
|
7819
|
+
version: "0.4.12",
|
|
7756
7820
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7757
7821
|
type: "module",
|
|
7758
7822
|
main: "dist/index.js",
|
|
@@ -7883,7 +7947,12 @@ function packageVersion() {
|
|
|
7883
7947
|
// src/daemon/index.ts
|
|
7884
7948
|
var program = new Command;
|
|
7885
7949
|
program.name("loops-daemon").description("OpenLoops daemon helper").version(packageVersion());
|
|
7886
|
-
program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).option("--concurrency <n>", "
|
|
7950
|
+
program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).option("--concurrency <n>", "legacy total knob; sets the agent/workflow lane budget", (value) => Number(value)).option("--command-concurrency <n>", "claim budget for command-target loops (default 4)", (value) => Number(value)).option("--agent-concurrency <n>", "claim budget for agent/workflow-target loops (default 8)", (value) => Number(value)).action(async (opts) => runDaemon({
|
|
7951
|
+
intervalMs: opts.intervalMs,
|
|
7952
|
+
concurrency: opts.concurrency,
|
|
7953
|
+
commandConcurrency: opts.commandConcurrency,
|
|
7954
|
+
agentConcurrency: opts.agentConcurrency
|
|
7955
|
+
}));
|
|
7887
7956
|
program.command("start").action(async () => {
|
|
7888
7957
|
const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
|
|
7889
7958
|
console.log(JSON.stringify(result, null, 2));
|
package/dist/index.js
CHANGED
|
@@ -1321,6 +1321,7 @@ function rowToWorkflowWorkItem(row) {
|
|
|
1321
1321
|
subjectRef: row.subject_ref,
|
|
1322
1322
|
projectKey: row.project_key ?? undefined,
|
|
1323
1323
|
projectGroup: row.project_group ?? undefined,
|
|
1324
|
+
routeScope: row.route_scope ?? undefined,
|
|
1324
1325
|
priority: row.priority,
|
|
1325
1326
|
status: row.status,
|
|
1326
1327
|
attempts: row.attempts,
|
|
@@ -1601,6 +1602,13 @@ class Store {
|
|
|
1601
1602
|
this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
|
|
1602
1603
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
|
|
1603
1604
|
}
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
id: "0008_work_item_route_scope",
|
|
1608
|
+
apply: () => {
|
|
1609
|
+
this.addColumnIfMissing("workflow_work_items", "route_scope", "TEXT");
|
|
1610
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status)");
|
|
1611
|
+
}
|
|
1604
1612
|
}
|
|
1605
1613
|
];
|
|
1606
1614
|
}
|
|
@@ -1748,6 +1756,7 @@ class Store {
|
|
|
1748
1756
|
subject_ref TEXT NOT NULL,
|
|
1749
1757
|
project_key TEXT,
|
|
1750
1758
|
project_group TEXT,
|
|
1759
|
+
route_scope TEXT,
|
|
1751
1760
|
priority INTEGER NOT NULL,
|
|
1752
1761
|
status TEXT NOT NULL,
|
|
1753
1762
|
attempts INTEGER NOT NULL,
|
|
@@ -1765,6 +1774,13 @@ class Store {
|
|
|
1765
1774
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
|
|
1766
1775
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
|
|
1767
1776
|
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
|
|
1777
|
+
-- idx_workflow_work_items_scope (route_scope, status) is created ONLY by
|
|
1778
|
+
-- migration 0008_work_item_route_scope, never here: this baseline DDL
|
|
1779
|
+
-- re-runs on EVERY open (0001 is not skip-guarded), and on a pre-0008
|
|
1780
|
+
-- database the CREATE TABLE above is a no-op, so an index on route_scope
|
|
1781
|
+
-- here would execute before the column exists and crash the open
|
|
1782
|
+
-- ("no such column: route_scope"). New columns may be folded into the
|
|
1783
|
+
-- CREATE TABLE (fresh-db only); their indexes must live in the migration.
|
|
1768
1784
|
|
|
1769
1785
|
CREATE TABLE IF NOT EXISTS workflow_step_runs (
|
|
1770
1786
|
id TEXT PRIMARY KEY,
|
|
@@ -2556,10 +2572,10 @@ class Store {
|
|
|
2556
2572
|
const id = genId();
|
|
2557
2573
|
const status = input.status ?? "queued";
|
|
2558
2574
|
this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
|
|
2559
|
-
subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
2575
|
+
subject_ref, project_key, project_group, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
2560
2576
|
workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
|
|
2561
2577
|
VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
|
|
2562
|
-
$projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
2578
|
+
$projectKey, $projectGroup, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
2563
2579
|
$lastReason, $created, $updated)
|
|
2564
2580
|
ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
|
|
2565
2581
|
invocation_id=excluded.invocation_id,
|
|
@@ -2568,6 +2584,7 @@ class Store {
|
|
|
2568
2584
|
subject_ref=excluded.subject_ref,
|
|
2569
2585
|
project_key=excluded.project_key,
|
|
2570
2586
|
project_group=excluded.project_group,
|
|
2587
|
+
route_scope=excluded.route_scope,
|
|
2571
2588
|
priority=excluded.priority,
|
|
2572
2589
|
status=CASE
|
|
2573
2590
|
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
|
|
@@ -2609,6 +2626,7 @@ class Store {
|
|
|
2609
2626
|
$subjectRef: input.subjectRef,
|
|
2610
2627
|
$projectKey: input.projectKey ?? null,
|
|
2611
2628
|
$projectGroup: input.projectGroup ?? null,
|
|
2629
|
+
$routeScope: input.routeScope ?? null,
|
|
2612
2630
|
$priority: input.priority ?? 0,
|
|
2613
2631
|
$status: status,
|
|
2614
2632
|
$nextAttemptAt: input.nextAttemptAt ?? null,
|
|
@@ -2646,11 +2664,21 @@ class Store {
|
|
|
2646
2664
|
countActiveWorkflowWorkItems(args = {}) {
|
|
2647
2665
|
const active = ["admitted", "running"];
|
|
2648
2666
|
const placeholders = active.map(() => "?").join(",");
|
|
2649
|
-
const
|
|
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:
|
|
3098
|
-
$accountTool:
|
|
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.
|
|
4269
|
+
version: "0.4.12",
|
|
4239
4270
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4240
4271
|
type: "module",
|
|
4241
4272
|
main: "dist/index.js",
|
|
@@ -7286,6 +7317,9 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
7286
7317
|
}
|
|
7287
7318
|
|
|
7288
7319
|
// src/lib/scheduler.ts
|
|
7320
|
+
function loopLane(loop) {
|
|
7321
|
+
return loop.target.type === "command" ? "command" : "agent";
|
|
7322
|
+
}
|
|
7289
7323
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
7290
7324
|
if (loop.archivedAt)
|
|
7291
7325
|
return now.toISOString();
|
|
@@ -7678,14 +7712,23 @@ function claimDueRuns(deps) {
|
|
|
7678
7712
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
7679
7713
|
if (maxClaims === 0)
|
|
7680
7714
|
return { claims, claimed, completed: [], skipped, recovered, expired };
|
|
7715
|
+
const laneLimits = deps.laneLimits;
|
|
7716
|
+
const laneClaims = { command: 0, agent: 0 };
|
|
7717
|
+
const laneCap = (lane) => laneLimits === undefined ? Number.POSITIVE_INFINITY : Math.max(0, laneLimits[lane] ?? Number.POSITIVE_INFINITY);
|
|
7718
|
+
const laneFull = (lane) => laneClaims[lane] >= laneCap(lane);
|
|
7681
7719
|
for (const loop of deps.store.dueLoops(now)) {
|
|
7682
7720
|
if (claims.length >= maxClaims)
|
|
7683
7721
|
break;
|
|
7722
|
+
const lane = loopLane(loop);
|
|
7723
|
+
if (laneFull(lane))
|
|
7724
|
+
continue;
|
|
7684
7725
|
const plan = dueSlots(loop, now);
|
|
7685
7726
|
let loopSkips = 0;
|
|
7686
7727
|
for (const slot of plan.slots) {
|
|
7687
7728
|
if (claims.length >= maxClaims)
|
|
7688
7729
|
break;
|
|
7730
|
+
if (laneFull(lane))
|
|
7731
|
+
break;
|
|
7689
7732
|
if (loopSkips >= MAX_SKIPS_PER_LOOP_PER_TICK)
|
|
7690
7733
|
break;
|
|
7691
7734
|
const run = claimSlot(deps, loop, slot);
|
|
@@ -7694,6 +7737,7 @@ function claimDueRuns(deps) {
|
|
|
7694
7737
|
if ("loop" in run) {
|
|
7695
7738
|
claims.push(run);
|
|
7696
7739
|
claimed.push(run.run);
|
|
7740
|
+
laneClaims[lane] += 1;
|
|
7697
7741
|
} else if (run.status === "skipped") {
|
|
7698
7742
|
skipped.push(run);
|
|
7699
7743
|
loopSkips += 1;
|
|
@@ -9533,6 +9577,10 @@ CREATE TABLE IF NOT EXISTS audit_events (
|
|
|
9533
9577
|
);
|
|
9534
9578
|
CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events(subject_type, subject_id, created_at DESC);
|
|
9535
9579
|
CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action, created_at DESC);
|
|
9580
|
+
`),
|
|
9581
|
+
migration("0004_work_item_route_scope", `
|
|
9582
|
+
ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS route_scope TEXT;
|
|
9583
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status);
|
|
9536
9584
|
`)
|
|
9537
9585
|
]);
|
|
9538
9586
|
|
|
@@ -10187,6 +10235,79 @@ function prHandoffCommand(opts) {
|
|
|
10187
10235
|
].join(`
|
|
10188
10236
|
`);
|
|
10189
10237
|
}
|
|
10238
|
+
|
|
10239
|
+
// src/lib/route/profile-pool.ts
|
|
10240
|
+
function stableIndex(seed, size) {
|
|
10241
|
+
let hash = 2166136261;
|
|
10242
|
+
for (let i = 0;i < seed.length; i += 1) {
|
|
10243
|
+
hash ^= seed.charCodeAt(i);
|
|
10244
|
+
hash = Math.imul(hash, 16777619);
|
|
10245
|
+
}
|
|
10246
|
+
return Math.abs(hash >>> 0) % size;
|
|
10247
|
+
}
|
|
10248
|
+
function poolRoleOffset(role) {
|
|
10249
|
+
if (role === "worker")
|
|
10250
|
+
return 0;
|
|
10251
|
+
if (role === "verifier")
|
|
10252
|
+
return 1;
|
|
10253
|
+
if (role === "planner")
|
|
10254
|
+
return 2;
|
|
10255
|
+
return 3;
|
|
10256
|
+
}
|
|
10257
|
+
function selectLeastLoadedProfile(pool, loadCounts, anchor, exclude) {
|
|
10258
|
+
const size = pool.length;
|
|
10259
|
+
let best;
|
|
10260
|
+
let bestExcluded = true;
|
|
10261
|
+
let bestLoad = Number.POSITIVE_INFINITY;
|
|
10262
|
+
for (let k = 0;k < size; k += 1) {
|
|
10263
|
+
const profile = pool[(anchor + k) % size];
|
|
10264
|
+
const excluded = exclude.has(profile);
|
|
10265
|
+
const load = loadCounts[profile] ?? 0;
|
|
10266
|
+
if (best === undefined) {
|
|
10267
|
+
best = profile;
|
|
10268
|
+
bestExcluded = excluded;
|
|
10269
|
+
bestLoad = load;
|
|
10270
|
+
continue;
|
|
10271
|
+
}
|
|
10272
|
+
const better = excluded === bestExcluded ? load < bestLoad : !excluded && bestExcluded;
|
|
10273
|
+
if (better) {
|
|
10274
|
+
best = profile;
|
|
10275
|
+
bestExcluded = excluded;
|
|
10276
|
+
bestLoad = load;
|
|
10277
|
+
}
|
|
10278
|
+
}
|
|
10279
|
+
return best;
|
|
10280
|
+
}
|
|
10281
|
+
var ROLE_PRIORITY = ["worker", "verifier", "planner", "triage"];
|
|
10282
|
+
function assignPoolAuthProfiles(input) {
|
|
10283
|
+
const pool = input.pool.filter((entry) => entry.trim().length > 0);
|
|
10284
|
+
if (pool.length === 0)
|
|
10285
|
+
return { profiles: {}, deferred: false, minLoad: 0 };
|
|
10286
|
+
const minLoad = pool.reduce((min, profile) => Math.min(min, input.loadCounts[profile] ?? 0), Number.POSITIVE_INFINITY);
|
|
10287
|
+
const guard = input.maxPerProfile !== undefined && input.maxPerProfile > 0;
|
|
10288
|
+
if (guard && minLoad >= input.maxPerProfile) {
|
|
10289
|
+
return {
|
|
10290
|
+
profiles: {},
|
|
10291
|
+
deferred: true,
|
|
10292
|
+
minLoad,
|
|
10293
|
+
reason: `per-profile active limit reached (all ${pool.length} pool members >= ${input.maxPerProfile} running; min ${minLoad})`
|
|
10294
|
+
};
|
|
10295
|
+
}
|
|
10296
|
+
const workerIndex = stableIndex(input.seed, pool.length);
|
|
10297
|
+
const rolesToAssign = ROLE_PRIORITY.filter((role) => input.roles.includes(role));
|
|
10298
|
+
for (const role of input.roles)
|
|
10299
|
+
if (!rolesToAssign.includes(role))
|
|
10300
|
+
rolesToAssign.push(role);
|
|
10301
|
+
const profiles = {};
|
|
10302
|
+
const taken = new Set;
|
|
10303
|
+
for (const role of rolesToAssign) {
|
|
10304
|
+
const anchor = (workerIndex + poolRoleOffset(role)) % pool.length;
|
|
10305
|
+
const profile = selectLeastLoadedProfile(pool, input.loadCounts, anchor, taken);
|
|
10306
|
+
profiles[role] = profile;
|
|
10307
|
+
taken.add(profile);
|
|
10308
|
+
}
|
|
10309
|
+
return { profiles, deferred: false, minLoad: Number.isFinite(minLoad) ? minLoad : 0 };
|
|
10310
|
+
}
|
|
10190
10311
|
// src/lib/templates-custom.ts
|
|
10191
10312
|
import { existsSync as existsSync5, lstatSync as lstatSync2, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
10192
10313
|
import { join as join6, resolve as resolve3 } from "path";
|
|
@@ -10582,25 +10703,13 @@ function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
|
|
|
10582
10703
|
throw new Error(`${label} must be a positive integer number of milliseconds`);
|
|
10583
10704
|
return value;
|
|
10584
10705
|
}
|
|
10585
|
-
function stableIndex(seed, size) {
|
|
10586
|
-
let hash = 2166136261;
|
|
10587
|
-
for (let i = 0;i < seed.length; i += 1) {
|
|
10588
|
-
hash ^= seed.charCodeAt(i);
|
|
10589
|
-
hash = Math.imul(hash, 16777619);
|
|
10590
|
-
}
|
|
10591
|
-
return Math.abs(hash >>> 0) % size;
|
|
10592
|
-
}
|
|
10593
10706
|
function rolePoolValue(pool, seed, role) {
|
|
10594
10707
|
if (!pool?.length)
|
|
10595
10708
|
return;
|
|
10596
10709
|
const workerIndex = stableIndex(seed, pool.length);
|
|
10597
|
-
if (
|
|
10710
|
+
if (pool.length === 1)
|
|
10598
10711
|
return pool[workerIndex];
|
|
10599
|
-
|
|
10600
|
-
return pool[(workerIndex + 1) % pool.length];
|
|
10601
|
-
if (role === "planner")
|
|
10602
|
-
return pool[(workerIndex + 2) % pool.length];
|
|
10603
|
-
return pool[(workerIndex + 3) % pool.length];
|
|
10712
|
+
return pool[(workerIndex + poolRoleOffset(role)) % pool.length];
|
|
10604
10713
|
}
|
|
10605
10714
|
function authProfileForRole(input, role, seed) {
|
|
10606
10715
|
if (role === "triage" && input.triageAuthProfile)
|
|
@@ -10770,7 +10879,8 @@ function agentTarget(input, prompt, role, seed, plan) {
|
|
|
10770
10879
|
allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
|
|
10771
10880
|
routing: {
|
|
10772
10881
|
projectPath: input.routeProjectPath ?? input.projectPath,
|
|
10773
|
-
...input.projectGroup ? { projectGroup: input.projectGroup } : {}
|
|
10882
|
+
...input.projectGroup ? { projectGroup: input.projectGroup } : {},
|
|
10883
|
+
role
|
|
10774
10884
|
},
|
|
10775
10885
|
account: accountForRole(input, role, seed),
|
|
10776
10886
|
timeoutMs: agentTimeoutMs(input),
|
package/dist/lib/mode.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// package.json
|
|
3
3
|
var package_default = {
|
|
4
4
|
name: "@hasna/loops",
|
|
5
|
-
version: "0.4.
|
|
5
|
+
version: "0.4.12",
|
|
6
6
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7
7
|
type: "module",
|
|
8
8
|
main: "dist/index.js",
|
|
@@ -7,6 +7,10 @@ export interface PrReviewRoutingDecision {
|
|
|
7
7
|
reviewers: string[];
|
|
8
8
|
selectedReviewer?: string;
|
|
9
9
|
signals: string[];
|
|
10
|
+
/** Set when the route was skipped because the PR is definitively MERGED/CLOSED. */
|
|
11
|
+
freshnessSkip?: boolean;
|
|
12
|
+
/** Resolved PR lifecycle state (e.g. MERGED/CLOSED) when the freshness gate fired. */
|
|
13
|
+
prState?: string;
|
|
10
14
|
}
|
|
11
15
|
/** A GitHub PR reference resolvable to `owner/repo#number`. */
|
|
12
16
|
export interface PrReference {
|
|
@@ -25,4 +29,16 @@ export interface PrLiveState {
|
|
|
25
29
|
export type PrStateResolver = (ref: PrReference) => PrLiveState | undefined;
|
|
26
30
|
/** Extracts a concrete owner/repo/number PR reference from route evidence text. */
|
|
27
31
|
export declare function prReferenceFrom(text: string): PrReference | undefined;
|
|
32
|
+
/** Canonical `owner/repo#number`, lowercased for case-stable dedupe. */
|
|
33
|
+
export declare function prFingerprint(ref: PrReference): string;
|
|
34
|
+
/**
|
|
35
|
+
* Stable GitHub `owner/repo#number` fingerprint for a PR-subject task. The repos
|
|
36
|
+
* registry maps several local checkouts to one GitHub repo, so a single PR is
|
|
37
|
+
* minted as 2-3 duplicate todos tasks (distinct ids + distinct checkout paths).
|
|
38
|
+
* Keying dedupe on this fingerprint collapses them to one work item instead of
|
|
39
|
+
* burning a full worker cycle per checkout. Prefers an explicit PR fingerprint
|
|
40
|
+
* field, then a canonical PR URL / `github-pr:` handle in route evidence;
|
|
41
|
+
* returns undefined for tasks with no concrete PR reference (keep path/id keying).
|
|
42
|
+
*/
|
|
43
|
+
export declare function prFingerprintFromTask(data: Record<string, unknown>, metadata: Record<string, unknown>): string | undefined;
|
|
28
44
|
export declare function prReviewRoutingDecision(data: Record<string, unknown>, metadata: Record<string, unknown>, opts: TodosTaskRouteOptions, resolveAuthor?: PrAuthorResolver, resolveState?: PrStateResolver): PrReviewRoutingDecision;
|