@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 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 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
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.12",
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.12",
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
- ` printf 'no PR handoff artifact at %s\\n' "$OPENLOOPS_PR_HANDOFF_ARTIFACT"`,
9490
- " exit 0",
9491
- "fi",
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
  }
@@ -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.12",
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.12",
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
- ` printf 'no PR handoff artifact at %s\\n' "$OPENLOOPS_PR_HANDOFF_ARTIFACT"`,
10230
- " exit 0",
10231
- "fi",
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
  }
@@ -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;