@hasna/loops 0.4.12 → 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 +32 -0
- package/README.md +16 -5
- package/dist/api/index.js +56 -1
- package/dist/cli/index.js +121 -5
- package/dist/daemon/index.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +121 -5
- package/dist/lib/mode.d.ts +27 -0
- package/dist/lib/mode.js +56 -1
- package/dist/mcp/index.js +337 -130
- package/dist/runner/index.js +56 -1
- package/dist/sdk/index.js +71 -1
- 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,38 @@ 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
|
+
|
|
8
40
|
## 0.4.12 (2026-07-05)
|
|
9
41
|
|
|
10
42
|
Drain throughput: `--max-active` is now a per-route ceiling instead of a
|
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
|
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
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -4268,7 +4268,7 @@ class Store {
|
|
|
4268
4268
|
// package.json
|
|
4269
4269
|
var package_default = {
|
|
4270
4270
|
name: "@hasna/loops",
|
|
4271
|
-
version: "0.4.
|
|
4271
|
+
version: "0.4.13",
|
|
4272
4272
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4273
4273
|
type: "module",
|
|
4274
4274
|
main: "dist/index.js",
|
|
@@ -4459,6 +4459,50 @@ function sourceOfTruthForMode(mode) {
|
|
|
4459
4459
|
return "self_hosted_control_plane";
|
|
4460
4460
|
return "cloud_control_plane";
|
|
4461
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
|
+
}
|
|
4462
4506
|
function displayControlPlaneUrl(value) {
|
|
4463
4507
|
if (!value)
|
|
4464
4508
|
return;
|
|
@@ -4484,6 +4528,9 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
4484
4528
|
if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
|
|
4485
4529
|
warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
|
|
4486
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
|
+
}
|
|
4487
4534
|
if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
|
|
4488
4535
|
warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
|
|
4489
4536
|
}
|
|
@@ -4517,6 +4564,13 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
4517
4564
|
required: deploymentMode !== "local",
|
|
4518
4565
|
role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
|
|
4519
4566
|
},
|
|
4567
|
+
schedulerState: schedulerStateForMode({
|
|
4568
|
+
deploymentMode,
|
|
4569
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
4570
|
+
localRole: deploymentMode === "local" ? "authoritative" : "cache_and_spool",
|
|
4571
|
+
controlPlaneConfigured,
|
|
4572
|
+
config
|
|
4573
|
+
}),
|
|
4520
4574
|
warnings
|
|
4521
4575
|
};
|
|
4522
4576
|
}
|
|
@@ -4529,6 +4583,7 @@ function deploymentStatusLine(status) {
|
|
|
4529
4583
|
`source=${status.deploymentModeSource}`,
|
|
4530
4584
|
`truth=${status.sourceOfTruth}`,
|
|
4531
4585
|
`local=${status.localStore.role}`,
|
|
4586
|
+
`scheduler=${status.schedulerState.routeAdmission.stateStore}`,
|
|
4532
4587
|
`control_plane=${configured}`
|
|
4533
4588
|
].join(" ");
|
|
4534
4589
|
}
|
|
@@ -8147,6 +8202,21 @@ function runDoctor(store) {
|
|
|
8147
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" });
|
|
8148
8203
|
const failedRuns = store.countRuns("failed");
|
|
8149
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
|
+
});
|
|
8150
8220
|
for (const loop of store.listLoops({ status: "active" })) {
|
|
8151
8221
|
try {
|
|
8152
8222
|
if (loop.target.type === "workflow") {
|
|
@@ -9476,6 +9546,50 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
9476
9546
|
"console.log(`PR handoff complete: ${finalPrUrl}`);"
|
|
9477
9547
|
].join(`
|
|
9478
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
|
+
`);
|
|
9479
9593
|
function prHandoffCommand(opts) {
|
|
9480
9594
|
return [
|
|
9481
9595
|
"set -euo pipefail",
|
|
@@ -9486,12 +9600,14 @@ function prHandoffCommand(opts) {
|
|
|
9486
9600
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote3(opts.worktreeRoot)}`,
|
|
9487
9601
|
`export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote3(opts.expectedBranch)}`,
|
|
9488
9602
|
'if [ ! -s "$OPENLOOPS_PR_HANDOFF_ARTIFACT" ]; then',
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
"
|
|
9603
|
+
"bun - <<'OPENLOOPS_PR_HANDOFF_NOARTIFACT'",
|
|
9604
|
+
PR_HANDOFF_NO_ARTIFACT_SCRIPT,
|
|
9605
|
+
"OPENLOOPS_PR_HANDOFF_NOARTIFACT",
|
|
9606
|
+
"else",
|
|
9492
9607
|
"bun - <<'BUN'",
|
|
9493
9608
|
PR_HANDOFF_SCRIPT,
|
|
9494
|
-
"BUN"
|
|
9609
|
+
"BUN",
|
|
9610
|
+
"fi"
|
|
9495
9611
|
].join(`
|
|
9496
9612
|
`);
|
|
9497
9613
|
}
|
package/dist/daemon/index.js
CHANGED
|
@@ -7816,7 +7816,7 @@ function enableStartup(result) {
|
|
|
7816
7816
|
// package.json
|
|
7817
7817
|
var package_default = {
|
|
7818
7818
|
name: "@hasna/loops",
|
|
7819
|
-
version: "0.4.
|
|
7819
|
+
version: "0.4.13",
|
|
7820
7820
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7821
7821
|
type: "module",
|
|
7822
7822
|
main: "dist/index.js",
|
package/dist/index.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export { Store } from "./lib/store.js";
|
|
|
23
23
|
export { POSTGRES_MIGRATION_LEDGER_TABLE, POSTGRES_STORAGE_MIGRATIONS, PostgresStorage, SqliteLoopStorage, checksumStorageSql, createPostgresStorage, createSqliteLoopStorage, } from "./lib/storage/index.js";
|
|
24
24
|
export type { AppliedStorageMigration, AuditEventRecord, LoopStorageBackend, LoopStorageContract, LoopStorageMethodName, PostgresQueryExecutor, RunnerLeaseRecord, RunnerLeaseStatus, RunnerMachineRecord, RunnerMachineStatus, SchemaMigrationStorage, StorageMigration, StorageMigrationPlanItem, StorageMigrationResult, } from "./lib/storage/index.js";
|
|
25
25
|
export { LOOP_DEPLOYMENT_MODES, buildDeploymentStatus, deploymentStatusLine, loopControlPlaneConfig, normalizeLoopDeploymentMode, resolveLoopDeploymentMode, } from "./lib/mode.js";
|
|
26
|
-
export type { LoopControlPlaneConfig, LoopDeploymentMode, LoopDeploymentStatus, LoopModeResolution, LoopSourceOfTruth } from "./lib/mode.js";
|
|
26
|
+
export type { LoopControlPlaneConfig, LoopDeploymentMode, LoopDeploymentStatus, LoopModeResolution, LoopRemoteArtifactStore, LoopRemoteSchedulerBackend, LoopRouteAdmissionGate, LoopRouteAdmissionStateStore, LoopSchedulerStateStatus, LoopSourceOfTruth, } from "./lib/mode.js";
|
|
27
27
|
export { executeLoop, executeTarget, preflightTarget } from "./lib/executor.js";
|
|
28
28
|
export { executeLoopTarget, executeWorkflow, preflightWorkflow } from "./lib/workflow-runner.js";
|
|
29
29
|
export { workflowBodyFromJson, workflowExecutionOrder } from "./lib/workflow-spec.js";
|
package/dist/index.js
CHANGED
|
@@ -4266,7 +4266,7 @@ class Store {
|
|
|
4266
4266
|
// package.json
|
|
4267
4267
|
var package_default = {
|
|
4268
4268
|
name: "@hasna/loops",
|
|
4269
|
-
version: "0.4.
|
|
4269
|
+
version: "0.4.13",
|
|
4270
4270
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4271
4271
|
type: "module",
|
|
4272
4272
|
main: "dist/index.js",
|
|
@@ -4457,6 +4457,50 @@ function sourceOfTruthForMode(mode) {
|
|
|
4457
4457
|
return "self_hosted_control_plane";
|
|
4458
4458
|
return "cloud_control_plane";
|
|
4459
4459
|
}
|
|
4460
|
+
var ROUTE_ADMISSION_GATES = [
|
|
4461
|
+
"max_dispatch",
|
|
4462
|
+
"max_active",
|
|
4463
|
+
"max_active_per_project",
|
|
4464
|
+
"max_active_per_project_group",
|
|
4465
|
+
"max_active_scope",
|
|
4466
|
+
"max_per_profile"
|
|
4467
|
+
];
|
|
4468
|
+
function remoteSchedulerBackendForMode(mode, config) {
|
|
4469
|
+
if (mode === "local")
|
|
4470
|
+
return "none";
|
|
4471
|
+
if (mode === "cloud")
|
|
4472
|
+
return config.cloudApiUrl ? "hosted_control_plane_contract" : "unconfigured";
|
|
4473
|
+
if (config.databaseUrlPresent)
|
|
4474
|
+
return "postgres_contract";
|
|
4475
|
+
if (config.apiUrl)
|
|
4476
|
+
return "api_control_plane_contract";
|
|
4477
|
+
return "unconfigured";
|
|
4478
|
+
}
|
|
4479
|
+
function schedulerStateForMode(args) {
|
|
4480
|
+
const nonLocal = args.deploymentMode !== "local";
|
|
4481
|
+
return {
|
|
4482
|
+
authority: args.sourceOfTruth,
|
|
4483
|
+
localStore: {
|
|
4484
|
+
backend: "sqlite",
|
|
4485
|
+
role: args.localRole,
|
|
4486
|
+
runArtifacts: "local_files",
|
|
4487
|
+
routeAdmissionState: "workflow_work_items"
|
|
4488
|
+
},
|
|
4489
|
+
remoteStore: {
|
|
4490
|
+
backend: remoteSchedulerBackendForMode(args.deploymentMode, args.config),
|
|
4491
|
+
configured: nonLocal && args.controlPlaneConfigured,
|
|
4492
|
+
applySupported: false,
|
|
4493
|
+
objectArtifacts: nonLocal ? "object_store_contract" : "none",
|
|
4494
|
+
mutatesAws: false
|
|
4495
|
+
},
|
|
4496
|
+
routeAdmission: {
|
|
4497
|
+
stateStore: nonLocal ? "control_plane_contract" : "local_sqlite",
|
|
4498
|
+
activeStatuses: ["admitted", "running"],
|
|
4499
|
+
gates: ROUTE_ADMISSION_GATES,
|
|
4500
|
+
dryRunEvaluatesLiveCounts: false
|
|
4501
|
+
}
|
|
4502
|
+
};
|
|
4503
|
+
}
|
|
4460
4504
|
function displayControlPlaneUrl(value) {
|
|
4461
4505
|
if (!value)
|
|
4462
4506
|
return;
|
|
@@ -4482,6 +4526,9 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
4482
4526
|
if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
|
|
4483
4527
|
warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
|
|
4484
4528
|
}
|
|
4529
|
+
if (deploymentMode === "self_hosted" && config.databaseUrlPresent && !config.apiUrl) {
|
|
4530
|
+
warnings.push("LOOPS_DATABASE_URL selects the self_hosted storage contract; loops-runner still needs LOOPS_API_URL to claim remote work");
|
|
4531
|
+
}
|
|
4485
4532
|
if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
|
|
4486
4533
|
warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
|
|
4487
4534
|
}
|
|
@@ -4515,6 +4562,13 @@ function buildDeploymentStatus(opts = {}) {
|
|
|
4515
4562
|
required: deploymentMode !== "local",
|
|
4516
4563
|
role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
|
|
4517
4564
|
},
|
|
4565
|
+
schedulerState: schedulerStateForMode({
|
|
4566
|
+
deploymentMode,
|
|
4567
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
4568
|
+
localRole: deploymentMode === "local" ? "authoritative" : "cache_and_spool",
|
|
4569
|
+
controlPlaneConfigured,
|
|
4570
|
+
config
|
|
4571
|
+
}),
|
|
4518
4572
|
warnings
|
|
4519
4573
|
};
|
|
4520
4574
|
}
|
|
@@ -4527,6 +4581,7 @@ function deploymentStatusLine(status) {
|
|
|
4527
4581
|
`source=${status.deploymentModeSource}`,
|
|
4528
4582
|
`truth=${status.sourceOfTruth}`,
|
|
4529
4583
|
`local=${status.localStore.role}`,
|
|
4584
|
+
`scheduler=${status.schedulerState.routeAdmission.stateStore}`,
|
|
4530
4585
|
`control_plane=${configured}`
|
|
4531
4586
|
].join(" ");
|
|
4532
4587
|
}
|
|
@@ -5832,6 +5887,21 @@ function runDoctor(store) {
|
|
|
5832
5887
|
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" });
|
|
5833
5888
|
const failedRuns = store.countRuns("failed");
|
|
5834
5889
|
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` });
|
|
5890
|
+
const deployment = buildDeploymentStatus();
|
|
5891
|
+
const schedulerState = deployment.schedulerState;
|
|
5892
|
+
checks.push({
|
|
5893
|
+
id: "scheduler-state",
|
|
5894
|
+
status: deployment.deploymentMode === "local" || deployment.controlPlane.configured ? "ok" : "warn",
|
|
5895
|
+
message: `scheduler state authority=${schedulerState.authority} local=${schedulerState.localStore.role} remote=${schedulerState.remoteStore.backend}`,
|
|
5896
|
+
detail: [
|
|
5897
|
+
`route_state=${schedulerState.routeAdmission.stateStore}`,
|
|
5898
|
+
`active_statuses=${schedulerState.routeAdmission.activeStatuses.join(",")}`,
|
|
5899
|
+
`gates=${schedulerState.routeAdmission.gates.join(",")}`,
|
|
5900
|
+
`artifacts=${schedulerState.localStore.runArtifacts}`,
|
|
5901
|
+
`remote_artifacts=${schedulerState.remoteStore.objectArtifacts}`,
|
|
5902
|
+
`remote_apply=${String(schedulerState.remoteStore.applySupported)}`
|
|
5903
|
+
].join(" ")
|
|
5904
|
+
});
|
|
5835
5905
|
for (const loop of store.listLoops({ status: "active" })) {
|
|
5836
5906
|
try {
|
|
5837
5907
|
if (loop.target.type === "workflow") {
|
|
@@ -10216,6 +10286,50 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
10216
10286
|
"console.log(`PR handoff complete: ${finalPrUrl}`);"
|
|
10217
10287
|
].join(`
|
|
10218
10288
|
`);
|
|
10289
|
+
var PR_HANDOFF_NO_ARTIFACT_SCRIPT = [
|
|
10290
|
+
"const { spawnSync } = await import('node:child_process');",
|
|
10291
|
+
"const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
|
|
10292
|
+
"const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
|
|
10293
|
+
"const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
|
|
10294
|
+
"const worktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
|
|
10295
|
+
"const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
|
|
10296
|
+
"const todosBin = process.env.OPENLOOPS_PR_HANDOFF_TODOS_BIN || 'todos';",
|
|
10297
|
+
"const gitBin = process.env.OPENLOOPS_PR_HANDOFF_GIT_BIN || 'git';",
|
|
10298
|
+
"const ghBin = process.env.OPENLOOPS_PR_HANDOFF_GH_BIN || 'gh';",
|
|
10299
|
+
"process.stdout.write(`no PR handoff artifact at ${artifactPath}\\n`);",
|
|
10300
|
+
"const run = (command, args, options = {}) => {",
|
|
10301
|
+
" try { return spawnSync(command, args, { encoding: 'utf8', ...options }); }",
|
|
10302
|
+
" catch (error) { return { status: 1, stdout: '', stderr: String((error && error.message) || error) }; }",
|
|
10303
|
+
"};",
|
|
10304
|
+
"const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
|
|
10305
|
+
"const comment = (text) => {",
|
|
10306
|
+
" const result = run(todosBin, todosArgs('comment', taskId, text));",
|
|
10307
|
+
" if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
|
|
10308
|
+
"};",
|
|
10309
|
+
"const main = () => {",
|
|
10310
|
+
" let branch = expectedBranch;",
|
|
10311
|
+
" if (!branch) {",
|
|
10312
|
+
" const shown = run(gitBin, ['-C', worktree, 'branch', '--show-current']);",
|
|
10313
|
+
" branch = String((shown.status === 0 ? shown.stdout : '') || '').trim();",
|
|
10314
|
+
" }",
|
|
10315
|
+
" if (!branch) { console.log('pr-handoff: no artifact and no resolvable branch; nothing to hand off'); return; }",
|
|
10316
|
+
" const listed = run(ghBin, ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'url,number,headRefName,headRefOid'], { cwd: worktree });",
|
|
10317
|
+
" 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; }",
|
|
10318
|
+
" let prs = [];",
|
|
10319
|
+
" try { prs = JSON.parse(String(listed.stdout || '[]')); } catch { prs = []; }",
|
|
10320
|
+
" const pr = Array.isArray(prs) ? prs.find((entry) => entry && entry.headRefName === branch && typeof entry.url === 'string' && entry.url) : undefined;",
|
|
10321
|
+
" if (!pr) { console.log(`pr-handoff: no artifact and no open PR for branch ${branch}; worker completed without opening a PR`); return; }",
|
|
10322
|
+
" let commit = String(pr.headRefOid || '').trim();",
|
|
10323
|
+
" if (!commit) {",
|
|
10324
|
+
" const head = run(gitBin, ['-C', worktree, 'rev-parse', 'HEAD']);",
|
|
10325
|
+
" commit = String((head.status === 0 ? head.stdout : '') || '').trim();",
|
|
10326
|
+
" }",
|
|
10327
|
+
" comment(`openloops:pr-handoff=done task=${taskId} pr=${pr.url} commit=${commit || 'unknown'} branch=${branch}`);",
|
|
10328
|
+
" console.log(`PR handoff complete (worker-opened PR): ${pr.url}`);",
|
|
10329
|
+
"};",
|
|
10330
|
+
"try { main(); } catch (error) { console.error(`pr-handoff no-artifact detection error (ignored): ${String((error && error.message) || error)}`); }"
|
|
10331
|
+
].join(`
|
|
10332
|
+
`);
|
|
10219
10333
|
function prHandoffCommand(opts) {
|
|
10220
10334
|
return [
|
|
10221
10335
|
"set -euo pipefail",
|
|
@@ -10226,12 +10340,14 @@ function prHandoffCommand(opts) {
|
|
|
10226
10340
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote2(opts.worktreeRoot)}`,
|
|
10227
10341
|
`export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote2(opts.expectedBranch)}`,
|
|
10228
10342
|
'if [ ! -s "$OPENLOOPS_PR_HANDOFF_ARTIFACT" ]; then',
|
|
10229
|
-
|
|
10230
|
-
|
|
10231
|
-
"
|
|
10343
|
+
"bun - <<'OPENLOOPS_PR_HANDOFF_NOARTIFACT'",
|
|
10344
|
+
PR_HANDOFF_NO_ARTIFACT_SCRIPT,
|
|
10345
|
+
"OPENLOOPS_PR_HANDOFF_NOARTIFACT",
|
|
10346
|
+
"else",
|
|
10232
10347
|
"bun - <<'BUN'",
|
|
10233
10348
|
PR_HANDOFF_SCRIPT,
|
|
10234
|
-
"BUN"
|
|
10349
|
+
"BUN",
|
|
10350
|
+
"fi"
|
|
10235
10351
|
].join(`
|
|
10236
10352
|
`);
|
|
10237
10353
|
}
|
package/dist/lib/mode.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export declare const LOOP_DEPLOYMENT_MODES: readonly ["local", "self_hosted", "cloud"];
|
|
2
2
|
export type LoopDeploymentMode = (typeof LOOP_DEPLOYMENT_MODES)[number];
|
|
3
3
|
export type LoopSourceOfTruth = "local_sqlite" | "self_hosted_control_plane" | "cloud_control_plane";
|
|
4
|
+
export type LoopRemoteSchedulerBackend = "none" | "unconfigured" | "api_control_plane_contract" | "postgres_contract" | "hosted_control_plane_contract";
|
|
5
|
+
export type LoopRemoteArtifactStore = "none" | "object_store_contract";
|
|
6
|
+
export type LoopRouteAdmissionStateStore = "local_sqlite" | "control_plane_contract";
|
|
7
|
+
export type LoopRouteAdmissionGate = "max_dispatch" | "max_active" | "max_active_per_project" | "max_active_per_project_group" | "max_active_scope" | "max_per_profile";
|
|
4
8
|
export interface LoopModeResolution {
|
|
5
9
|
deploymentMode: LoopDeploymentMode;
|
|
6
10
|
source: string;
|
|
@@ -34,8 +38,31 @@ export interface LoopDeploymentStatus {
|
|
|
34
38
|
required: boolean;
|
|
35
39
|
role: "daemon" | "control_plane_worker";
|
|
36
40
|
};
|
|
41
|
+
schedulerState: LoopSchedulerStateStatus;
|
|
37
42
|
warnings: string[];
|
|
38
43
|
}
|
|
44
|
+
export interface LoopSchedulerStateStatus {
|
|
45
|
+
authority: LoopSourceOfTruth;
|
|
46
|
+
localStore: {
|
|
47
|
+
backend: "sqlite";
|
|
48
|
+
role: "authoritative" | "cache_and_spool";
|
|
49
|
+
runArtifacts: "local_files";
|
|
50
|
+
routeAdmissionState: "workflow_work_items";
|
|
51
|
+
};
|
|
52
|
+
remoteStore: {
|
|
53
|
+
backend: LoopRemoteSchedulerBackend;
|
|
54
|
+
configured: boolean;
|
|
55
|
+
applySupported: boolean;
|
|
56
|
+
objectArtifacts: LoopRemoteArtifactStore;
|
|
57
|
+
mutatesAws: false;
|
|
58
|
+
};
|
|
59
|
+
routeAdmission: {
|
|
60
|
+
stateStore: LoopRouteAdmissionStateStore;
|
|
61
|
+
activeStatuses: readonly ["admitted", "running"];
|
|
62
|
+
gates: readonly LoopRouteAdmissionGate[];
|
|
63
|
+
dryRunEvaluatesLiveCounts: false;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
39
66
|
type Env = Record<string, string | undefined>;
|
|
40
67
|
export declare function normalizeLoopDeploymentMode(value: string): LoopDeploymentMode;
|
|
41
68
|
export declare function resolveLoopDeploymentMode(env?: Env): LoopModeResolution;
|