@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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,86 @@ 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.13 (2026-07-05)
|
|
9
|
+
|
|
10
|
+
`--pr-handoff` workflows whose worker pushes its own branch and opens the PR
|
|
11
|
+
directly (no handoff artifact — the cursor pattern) now complete instead of
|
|
12
|
+
failing the pr-handoff step and skipping the verifier.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **pr-handoff — no-artifact/direct-PR path exited 1 instead of 0:** the step
|
|
17
|
+
runs as `bash -lc` (a login shell). The no-artifact guard ended with an
|
|
18
|
+
explicit `exit 0` while `set -e` was active; under systemd (`SHLVL` unset →
|
|
19
|
+
bash sets 1) an explicit exit sources `~/.bash_logout`, whose `clear_console`
|
|
20
|
+
fails without a controlling TTY, and errexit handed that failure back as the
|
|
21
|
+
step's exit code. The workflow failed, the verifier (which depends on
|
|
22
|
+
pr-handoff) was skipped, and the source task was stranded `in_progress`.
|
|
23
|
+
Both guard branches now route through bun heredocs and fall through the `if`'s
|
|
24
|
+
natural end — no top-level shell `exit` — so the intended status is preserved
|
|
25
|
+
on the local and remote execution paths alike (gate steps already ended
|
|
26
|
+
naturally, which is why only pr-handoff failed). (#34)
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **Worker-opened PR detection on the no-artifact path:** when no handoff
|
|
31
|
+
artifact exists, pr-handoff now looks up the worker's own open PR for the
|
|
32
|
+
workflow branch (`gh pr list --head <branch> --state open`), records the same
|
|
33
|
+
`openloops:pr-handoff=done task=… pr=… commit=… branch=…` comment the
|
|
34
|
+
artifact path records, and exits 0 — so direct-PR (cursor-style) completions
|
|
35
|
+
carry PR evidence into the verifier and merge lane. Best-effort and
|
|
36
|
+
fail-open on lookup: a missing PR or a `gh`/`git`/`todos` error is logged,
|
|
37
|
+
writes no done-evidence, and never fails the step. The artifact (codewith)
|
|
38
|
+
handoff path is unchanged. (#34)
|
|
39
|
+
|
|
40
|
+
## 0.4.12 (2026-07-05)
|
|
41
|
+
|
|
42
|
+
Drain throughput: `--max-active` is now a per-route ceiling instead of a
|
|
43
|
+
store-wide one, drain dispatch spreads across subscription accounts by live load,
|
|
44
|
+
and every step records the account it ran on.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **Route admission — per-route `--max-active`:** the global admission count was
|
|
49
|
+
store-wide, so the busiest router's `--max-active` became a shared ceiling that
|
|
50
|
+
throttled every other router (average concurrency ~1.9 while the agent lane
|
|
51
|
+
allowed 12; ~253 items/24h deferred as `global active workflow limit reached`).
|
|
52
|
+
The global count is now scoped to the route/drain that set the limit; scope
|
|
53
|
+
precedence is an explicit `--max-active-scope <key>` first, then the running
|
|
54
|
+
loop's `LOOPS_LOOP_NAME`, then the route key as the final fallback — so each
|
|
55
|
+
router's `--max-active` is its own ceiling.
|
|
56
|
+
Existing routers get correct scoping with no config change. `project` and
|
|
57
|
+
`project_group` counts are unchanged: they remain cross-route anti-hog caps.
|
|
58
|
+
- **Per-account attribution:** Codewith agent steps carry their subscription
|
|
59
|
+
account in `authProfile`, not an `AccountRef`, so `workflow_step_runs.account_profile`
|
|
60
|
+
was NULL for every drain worker and per-account burn could not be attributed.
|
|
61
|
+
The resolved auth profile (and provider as `account_tool`) is now recorded on
|
|
62
|
+
each step at run creation, and the per-role assignment is surfaced in drain
|
|
63
|
+
reports.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **Least-loaded auth-profile pool selection + `--max-per-profile`:** pooled
|
|
68
|
+
Codewith dispatch picked a member by a pure hash of the work item, which stacked
|
|
69
|
+
several concurrent workers on one account at high concurrency and tripped
|
|
70
|
+
provider-side 429/stream-drop limits. Live drains now count running steps per
|
|
71
|
+
account and place each route on the least-loaded pool member (verifier/planner
|
|
72
|
+
still land on a different account than the worker; the hash is the deterministic
|
|
73
|
+
tie-break, so a cold store reproduces the prior assignment). New
|
|
74
|
+
`--max-per-profile <K>` (default 2 for pools of two or more, `0` disables) defers
|
|
75
|
+
a route when every pool account already has `K` running steps.
|
|
76
|
+
|
|
77
|
+
Schema: additive sqlite migration `0008_work_item_route_scope` (nullable
|
|
78
|
+
`workflow_work_items.route_scope`; its index is created only by the migration —
|
|
79
|
+
never in the baseline DDL, which re-runs on every open and would crash pre-0008
|
|
80
|
+
databases). `SCHEMA_USER_VERSION` is intentionally not bumped so an older binary
|
|
81
|
+
can still open a database this migration touched (rollback-safe). Postgres gets
|
|
82
|
+
the equivalent additive migration `0004_work_item_route_scope`; the released,
|
|
83
|
+
checksummed `0002_workflows_goals` block is untouched so existing postgres
|
|
84
|
+
ledgers keep verifying. Upgrade coverage: a real pre-0008 (0.4.11 schema)
|
|
85
|
+
fixture database must open cleanly, gain the column + index, and keep its data;
|
|
86
|
+
released postgres migration checksums are pinned in tests.
|
|
87
|
+
|
|
8
88
|
## 0.4.11 (2026-07-05)
|
|
9
89
|
|
|
10
90
|
Scheduler fairness and PR-route hygiene: fast command loops no longer starve
|
package/README.md
CHANGED
|
@@ -38,6 +38,16 @@ package. Cloud mode is a public contract until a cloud-specific hosted URL and
|
|
|
38
38
|
cloud token are configured through `LOOPS_CLOUD_API_URL` plus
|
|
39
39
|
`LOOPS_CLOUD_TOKEN` or `HASNA_LOOPS_CLOUD_TOKEN`.
|
|
40
40
|
|
|
41
|
+
Scheduler state is explicit in status JSON. `schedulerState.localStore` is
|
|
42
|
+
SQLite plus local run artifact files: authoritative in `local`, cache/spool in
|
|
43
|
+
non-local modes. `schedulerState.remoteStore` names the non-local contract
|
|
44
|
+
(`api_control_plane_contract`, `postgres_contract`, or
|
|
45
|
+
`hosted_control_plane_contract`) and reports `applySupported=false` because this
|
|
46
|
+
public package does not directly mutate remote Postgres, S3/object storage, AWS
|
|
47
|
+
resources, or hosted credentials. Route admission remains bounded by
|
|
48
|
+
`max_dispatch`, `max_active`, `max_active_per_project`,
|
|
49
|
+
`max_active_per_project_group`, `max_active_scope`, and `max_per_profile`.
|
|
50
|
+
|
|
41
51
|
Useful status commands:
|
|
42
52
|
|
|
43
53
|
```bash
|
|
@@ -524,11 +534,12 @@ workflow unless the event data or metadata has `route_enabled=true`,
|
|
|
524
534
|
`automation.allowed=true`, or a task tag containing `auto:route`. It also skips
|
|
525
535
|
blocked, completed/done, cancelled/canceled, failed, archived, manual,
|
|
526
536
|
approval-required, or `no-auto` tasks. Terminal route work items such as
|
|
527
|
-
failed, dead-letter, cancelled, or succeeded history
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
id
|
|
537
|
+
failed, dead-letter, cancelled, or succeeded history are re-admitted only when
|
|
538
|
+
the todos task is still actionable, the per-attempt backoff has elapsed, and the
|
|
539
|
+
redispatch cap has not been reached. Operators can still force a retry with
|
|
540
|
+
`loops routes requeue <work-item-id> --reason "<cause fixed>"`. The next
|
|
541
|
+
route-created output records `requeue` evidence with the previous work item id,
|
|
542
|
+
previous attempts, reason, new attempt, workflow id, and loop id.
|
|
532
543
|
|
|
533
544
|
Task route drains can select providers from task metadata instead of running one
|
|
534
545
|
fixed provider/account pool for the whole drain. Add one or more
|
|
@@ -594,8 +605,15 @@ This is the intended deterministic-to-agentic path: a producer creates a todos
|
|
|
594
605
|
task, `@hasna/events` delivers `task.created`, OpenLoops records the invocation
|
|
595
606
|
and admission item, OpenLoops creates a worker/verifier workflow when admitted,
|
|
596
607
|
and the workflow updates todos with evidence. Use account pools so worker and
|
|
597
|
-
verifier steps do not burn the same profile
|
|
598
|
-
|
|
608
|
+
verifier steps do not burn the same profile. For live Codewith drains, OpenLoops
|
|
609
|
+
spreads each route to the **least-loaded** pool account (counting running steps
|
|
610
|
+
per account) and still keeps the verifier on a different profile than the worker;
|
|
611
|
+
the deterministic hash is the tie-break, so a cold store reproduces the previous
|
|
612
|
+
assignment exactly. `--max-per-profile <K>` (default 2 for pools of two or more,
|
|
613
|
+
`0` to disable) defers a route when every pool account already has `K` running
|
|
614
|
+
steps — the guardrail that keeps higher concurrency from stacking on one
|
|
615
|
+
subscription and tripping provider-side rate limits. The chosen account per role
|
|
616
|
+
is recorded on each step (`account_profile`) and surfaced in drain reports.
|
|
599
617
|
Use `--dry-run` to inspect the rendered invocation, work item, workflow, and
|
|
600
618
|
loop input without storing anything.
|
|
601
619
|
|
|
@@ -645,11 +663,21 @@ loops routes schedule todos-task oss-task-route-drain \
|
|
|
645
663
|
--max-active-per-project 1 \
|
|
646
664
|
--max-active-per-project-group 4 \
|
|
647
665
|
--max-active 12 \
|
|
666
|
+
--max-per-profile 2 \
|
|
648
667
|
--worktree-mode required \
|
|
649
668
|
--evidence-dir "$HOME/.hasna/loops/reports/oss-task-route-drain" \
|
|
650
669
|
--compact
|
|
651
670
|
```
|
|
652
671
|
|
|
672
|
+
`--max-active` counts active routed workflows **per route**. Scope precedence:
|
|
673
|
+
an explicit `--max-active-scope <key>` wins, else the running loop's
|
|
674
|
+
`LOOPS_LOOP_NAME`, else the route key — so each router's limit is its own
|
|
675
|
+
ceiling rather than a store-wide total shared by every router.
|
|
676
|
+
`--max-active-per-project` and `--max-active-per-project-group` remain cross-route
|
|
677
|
+
anti-hog caps counted over all routes. Raise a router's `--max-active`
|
|
678
|
+
deliberately once counting is per-route; keep `--max-per-profile` set so the
|
|
679
|
+
extra concurrency spreads across subscription accounts.
|
|
680
|
+
|
|
653
681
|
Only route tasks that explicitly opt in with `auto:route`, `route_enabled=true`,
|
|
654
682
|
or `automation.allowed=true`. Use Codewith account pools, required worktrees,
|
|
655
683
|
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.13",
|
|
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",
|
|
@@ -194,6 +194,50 @@ function sourceOfTruthForMode(mode) {
|
|
|
194
194
|
return "self_hosted_control_plane";
|
|
195
195
|
return "cloud_control_plane";
|
|
196
196
|
}
|
|
197
|
+
var ROUTE_ADMISSION_GATES = [
|
|
198
|
+
"max_dispatch",
|
|
199
|
+
"max_active",
|
|
200
|
+
"max_active_per_project",
|
|
201
|
+
"max_active_per_project_group",
|
|
202
|
+
"max_active_scope",
|
|
203
|
+
"max_per_profile"
|
|
204
|
+
];
|
|
205
|
+
function remoteSchedulerBackendForMode(mode, config) {
|
|
206
|
+
if (mode === "local")
|
|
207
|
+
return "none";
|
|
208
|
+
if (mode === "cloud")
|
|
209
|
+
return config.cloudApiUrl ? "hosted_control_plane_contract" : "unconfigured";
|
|
210
|
+
if (config.databaseUrlPresent)
|
|
211
|
+
return "postgres_contract";
|
|
212
|
+
if (config.apiUrl)
|
|
213
|
+
return "api_control_plane_contract";
|
|
214
|
+
return "unconfigured";
|
|
215
|
+
}
|
|
216
|
+
function schedulerStateForMode(args) {
|
|
217
|
+
const nonLocal = args.deploymentMode !== "local";
|
|
218
|
+
return {
|
|
219
|
+
authority: args.sourceOfTruth,
|
|
220
|
+
localStore: {
|
|
221
|
+
backend: "sqlite",
|
|
222
|
+
role: args.localRole,
|
|
223
|
+
runArtifacts: "local_files",
|
|
224
|
+
routeAdmissionState: "workflow_work_items"
|
|
225
|
+
},
|
|
226
|
+
remoteStore: {
|
|
227
|
+
backend: remoteSchedulerBackendForMode(args.deploymentMode, args.config),
|
|
228
|
+
configured: nonLocal && args.controlPlaneConfigured,
|
|
229
|
+
applySupported: false,
|
|
230
|
+
objectArtifacts: nonLocal ? "object_store_contract" : "none",
|
|
231
|
+
mutatesAws: false
|
|
232
|
+
},
|
|
233
|
+
routeAdmission: {
|
|
234
|
+
stateStore: nonLocal ? "control_plane_contract" : "local_sqlite",
|
|
235
|
+
activeStatuses: ["admitted", "running"],
|
|
236
|
+
gates: ROUTE_ADMISSION_GATES,
|
|
237
|
+
dryRunEvaluatesLiveCounts: false
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
197
241
|
function displayControlPlaneUrl(value) {
|
|
198
242
|
if (!value)
|
|
199
243
|
return;
|
|
@@ -219,6 +263,9 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
219
263
|
if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
|
|
220
264
|
warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
|
|
221
265
|
}
|
|
266
|
+
if (deploymentMode === "self_hosted" && config.databaseUrlPresent && !config.apiUrl) {
|
|
267
|
+
warnings.push("LOOPS_DATABASE_URL selects the self_hosted storage contract; loops-runner still needs LOOPS_API_URL to claim remote work");
|
|
268
|
+
}
|
|
222
269
|
if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
|
|
223
270
|
warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
|
|
224
271
|
}
|
|
@@ -252,6 +299,13 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
252
299
|
required: deploymentMode !== "local",
|
|
253
300
|
role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
|
|
254
301
|
},
|
|
302
|
+
schedulerState: schedulerStateForMode({
|
|
303
|
+
deploymentMode,
|
|
304
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
305
|
+
localRole: deploymentMode === "local" ? "authoritative" : "cache_and_spool",
|
|
306
|
+
controlPlaneConfigured,
|
|
307
|
+
config
|
|
308
|
+
}),
|
|
255
309
|
warnings
|
|
256
310
|
};
|
|
257
311
|
}
|
|
@@ -264,6 +318,7 @@ function deploymentStatusLine(status) {
|
|
|
264
318
|
`source=${status.deploymentModeSource}`,
|
|
265
319
|
`truth=${status.sourceOfTruth}`,
|
|
266
320
|
`local=${status.localStore.role}`,
|
|
321
|
+
`scheduler=${status.schedulerState.routeAdmission.stateStore}`,
|
|
267
322
|
`control_plane=${configured}`
|
|
268
323
|
].join(" ");
|
|
269
324
|
}
|