@hasna/loops 0.4.11 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +19 -2
- package/dist/api/index.js +1 -1
- package/dist/cli/index.js +204 -27
- package/dist/daemon/index.js +37 -6
- package/dist/index.js +118 -21
- package/dist/lib/mode.js +1 -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 +37 -6
- package/dist/runner/index.js +1 -1
- package/dist/sdk/index.js +37 -6
- package/dist/types.d.ts +10 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,54 @@ documented in this file. Version entries are generated from the
|
|
|
5
5
|
conventional-commit git history; one commit maps to one released patch version
|
|
6
6
|
unless noted.
|
|
7
7
|
|
|
8
|
+
## 0.4.12 (2026-07-05)
|
|
9
|
+
|
|
10
|
+
Drain throughput: `--max-active` is now a per-route ceiling instead of a
|
|
11
|
+
store-wide one, drain dispatch spreads across subscription accounts by live load,
|
|
12
|
+
and every step records the account it ran on.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Route admission — per-route `--max-active`:** the global admission count was
|
|
17
|
+
store-wide, so the busiest router's `--max-active` became a shared ceiling that
|
|
18
|
+
throttled every other router (average concurrency ~1.9 while the agent lane
|
|
19
|
+
allowed 12; ~253 items/24h deferred as `global active workflow limit reached`).
|
|
20
|
+
The global count is now scoped to the route/drain that set the limit; scope
|
|
21
|
+
precedence is an explicit `--max-active-scope <key>` first, then the running
|
|
22
|
+
loop's `LOOPS_LOOP_NAME`, then the route key as the final fallback — so each
|
|
23
|
+
router's `--max-active` is its own ceiling.
|
|
24
|
+
Existing routers get correct scoping with no config change. `project` and
|
|
25
|
+
`project_group` counts are unchanged: they remain cross-route anti-hog caps.
|
|
26
|
+
- **Per-account attribution:** Codewith agent steps carry their subscription
|
|
27
|
+
account in `authProfile`, not an `AccountRef`, so `workflow_step_runs.account_profile`
|
|
28
|
+
was NULL for every drain worker and per-account burn could not be attributed.
|
|
29
|
+
The resolved auth profile (and provider as `account_tool`) is now recorded on
|
|
30
|
+
each step at run creation, and the per-role assignment is surfaced in drain
|
|
31
|
+
reports.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **Least-loaded auth-profile pool selection + `--max-per-profile`:** pooled
|
|
36
|
+
Codewith dispatch picked a member by a pure hash of the work item, which stacked
|
|
37
|
+
several concurrent workers on one account at high concurrency and tripped
|
|
38
|
+
provider-side 429/stream-drop limits. Live drains now count running steps per
|
|
39
|
+
account and place each route on the least-loaded pool member (verifier/planner
|
|
40
|
+
still land on a different account than the worker; the hash is the deterministic
|
|
41
|
+
tie-break, so a cold store reproduces the prior assignment). New
|
|
42
|
+
`--max-per-profile <K>` (default 2 for pools of two or more, `0` disables) defers
|
|
43
|
+
a route when every pool account already has `K` running steps.
|
|
44
|
+
|
|
45
|
+
Schema: additive sqlite migration `0008_work_item_route_scope` (nullable
|
|
46
|
+
`workflow_work_items.route_scope`; its index is created only by the migration —
|
|
47
|
+
never in the baseline DDL, which re-runs on every open and would crash pre-0008
|
|
48
|
+
databases). `SCHEMA_USER_VERSION` is intentionally not bumped so an older binary
|
|
49
|
+
can still open a database this migration touched (rollback-safe). Postgres gets
|
|
50
|
+
the equivalent additive migration `0004_work_item_route_scope`; the released,
|
|
51
|
+
checksummed `0002_workflows_goals` block is untouched so existing postgres
|
|
52
|
+
ledgers keep verifying. Upgrade coverage: a real pre-0008 (0.4.11 schema)
|
|
53
|
+
fixture database must open cleanly, gain the column + index, and keep its data;
|
|
54
|
+
released postgres migration checksums are pinned in tests.
|
|
55
|
+
|
|
8
56
|
## 0.4.11 (2026-07-05)
|
|
9
57
|
|
|
10
58
|
Scheduler fairness and PR-route hygiene: fast command loops no longer starve
|
package/README.md
CHANGED
|
@@ -594,8 +594,15 @@ This is the intended deterministic-to-agentic path: a producer creates a todos
|
|
|
594
594
|
task, `@hasna/events` delivers `task.created`, OpenLoops records the invocation
|
|
595
595
|
and admission item, OpenLoops creates a worker/verifier workflow when admitted,
|
|
596
596
|
and the workflow updates todos with evidence. Use account pools so worker and
|
|
597
|
-
verifier steps do not burn the same profile
|
|
598
|
-
|
|
597
|
+
verifier steps do not burn the same profile. For live Codewith drains, OpenLoops
|
|
598
|
+
spreads each route to the **least-loaded** pool account (counting running steps
|
|
599
|
+
per account) and still keeps the verifier on a different profile than the worker;
|
|
600
|
+
the deterministic hash is the tie-break, so a cold store reproduces the previous
|
|
601
|
+
assignment exactly. `--max-per-profile <K>` (default 2 for pools of two or more,
|
|
602
|
+
`0` to disable) defers a route when every pool account already has `K` running
|
|
603
|
+
steps — the guardrail that keeps higher concurrency from stacking on one
|
|
604
|
+
subscription and tripping provider-side rate limits. The chosen account per role
|
|
605
|
+
is recorded on each step (`account_profile`) and surfaced in drain reports.
|
|
599
606
|
Use `--dry-run` to inspect the rendered invocation, work item, workflow, and
|
|
600
607
|
loop input without storing anything.
|
|
601
608
|
|
|
@@ -645,11 +652,21 @@ loops routes schedule todos-task oss-task-route-drain \
|
|
|
645
652
|
--max-active-per-project 1 \
|
|
646
653
|
--max-active-per-project-group 4 \
|
|
647
654
|
--max-active 12 \
|
|
655
|
+
--max-per-profile 2 \
|
|
648
656
|
--worktree-mode required \
|
|
649
657
|
--evidence-dir "$HOME/.hasna/loops/reports/oss-task-route-drain" \
|
|
650
658
|
--compact
|
|
651
659
|
```
|
|
652
660
|
|
|
661
|
+
`--max-active` counts active routed workflows **per route**. Scope precedence:
|
|
662
|
+
an explicit `--max-active-scope <key>` wins, else the running loop's
|
|
663
|
+
`LOOPS_LOOP_NAME`, else the route key — so each router's limit is its own
|
|
664
|
+
ceiling rather than a store-wide total shared by every router.
|
|
665
|
+
`--max-active-per-project` and `--max-active-per-project-group` remain cross-route
|
|
666
|
+
anti-hog caps counted over all routes. Raise a router's `--max-active`
|
|
667
|
+
deliberately once counting is per-route; keep `--max-per-profile` set so the
|
|
668
|
+
extra concurrency spreads across subscription accounts.
|
|
669
|
+
|
|
653
670
|
Only route tasks that explicitly opt in with `auto:route`, `route_enabled=true`,
|
|
654
671
|
or `automation.allowed=true`. Use Codewith account pools, required worktrees,
|
|
655
672
|
max-active throttles, and bounded evidence directories. Do not dispatch or paste
|
package/dist/api/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "@hasna/loops",
|
|
6
|
-
version: "0.4.
|
|
6
|
+
version: "0.4.12",
|
|
7
7
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
8
8
|
type: "module",
|
|
9
9
|
main: "dist/index.js",
|
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",
|
|
@@ -9464,6 +9495,79 @@ function prHandoffCommand(opts) {
|
|
|
9464
9495
|
].join(`
|
|
9465
9496
|
`);
|
|
9466
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
|
+
}
|
|
9467
9571
|
// src/lib/templates-custom.ts
|
|
9468
9572
|
import { existsSync as existsSync6, lstatSync as lstatSync2, mkdirSync as mkdirSync7, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
9469
9573
|
import { join as join6, resolve as resolve3 } from "path";
|
|
@@ -9859,25 +9963,13 @@ function parseDeterministicTimeoutMs(raw, fallbackMs, label = "timeoutMs") {
|
|
|
9859
9963
|
throw new Error(`${label} must be a positive integer number of milliseconds`);
|
|
9860
9964
|
return value;
|
|
9861
9965
|
}
|
|
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
9966
|
function rolePoolValue(pool, seed, role) {
|
|
9871
9967
|
if (!pool?.length)
|
|
9872
9968
|
return;
|
|
9873
9969
|
const workerIndex = stableIndex(seed, pool.length);
|
|
9874
|
-
if (
|
|
9970
|
+
if (pool.length === 1)
|
|
9875
9971
|
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];
|
|
9972
|
+
return pool[(workerIndex + poolRoleOffset(role)) % pool.length];
|
|
9881
9973
|
}
|
|
9882
9974
|
function authProfileForRole(input, role, seed) {
|
|
9883
9975
|
if (role === "triage" && input.triageAuthProfile)
|
|
@@ -10047,7 +10139,8 @@ function agentTarget(input, prompt, role, seed, plan) {
|
|
|
10047
10139
|
allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
|
|
10048
10140
|
routing: {
|
|
10049
10141
|
projectPath: input.routeProjectPath ?? input.projectPath,
|
|
10050
|
-
...input.projectGroup ? { projectGroup: input.projectGroup } : {}
|
|
10142
|
+
...input.projectGroup ? { projectGroup: input.projectGroup } : {},
|
|
10143
|
+
role
|
|
10051
10144
|
},
|
|
10052
10145
|
account: accountForRole(input, role, seed),
|
|
10053
10146
|
timeoutMs: agentTimeoutMs(input),
|
|
@@ -11751,7 +11844,8 @@ function routeProjectGroup(optsGroup, data, metadata) {
|
|
|
11751
11844
|
function routeThrottleDecision(store, args) {
|
|
11752
11845
|
const projectPath = normalizeRoutePath(args.projectPath) ?? resolve6(args.projectPath);
|
|
11753
11846
|
const projectGroup = args.projectGroup?.trim() || undefined;
|
|
11754
|
-
const
|
|
11847
|
+
const routeScope = args.routeScope?.trim() || undefined;
|
|
11848
|
+
const counts = store.countActiveWorkflowWorkItems({ projectKey: projectPath, projectGroup, routeScope });
|
|
11755
11849
|
const base = {
|
|
11756
11850
|
projectPath,
|
|
11757
11851
|
...projectGroup ? { projectGroup } : {},
|
|
@@ -11913,6 +12007,33 @@ async function readEventEnvelopeInput(opts = {}) {
|
|
|
11913
12007
|
throw new ValidationError("event.source is required");
|
|
11914
12008
|
return event;
|
|
11915
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
|
+
}
|
|
11916
12037
|
function dedupedRoutePrint(plan, outcome) {
|
|
11917
12038
|
return {
|
|
11918
12039
|
kind: "deduped",
|
|
@@ -11942,6 +12063,7 @@ function routeEvent(plan) {
|
|
|
11942
12063
|
subjectRef: plan.subjectRef,
|
|
11943
12064
|
projectKey: plan.routeProjectPath,
|
|
11944
12065
|
projectGroup: plan.projectGroup,
|
|
12066
|
+
routeScope: plan.routeScope,
|
|
11945
12067
|
priority: 0,
|
|
11946
12068
|
status: "queued"
|
|
11947
12069
|
};
|
|
@@ -11981,6 +12103,7 @@ function routeEvent(plan) {
|
|
|
11981
12103
|
const workflowPreflightSpec = workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
11982
12104
|
generatedRouteSandboxPreflight(workflowPreflightSpec);
|
|
11983
12105
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, plan.workflowContext, {}) : undefined;
|
|
12106
|
+
let poolAssignment;
|
|
11984
12107
|
const outcome = store.writeTransaction(() => {
|
|
11985
12108
|
const invocation = store.createWorkflowInvocation(plan.invocationInput);
|
|
11986
12109
|
const existingItem = store.findWorkflowWorkItem(plan.routeKey, idempotencyKey);
|
|
@@ -11991,12 +12114,39 @@ function routeEvent(plan) {
|
|
|
11991
12114
|
return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
|
|
11992
12115
|
}
|
|
11993
12116
|
}
|
|
11994
|
-
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;
|
|
11995
12145
|
const workItem = store.upsertWorkflowWorkItem({
|
|
11996
12146
|
...workItemInput,
|
|
11997
12147
|
invocationId: invocation.id,
|
|
11998
|
-
status:
|
|
11999
|
-
lastReason:
|
|
12148
|
+
status: deferred ? "deferred" : "queued",
|
|
12149
|
+
lastReason: deferred ? deferReason : undefined
|
|
12000
12150
|
});
|
|
12001
12151
|
const requeue = workItem.attempts > 0 && workItem.status === "queued" ? {
|
|
12002
12152
|
previousWorkItemId: workItem.id,
|
|
@@ -12004,8 +12154,16 @@ function routeEvent(plan) {
|
|
|
12004
12154
|
reason: workItem.lastReason
|
|
12005
12155
|
} : undefined;
|
|
12006
12156
|
const refreshedInvocation = store.refreshWorkflowInvocationForWorkItem(workItem.id, plan.invocationInput);
|
|
12007
|
-
if (
|
|
12008
|
-
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
|
+
}
|
|
12009
12167
|
const workflow = routeWorkflowForStorage(store, workflowBody);
|
|
12010
12168
|
const loop = store.createLoop({
|
|
12011
12169
|
...loopInput,
|
|
@@ -12063,6 +12221,7 @@ function routeEvent(plan) {
|
|
|
12063
12221
|
loop: publicLoop(outcome.loop),
|
|
12064
12222
|
requeue: outcome.requeue,
|
|
12065
12223
|
throttle: outcome.throttle,
|
|
12224
|
+
...poolAssignment && !poolAssignment.deferred && Object.keys(poolAssignment.profiles).length ? { accountProfiles: poolAssignment.profiles, routeScope: plan.routeScope } : {},
|
|
12066
12225
|
sandboxPreflight,
|
|
12067
12226
|
preflight
|
|
12068
12227
|
},
|
|
@@ -12246,6 +12405,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
12246
12405
|
invocationInput,
|
|
12247
12406
|
routeProjectPath,
|
|
12248
12407
|
projectGroup,
|
|
12408
|
+
routeScope: resolveRouteScope(opts, "todos-task"),
|
|
12409
|
+
poolRouting: buildPoolRoutingPlan(opts, provider, providerRouting.authProfilePool, workflowBody, taskId),
|
|
12249
12410
|
subjectRef: taskId,
|
|
12250
12411
|
loopName,
|
|
12251
12412
|
loopDescription: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
|
|
@@ -12359,6 +12520,8 @@ function routeGenericEvent(event, opts) {
|
|
|
12359
12520
|
invocationInput,
|
|
12360
12521
|
routeProjectPath,
|
|
12361
12522
|
projectGroup,
|
|
12523
|
+
routeScope: resolveRouteScope(opts, "generic-event"),
|
|
12524
|
+
poolRouting: buildPoolRoutingPlan(opts, provider, providerRouting.authProfilePool, workflowBody, `${event.source}:${event.type}:${event.id}`),
|
|
12362
12525
|
subjectRef: stringField(event.subject) ?? event.id,
|
|
12363
12526
|
loopName,
|
|
12364
12527
|
loopDescription: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
|
|
@@ -12562,6 +12725,8 @@ function compactDrainResult(result) {
|
|
|
12562
12725
|
workflowId: stringField(workflow?.id),
|
|
12563
12726
|
workflowName: stringField(workflow?.name),
|
|
12564
12727
|
providerRouting,
|
|
12728
|
+
accountProfiles: objectField2(value.accountProfiles),
|
|
12729
|
+
routeScope: stringField(value.routeScope),
|
|
12565
12730
|
requeue,
|
|
12566
12731
|
queuedAtSource: value.queuedAtSource,
|
|
12567
12732
|
fatal: value.fatal === true ? true : undefined
|
|
@@ -13227,6 +13392,18 @@ var AGENT_ROUTING_OPTION_SPECS = [
|
|
|
13227
13392
|
kind: "value",
|
|
13228
13393
|
description: "skip creating a workflow when this many active routed workflows already exist for the project group"
|
|
13229
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
|
+
},
|
|
13230
13407
|
{ flags: "--worktree-mode <mode>", key: "worktreeMode", kind: "value", description: "worktree isolation mode: auto, required, off, or main", defaultValue: "auto" },
|
|
13231
13408
|
{ flags: "--worktree-root <path>", key: "worktreeRoot", kind: "value", description: "base directory for OpenLoops-managed git worktrees" },
|
|
13232
13409
|
{ flags: "--worktree-branch-prefix <prefix>", key: "worktreeBranchPrefix", kind: "value", description: "branch prefix for generated worktrees", defaultValue: "openloops" },
|
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
|
});
|
|
@@ -7785,7 +7816,7 @@ function enableStartup(result) {
|
|
|
7785
7816
|
// package.json
|
|
7786
7817
|
var package_default = {
|
|
7787
7818
|
name: "@hasna/loops",
|
|
7788
|
-
version: "0.4.
|
|
7819
|
+
version: "0.4.12",
|
|
7789
7820
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7790
7821
|
type: "module",
|
|
7791
7822
|
main: "dist/index.js",
|