@hasna/loops 0.4.11 → 0.4.13
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 +80 -0
- package/README.md +35 -7
- package/dist/api/index.js +56 -1
- package/dist/cli/index.js +324 -31
- package/dist/daemon/index.js +37 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +238 -25
- package/dist/lib/mode.d.ts +27 -0
- package/dist/lib/mode.js +56 -1
- 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/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 +373 -135
- package/dist/runner/index.js +56 -1
- package/dist/sdk/index.js +107 -6
- package/dist/types.d.ts +10 -0
- package/docs/DEPLOYMENT_MODES.md +23 -1
- package/docs/USAGE.md +16 -5
- 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.13",
|
|
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",
|
|
@@ -4428,6 +4459,50 @@ function sourceOfTruthForMode(mode) {
|
|
|
4428
4459
|
return "self_hosted_control_plane";
|
|
4429
4460
|
return "cloud_control_plane";
|
|
4430
4461
|
}
|
|
4462
|
+
var ROUTE_ADMISSION_GATES = [
|
|
4463
|
+
"max_dispatch",
|
|
4464
|
+
"max_active",
|
|
4465
|
+
"max_active_per_project",
|
|
4466
|
+
"max_active_per_project_group",
|
|
4467
|
+
"max_active_scope",
|
|
4468
|
+
"max_per_profile"
|
|
4469
|
+
];
|
|
4470
|
+
function remoteSchedulerBackendForMode(mode, config) {
|
|
4471
|
+
if (mode === "local")
|
|
4472
|
+
return "none";
|
|
4473
|
+
if (mode === "cloud")
|
|
4474
|
+
return config.cloudApiUrl ? "hosted_control_plane_contract" : "unconfigured";
|
|
4475
|
+
if (config.databaseUrlPresent)
|
|
4476
|
+
return "postgres_contract";
|
|
4477
|
+
if (config.apiUrl)
|
|
4478
|
+
return "api_control_plane_contract";
|
|
4479
|
+
return "unconfigured";
|
|
4480
|
+
}
|
|
4481
|
+
function schedulerStateForMode(args) {
|
|
4482
|
+
const nonLocal = args.deploymentMode !== "local";
|
|
4483
|
+
return {
|
|
4484
|
+
authority: args.sourceOfTruth,
|
|
4485
|
+
localStore: {
|
|
4486
|
+
backend: "sqlite",
|
|
4487
|
+
role: args.localRole,
|
|
4488
|
+
runArtifacts: "local_files",
|
|
4489
|
+
routeAdmissionState: "workflow_work_items"
|
|
4490
|
+
},
|
|
4491
|
+
remoteStore: {
|
|
4492
|
+
backend: remoteSchedulerBackendForMode(args.deploymentMode, args.config),
|
|
4493
|
+
configured: nonLocal && args.controlPlaneConfigured,
|
|
4494
|
+
applySupported: false,
|
|
4495
|
+
objectArtifacts: nonLocal ? "object_store_contract" : "none",
|
|
4496
|
+
mutatesAws: false
|
|
4497
|
+
},
|
|
4498
|
+
routeAdmission: {
|
|
4499
|
+
stateStore: nonLocal ? "control_plane_contract" : "local_sqlite",
|
|
4500
|
+
activeStatuses: ["admitted", "running"],
|
|
4501
|
+
gates: ROUTE_ADMISSION_GATES,
|
|
4502
|
+
dryRunEvaluatesLiveCounts: false
|
|
4503
|
+
}
|
|
4504
|
+
};
|
|
4505
|
+
}
|
|
4431
4506
|
function displayControlPlaneUrl(value) {
|
|
4432
4507
|
if (!value)
|
|
4433
4508
|
return;
|
|
@@ -4453,6 +4528,9 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
4453
4528
|
if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
|
|
4454
4529
|
warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
|
|
4455
4530
|
}
|
|
4531
|
+
if (deploymentMode === "self_hosted" && config.databaseUrlPresent && !config.apiUrl) {
|
|
4532
|
+
warnings.push("LOOPS_DATABASE_URL selects the self_hosted storage contract; loops-runner still needs LOOPS_API_URL to claim remote work");
|
|
4533
|
+
}
|
|
4456
4534
|
if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
|
|
4457
4535
|
warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
|
|
4458
4536
|
}
|
|
@@ -4486,6 +4564,13 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
4486
4564
|
required: deploymentMode !== "local",
|
|
4487
4565
|
role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
|
|
4488
4566
|
},
|
|
4567
|
+
schedulerState: schedulerStateForMode({
|
|
4568
|
+
deploymentMode,
|
|
4569
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
4570
|
+
localRole: deploymentMode === "local" ? "authoritative" : "cache_and_spool",
|
|
4571
|
+
controlPlaneConfigured,
|
|
4572
|
+
config
|
|
4573
|
+
}),
|
|
4489
4574
|
warnings
|
|
4490
4575
|
};
|
|
4491
4576
|
}
|
|
@@ -4498,6 +4583,7 @@ function deploymentStatusLine(status) {
|
|
|
4498
4583
|
`source=${status.deploymentModeSource}`,
|
|
4499
4584
|
`truth=${status.sourceOfTruth}`,
|
|
4500
4585
|
`local=${status.localStore.role}`,
|
|
4586
|
+
`scheduler=${status.schedulerState.routeAdmission.stateStore}`,
|
|
4501
4587
|
`control_plane=${configured}`
|
|
4502
4588
|
].join(" ");
|
|
4503
4589
|
}
|
|
@@ -8116,6 +8202,21 @@ function runDoctor(store) {
|
|
|
8116
8202
|
checks.push(status.running ? { id: "daemon", status: "ok", message: `daemon is running pid=${status.pid}` } : { id: "daemon", status: status.stale ? "warn" : "ok", message: status.stale ? "daemon pid file is stale" : "daemon is not running" });
|
|
8117
8203
|
const failedRuns = store.countRuns("failed");
|
|
8118
8204
|
checks.push(failedRuns === 0 ? { id: "loop-runs", status: "ok", message: "no failed loop runs recorded" } : { id: "loop-runs", status: "warn", message: `${failedRuns} failed loop run(s) recorded` });
|
|
8205
|
+
const deployment = buildDeploymentStatus();
|
|
8206
|
+
const schedulerState = deployment.schedulerState;
|
|
8207
|
+
checks.push({
|
|
8208
|
+
id: "scheduler-state",
|
|
8209
|
+
status: deployment.deploymentMode === "local" || deployment.controlPlane.configured ? "ok" : "warn",
|
|
8210
|
+
message: `scheduler state authority=${schedulerState.authority} local=${schedulerState.localStore.role} remote=${schedulerState.remoteStore.backend}`,
|
|
8211
|
+
detail: [
|
|
8212
|
+
`route_state=${schedulerState.routeAdmission.stateStore}`,
|
|
8213
|
+
`active_statuses=${schedulerState.routeAdmission.activeStatuses.join(",")}`,
|
|
8214
|
+
`gates=${schedulerState.routeAdmission.gates.join(",")}`,
|
|
8215
|
+
`artifacts=${schedulerState.localStore.runArtifacts}`,
|
|
8216
|
+
`remote_artifacts=${schedulerState.remoteStore.objectArtifacts}`,
|
|
8217
|
+
`remote_apply=${String(schedulerState.remoteStore.applySupported)}`
|
|
8218
|
+
].join(" ")
|
|
8219
|
+
});
|
|
8119
8220
|
for (const loop of store.listLoops({ status: "active" })) {
|
|
8120
8221
|
try {
|
|
8121
8222
|
if (loop.target.type === "workflow") {
|
|
@@ -9445,6 +9546,50 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
9445
9546
|
"console.log(`PR handoff complete: ${finalPrUrl}`);"
|
|
9446
9547
|
].join(`
|
|
9447
9548
|
`);
|
|
9549
|
+
var PR_HANDOFF_NO_ARTIFACT_SCRIPT = [
|
|
9550
|
+
"const { spawnSync } = await import('node:child_process');",
|
|
9551
|
+
"const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
|
|
9552
|
+
"const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
|
|
9553
|
+
"const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
|
|
9554
|
+
"const worktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
|
|
9555
|
+
"const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
|
|
9556
|
+
"const todosBin = process.env.OPENLOOPS_PR_HANDOFF_TODOS_BIN || 'todos';",
|
|
9557
|
+
"const gitBin = process.env.OPENLOOPS_PR_HANDOFF_GIT_BIN || 'git';",
|
|
9558
|
+
"const ghBin = process.env.OPENLOOPS_PR_HANDOFF_GH_BIN || 'gh';",
|
|
9559
|
+
"process.stdout.write(`no PR handoff artifact at ${artifactPath}\\n`);",
|
|
9560
|
+
"const run = (command, args, options = {}) => {",
|
|
9561
|
+
" try { return spawnSync(command, args, { encoding: 'utf8', ...options }); }",
|
|
9562
|
+
" catch (error) { return { status: 1, stdout: '', stderr: String((error && error.message) || error) }; }",
|
|
9563
|
+
"};",
|
|
9564
|
+
"const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
|
|
9565
|
+
"const comment = (text) => {",
|
|
9566
|
+
" const result = run(todosBin, todosArgs('comment', taskId, text));",
|
|
9567
|
+
" if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
|
|
9568
|
+
"};",
|
|
9569
|
+
"const main = () => {",
|
|
9570
|
+
" let branch = expectedBranch;",
|
|
9571
|
+
" if (!branch) {",
|
|
9572
|
+
" const shown = run(gitBin, ['-C', worktree, 'branch', '--show-current']);",
|
|
9573
|
+
" branch = String((shown.status === 0 ? shown.stdout : '') || '').trim();",
|
|
9574
|
+
" }",
|
|
9575
|
+
" if (!branch) { console.log('pr-handoff: no artifact and no resolvable branch; nothing to hand off'); return; }",
|
|
9576
|
+
" const listed = run(ghBin, ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'url,number,headRefName,headRefOid'], { cwd: worktree });",
|
|
9577
|
+
" if (listed.status !== 0) { console.log(`pr-handoff: no artifact; PR lookup failed for branch ${branch}: ${String(listed.stderr || listed.stdout || listed.status).slice(0, 300)}`); return; }",
|
|
9578
|
+
" let prs = [];",
|
|
9579
|
+
" try { prs = JSON.parse(String(listed.stdout || '[]')); } catch { prs = []; }",
|
|
9580
|
+
" const pr = Array.isArray(prs) ? prs.find((entry) => entry && entry.headRefName === branch && typeof entry.url === 'string' && entry.url) : undefined;",
|
|
9581
|
+
" if (!pr) { console.log(`pr-handoff: no artifact and no open PR for branch ${branch}; worker completed without opening a PR`); return; }",
|
|
9582
|
+
" let commit = String(pr.headRefOid || '').trim();",
|
|
9583
|
+
" if (!commit) {",
|
|
9584
|
+
" const head = run(gitBin, ['-C', worktree, 'rev-parse', 'HEAD']);",
|
|
9585
|
+
" commit = String((head.status === 0 ? head.stdout : '') || '').trim();",
|
|
9586
|
+
" }",
|
|
9587
|
+
" comment(`openloops:pr-handoff=done task=${taskId} pr=${pr.url} commit=${commit || 'unknown'} branch=${branch}`);",
|
|
9588
|
+
" console.log(`PR handoff complete (worker-opened PR): ${pr.url}`);",
|
|
9589
|
+
"};",
|
|
9590
|
+
"try { main(); } catch (error) { console.error(`pr-handoff no-artifact detection error (ignored): ${String((error && error.message) || error)}`); }"
|
|
9591
|
+
].join(`
|
|
9592
|
+
`);
|
|
9448
9593
|
function prHandoffCommand(opts) {
|
|
9449
9594
|
return [
|
|
9450
9595
|
"set -euo pipefail",
|
|
@@ -9455,15 +9600,90 @@ function prHandoffCommand(opts) {
|
|
|
9455
9600
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote3(opts.worktreeRoot)}`,
|
|
9456
9601
|
`export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote3(opts.expectedBranch)}`,
|
|
9457
9602
|
'if [ ! -s "$OPENLOOPS_PR_HANDOFF_ARTIFACT" ]; then',
|
|
9458
|
-
|
|
9459
|
-
|
|
9460
|
-
"
|
|
9603
|
+
"bun - <<'OPENLOOPS_PR_HANDOFF_NOARTIFACT'",
|
|
9604
|
+
PR_HANDOFF_NO_ARTIFACT_SCRIPT,
|
|
9605
|
+
"OPENLOOPS_PR_HANDOFF_NOARTIFACT",
|
|
9606
|
+
"else",
|
|
9461
9607
|
"bun - <<'BUN'",
|
|
9462
9608
|
PR_HANDOFF_SCRIPT,
|
|
9463
|
-
"BUN"
|
|
9609
|
+
"BUN",
|
|
9610
|
+
"fi"
|
|
9464
9611
|
].join(`
|
|
9465
9612
|
`);
|
|
9466
9613
|
}
|
|
9614
|
+
|
|
9615
|
+
// src/lib/route/profile-pool.ts
|
|
9616
|
+
function stableIndex(seed, size) {
|
|
9617
|
+
let hash = 2166136261;
|
|
9618
|
+
for (let i = 0;i < seed.length; i += 1) {
|
|
9619
|
+
hash ^= seed.charCodeAt(i);
|
|
9620
|
+
hash = Math.imul(hash, 16777619);
|
|
9621
|
+
}
|
|
9622
|
+
return Math.abs(hash >>> 0) % size;
|
|
9623
|
+
}
|
|
9624
|
+
function poolRoleOffset(role) {
|
|
9625
|
+
if (role === "worker")
|
|
9626
|
+
return 0;
|
|
9627
|
+
if (role === "verifier")
|
|
9628
|
+
return 1;
|
|
9629
|
+
if (role === "planner")
|
|
9630
|
+
return 2;
|
|
9631
|
+
return 3;
|
|
9632
|
+
}
|
|
9633
|
+
function selectLeastLoadedProfile(pool, loadCounts, anchor, exclude) {
|
|
9634
|
+
const size = pool.length;
|
|
9635
|
+
let best;
|
|
9636
|
+
let bestExcluded = true;
|
|
9637
|
+
let bestLoad = Number.POSITIVE_INFINITY;
|
|
9638
|
+
for (let k = 0;k < size; k += 1) {
|
|
9639
|
+
const profile = pool[(anchor + k) % size];
|
|
9640
|
+
const excluded = exclude.has(profile);
|
|
9641
|
+
const load = loadCounts[profile] ?? 0;
|
|
9642
|
+
if (best === undefined) {
|
|
9643
|
+
best = profile;
|
|
9644
|
+
bestExcluded = excluded;
|
|
9645
|
+
bestLoad = load;
|
|
9646
|
+
continue;
|
|
9647
|
+
}
|
|
9648
|
+
const better = excluded === bestExcluded ? load < bestLoad : !excluded && bestExcluded;
|
|
9649
|
+
if (better) {
|
|
9650
|
+
best = profile;
|
|
9651
|
+
bestExcluded = excluded;
|
|
9652
|
+
bestLoad = load;
|
|
9653
|
+
}
|
|
9654
|
+
}
|
|
9655
|
+
return best;
|
|
9656
|
+
}
|
|
9657
|
+
var ROLE_PRIORITY = ["worker", "verifier", "planner", "triage"];
|
|
9658
|
+
function assignPoolAuthProfiles(input) {
|
|
9659
|
+
const pool = input.pool.filter((entry) => entry.trim().length > 0);
|
|
9660
|
+
if (pool.length === 0)
|
|
9661
|
+
return { profiles: {}, deferred: false, minLoad: 0 };
|
|
9662
|
+
const minLoad = pool.reduce((min, profile) => Math.min(min, input.loadCounts[profile] ?? 0), Number.POSITIVE_INFINITY);
|
|
9663
|
+
const guard = input.maxPerProfile !== undefined && input.maxPerProfile > 0;
|
|
9664
|
+
if (guard && minLoad >= input.maxPerProfile) {
|
|
9665
|
+
return {
|
|
9666
|
+
profiles: {},
|
|
9667
|
+
deferred: true,
|
|
9668
|
+
minLoad,
|
|
9669
|
+
reason: `per-profile active limit reached (all ${pool.length} pool members >= ${input.maxPerProfile} running; min ${minLoad})`
|
|
9670
|
+
};
|
|
9671
|
+
}
|
|
9672
|
+
const workerIndex = stableIndex(input.seed, pool.length);
|
|
9673
|
+
const rolesToAssign = ROLE_PRIORITY.filter((role) => input.roles.includes(role));
|
|
9674
|
+
for (const role of input.roles)
|
|
9675
|
+
if (!rolesToAssign.includes(role))
|
|
9676
|
+
rolesToAssign.push(role);
|
|
9677
|
+
const profiles = {};
|
|
9678
|
+
const taken = new Set;
|
|
9679
|
+
for (const role of rolesToAssign) {
|
|
9680
|
+
const anchor = (workerIndex + poolRoleOffset(role)) % pool.length;
|
|
9681
|
+
const profile = selectLeastLoadedProfile(pool, input.loadCounts, anchor, taken);
|
|
9682
|
+
profiles[role] = profile;
|
|
9683
|
+
taken.add(profile);
|
|
9684
|
+
}
|
|
9685
|
+
return { profiles, deferred: false, minLoad: Number.isFinite(minLoad) ? minLoad : 0 };
|
|
9686
|
+
}
|
|
9467
9687
|
// src/lib/templates-custom.ts
|
|
9468
9688
|
import { existsSync as existsSync6, lstatSync as lstatSync2, mkdirSync as mkdirSync7, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
9469
9689
|
import { join as join6, resolve as resolve3 } from "path";
|
|
@@ -9859,25 +10079,13 @@ function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
|
|
|
9859
10079
|
throw new Error(`${label} must be a positive integer number of milliseconds`);
|
|
9860
10080
|
return value;
|
|
9861
10081
|
}
|
|
9862
|
-
function stableIndex(seed, size) {
|
|
9863
|
-
let hash = 2166136261;
|
|
9864
|
-
for (let i = 0;i < seed.length; i += 1) {
|
|
9865
|
-
hash ^= seed.charCodeAt(i);
|
|
9866
|
-
hash = Math.imul(hash, 16777619);
|
|
9867
|
-
}
|
|
9868
|
-
return Math.abs(hash >>> 0) % size;
|
|
9869
|
-
}
|
|
9870
10082
|
function rolePoolValue(pool, seed, role) {
|
|
9871
10083
|
if (!pool?.length)
|
|
9872
10084
|
return;
|
|
9873
10085
|
const workerIndex = stableIndex(seed, pool.length);
|
|
9874
|
-
if (
|
|
10086
|
+
if (pool.length === 1)
|
|
9875
10087
|
return pool[workerIndex];
|
|
9876
|
-
|
|
9877
|
-
return pool[(workerIndex + 1) % pool.length];
|
|
9878
|
-
if (role === "planner")
|
|
9879
|
-
return pool[(workerIndex + 2) % pool.length];
|
|
9880
|
-
return pool[(workerIndex + 3) % pool.length];
|
|
10088
|
+
return pool[(workerIndex + poolRoleOffset(role)) % pool.length];
|
|
9881
10089
|
}
|
|
9882
10090
|
function authProfileForRole(input, role, seed) {
|
|
9883
10091
|
if (role === "triage" && input.triageAuthProfile)
|
|
@@ -10047,7 +10255,8 @@ function agentTarget(input, prompt, role, seed, plan) {
|
|
|
10047
10255
|
allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
|
|
10048
10256
|
routing: {
|
|
10049
10257
|
projectPath: input.routeProjectPath ?? input.projectPath,
|
|
10050
|
-
...input.projectGroup ? { projectGroup: input.projectGroup } : {}
|
|
10258
|
+
...input.projectGroup ? { projectGroup: input.projectGroup } : {},
|
|
10259
|
+
role
|
|
10051
10260
|
},
|
|
10052
10261
|
account: accountForRole(input, role, seed),
|
|
10053
10262
|
timeoutMs: agentTimeoutMs(input),
|
|
@@ -11751,7 +11960,8 @@ function routeProjectGroup(optsGroup, data, metadata) {
|
|
|
11751
11960
|
function routeThrottleDecision(store, args) {
|
|
11752
11961
|
const projectPath = normalizeRoutePath(args.projectPath) ?? resolve6(args.projectPath);
|
|
11753
11962
|
const projectGroup = args.projectGroup?.trim() || undefined;
|
|
11754
|
-
const
|
|
11963
|
+
const routeScope = args.routeScope?.trim() || undefined;
|
|
11964
|
+
const counts = store.countActiveWorkflowWorkItems({ projectKey: projectPath, projectGroup, routeScope });
|
|
11755
11965
|
const base = {
|
|
11756
11966
|
projectPath,
|
|
11757
11967
|
...projectGroup ? { projectGroup } : {},
|
|
@@ -11913,6 +12123,33 @@ async function readEventEnvelopeInput(opts = {}) {
|
|
|
11913
12123
|
throw new ValidationError("event.source is required");
|
|
11914
12124
|
return event;
|
|
11915
12125
|
}
|
|
12126
|
+
function resolveRouteScope(opts, routeKey) {
|
|
12127
|
+
return opts.maxActiveScope?.trim() || process.env.LOOPS_LOOP_NAME?.trim() || routeKey || undefined;
|
|
12128
|
+
}
|
|
12129
|
+
function buildPoolRoutingPlan(opts, provider, authProfilePool, workflowBody, seed) {
|
|
12130
|
+
if (provider !== "codewith")
|
|
12131
|
+
return;
|
|
12132
|
+
const pool = (authProfilePool ?? []).filter((entry) => entry.trim().length > 0);
|
|
12133
|
+
if (pool.length < 2)
|
|
12134
|
+
return;
|
|
12135
|
+
const pinned = Boolean(opts.triageAuthProfile || opts.plannerAuthProfile || opts.workerAuthProfile || opts.verifierAuthProfile);
|
|
12136
|
+
if (pinned)
|
|
12137
|
+
return;
|
|
12138
|
+
const rolesByStepId = {};
|
|
12139
|
+
for (const step of workflowBody.steps) {
|
|
12140
|
+
const target = step.target;
|
|
12141
|
+
if (target.type !== "agent" || target.provider !== "codewith")
|
|
12142
|
+
continue;
|
|
12143
|
+
const role = target.routing?.role;
|
|
12144
|
+
if (role)
|
|
12145
|
+
rolesByStepId[step.id] = role;
|
|
12146
|
+
}
|
|
12147
|
+
if (Object.keys(rolesByStepId).length === 0)
|
|
12148
|
+
return;
|
|
12149
|
+
const explicitMax = nonNegativeInteger(opts.maxPerProfile, "--max-per-profile");
|
|
12150
|
+
const maxPerProfile = explicitMax ?? 2;
|
|
12151
|
+
return { pool, seed, maxPerProfile: maxPerProfile > 0 ? maxPerProfile : undefined, rolesByStepId };
|
|
12152
|
+
}
|
|
11916
12153
|
function dedupedRoutePrint(plan, outcome) {
|
|
11917
12154
|
return {
|
|
11918
12155
|
kind: "deduped",
|
|
@@ -11942,6 +12179,7 @@ function routeEvent(plan) {
|
|
|
11942
12179
|
subjectRef: plan.subjectRef,
|
|
11943
12180
|
projectKey: plan.routeProjectPath,
|
|
11944
12181
|
projectGroup: plan.projectGroup,
|
|
12182
|
+
routeScope: plan.routeScope,
|
|
11945
12183
|
priority: 0,
|
|
11946
12184
|
status: "queued"
|
|
11947
12185
|
};
|
|
@@ -11981,6 +12219,7 @@ function routeEvent(plan) {
|
|
|
11981
12219
|
const workflowPreflightSpec = workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
11982
12220
|
generatedRouteSandboxPreflight(workflowPreflightSpec);
|
|
11983
12221
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, plan.workflowContext, {}) : undefined;
|
|
12222
|
+
let poolAssignment;
|
|
11984
12223
|
const outcome = store.writeTransaction(() => {
|
|
11985
12224
|
const invocation = store.createWorkflowInvocation(plan.invocationInput);
|
|
11986
12225
|
const existingItem = store.findWorkflowWorkItem(plan.routeKey, idempotencyKey);
|
|
@@ -11991,12 +12230,39 @@ function routeEvent(plan) {
|
|
|
11991
12230
|
return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
|
|
11992
12231
|
}
|
|
11993
12232
|
}
|
|
11994
|
-
const throttle = hasThrottleLimits(plan.throttleLimits) ? routeThrottleDecision(store, {
|
|
12233
|
+
const throttle = hasThrottleLimits(plan.throttleLimits) ? routeThrottleDecision(store, {
|
|
12234
|
+
projectPath: plan.routeProjectPath,
|
|
12235
|
+
projectGroup: plan.projectGroup,
|
|
12236
|
+
routeScope: plan.routeScope,
|
|
12237
|
+
limits: plan.throttleLimits
|
|
12238
|
+
}) : undefined;
|
|
12239
|
+
if (plan.poolRouting) {
|
|
12240
|
+
const loadCounts = store.countRunningWorkflowStepsByAuthProfile();
|
|
12241
|
+
poolAssignment = assignPoolAuthProfiles({
|
|
12242
|
+
pool: plan.poolRouting.pool,
|
|
12243
|
+
seed: plan.poolRouting.seed,
|
|
12244
|
+
loadCounts,
|
|
12245
|
+
maxPerProfile: plan.poolRouting.maxPerProfile,
|
|
12246
|
+
roles: Object.values(plan.poolRouting.rolesByStepId)
|
|
12247
|
+
});
|
|
12248
|
+
}
|
|
12249
|
+
const activeThrottled = Boolean(throttle && !throttle.allowed);
|
|
12250
|
+
const poolDeferred = Boolean(poolAssignment?.deferred);
|
|
12251
|
+
const deferred = activeThrottled || poolDeferred;
|
|
12252
|
+
const deferReason = activeThrottled ? throttle.reason : poolAssignment?.reason;
|
|
12253
|
+
const effectiveThrottle = activeThrottled ? throttle : poolDeferred ? {
|
|
12254
|
+
allowed: false,
|
|
12255
|
+
reason: deferReason,
|
|
12256
|
+
projectPath: plan.routeProjectPath,
|
|
12257
|
+
...plan.projectGroup ? { projectGroup: plan.projectGroup } : {},
|
|
12258
|
+
limits: plan.throttleLimits,
|
|
12259
|
+
counts: throttle?.counts ?? { global: 0, project: 0 }
|
|
12260
|
+
} : throttle;
|
|
11995
12261
|
const workItem = store.upsertWorkflowWorkItem({
|
|
11996
12262
|
...workItemInput,
|
|
11997
12263
|
invocationId: invocation.id,
|
|
11998
|
-
status:
|
|
11999
|
-
lastReason:
|
|
12264
|
+
status: deferred ? "deferred" : "queued",
|
|
12265
|
+
lastReason: deferred ? deferReason : undefined
|
|
12000
12266
|
});
|
|
12001
12267
|
const requeue = workItem.attempts > 0 && workItem.status === "queued" ? {
|
|
12002
12268
|
previousWorkItemId: workItem.id,
|
|
@@ -12004,8 +12270,16 @@ function routeEvent(plan) {
|
|
|
12004
12270
|
reason: workItem.lastReason
|
|
12005
12271
|
} : undefined;
|
|
12006
12272
|
const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, plan.invocationInput);
|
|
12007
|
-
if (
|
|
12008
|
-
return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle };
|
|
12273
|
+
if (deferred)
|
|
12274
|
+
return { kind: "throttled", invocation: refreshedInvocation, workItem, throttle: effectiveThrottle };
|
|
12275
|
+
if (poolAssignment && !poolAssignment.deferred) {
|
|
12276
|
+
for (const step of workflowBody.steps) {
|
|
12277
|
+
const role = plan.poolRouting?.rolesByStepId[step.id];
|
|
12278
|
+
const chosen = role ? poolAssignment.profiles[role] : undefined;
|
|
12279
|
+
if (chosen && step.target.type === "agent")
|
|
12280
|
+
step.target.authProfile = chosen;
|
|
12281
|
+
}
|
|
12282
|
+
}
|
|
12009
12283
|
const workflow = routeWorkflowForStorage(store, workflowBody);
|
|
12010
12284
|
const loop = store.createLoop({
|
|
12011
12285
|
...loopInput,
|
|
@@ -12063,6 +12337,7 @@ function routeEvent(plan) {
|
|
|
12063
12337
|
loop: publicLoop(outcome.loop),
|
|
12064
12338
|
requeue: outcome.requeue,
|
|
12065
12339
|
throttle: outcome.throttle,
|
|
12340
|
+
...poolAssignment && !poolAssignment.deferred && Object.keys(poolAssignment.profiles).length ? { accountProfiles: poolAssignment.profiles, routeScope: plan.routeScope } : {},
|
|
12066
12341
|
sandboxPreflight,
|
|
12067
12342
|
preflight
|
|
12068
12343
|
},
|
|
@@ -12246,6 +12521,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
12246
12521
|
invocationInput,
|
|
12247
12522
|
routeProjectPath,
|
|
12248
12523
|
projectGroup,
|
|
12524
|
+
routeScope: resolveRouteScope(opts, "todos-task"),
|
|
12525
|
+
poolRouting: buildPoolRoutingPlan(opts, provider, providerRouting.authProfilePool, workflowBody, taskId),
|
|
12249
12526
|
subjectRef: taskId,
|
|
12250
12527
|
loopName,
|
|
12251
12528
|
loopDescription: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
|
|
@@ -12359,6 +12636,8 @@ function routeGenericEvent(event, opts) {
|
|
|
12359
12636
|
invocationInput,
|
|
12360
12637
|
routeProjectPath,
|
|
12361
12638
|
projectGroup,
|
|
12639
|
+
routeScope: resolveRouteScope(opts, "generic-event"),
|
|
12640
|
+
poolRouting: buildPoolRoutingPlan(opts, provider, providerRouting.authProfilePool, workflowBody, `${event.source}:${event.type}:${event.id}`),
|
|
12362
12641
|
subjectRef: stringField(event.subject) ?? event.id,
|
|
12363
12642
|
loopName,
|
|
12364
12643
|
loopDescription: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
|
|
@@ -12562,6 +12841,8 @@ function compactDrainResult(result) {
|
|
|
12562
12841
|
workflowId: stringField(workflow?.id),
|
|
12563
12842
|
workflowName: stringField(workflow?.name),
|
|
12564
12843
|
providerRouting,
|
|
12844
|
+
accountProfiles: objectField2(value.accountProfiles),
|
|
12845
|
+
routeScope: stringField(value.routeScope),
|
|
12565
12846
|
requeue,
|
|
12566
12847
|
queuedAtSource: value.queuedAtSource,
|
|
12567
12848
|
fatal: value.fatal === true ? true : undefined
|
|
@@ -13227,6 +13508,18 @@ var AGENT_ROUTING_OPTION_SPECS = [
|
|
|
13227
13508
|
kind: "value",
|
|
13228
13509
|
description: "skip creating a workflow when this many active routed workflows already exist for the project group"
|
|
13229
13510
|
},
|
|
13511
|
+
{
|
|
13512
|
+
flags: "--max-active-scope <key>",
|
|
13513
|
+
key: "maxActiveScope",
|
|
13514
|
+
kind: "value",
|
|
13515
|
+
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"
|
|
13516
|
+
},
|
|
13517
|
+
{
|
|
13518
|
+
flags: "--max-per-profile <n>",
|
|
13519
|
+
key: "maxPerProfile",
|
|
13520
|
+
kind: "value",
|
|
13521
|
+
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)"
|
|
13522
|
+
},
|
|
13230
13523
|
{ flags: "--worktree-mode <mode>", key: "worktreeMode", kind: "value", description: "worktree isolation mode: auto, required, off, or main", defaultValue: "auto" },
|
|
13231
13524
|
{ flags: "--worktree-root <path>", key: "worktreeRoot", kind: "value", description: "base directory for OpenLoops-managed git worktrees" },
|
|
13232
13525
|
{ flags: "--worktree-branch-prefix <prefix>", key: "worktreeBranchPrefix", kind: "value", description: "branch prefix for generated worktrees", defaultValue: "openloops" },
|