@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 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 stay deduped until an
528
- operator runs `loops routes requeue <work-item-id> --reason "<cause fixed>"`.
529
- The next route-created output records `requeue` evidence with the previous work
530
- item id, previous attempts, operator reason, new attempt, workflow id, and loop
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; OpenLoops picks deterministically
598
- and uses a different verifier profile when the pool has at least two entries.
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.11",
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
  }