@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.
@@ -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
  }
package/dist/sdk/index.js CHANGED
@@ -1321,6 +1321,7 @@ function rowToWorkflowWorkItem(row) {
1321
1321
  subjectRef: row.subject_ref,
1322
1322
  projectKey: row.project_key ?? undefined,
1323
1323
  projectGroup: row.project_group ?? undefined,
1324
+ routeScope: row.route_scope ?? undefined,
1324
1325
  priority: row.priority,
1325
1326
  status: row.status,
1326
1327
  attempts: row.attempts,
@@ -1601,6 +1602,13 @@ class Store {
1601
1602
  this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
1602
1603
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
1603
1604
  }
1605
+ },
1606
+ {
1607
+ id: "0008_work_item_route_scope",
1608
+ apply: () => {
1609
+ this.addColumnIfMissing("workflow_work_items", "route_scope", "TEXT");
1610
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_scope ON workflow_work_items(route_scope, status)");
1611
+ }
1604
1612
  }
1605
1613
  ];
1606
1614
  }
@@ -1748,6 +1756,7 @@ class Store {
1748
1756
  subject_ref TEXT NOT NULL,
1749
1757
  project_key TEXT,
1750
1758
  project_group TEXT,
1759
+ route_scope TEXT,
1751
1760
  priority INTEGER NOT NULL,
1752
1761
  status TEXT NOT NULL,
1753
1762
  attempts INTEGER NOT NULL,
@@ -1765,6 +1774,13 @@ class Store {
1765
1774
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
1766
1775
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
1767
1776
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
1777
+ -- idx_workflow_work_items_scope (route_scope, status) is created ONLY by
1778
+ -- migration 0008_work_item_route_scope, never here: this baseline DDL
1779
+ -- re-runs on EVERY open (0001 is not skip-guarded), and on a pre-0008
1780
+ -- database the CREATE TABLE above is a no-op, so an index on route_scope
1781
+ -- here would execute before the column exists and crash the open
1782
+ -- ("no such column: route_scope"). New columns may be folded into the
1783
+ -- CREATE TABLE (fresh-db only); their indexes must live in the migration.
1768
1784
 
1769
1785
  CREATE TABLE IF NOT EXISTS workflow_step_runs (
1770
1786
  id TEXT PRIMARY KEY,
@@ -2556,10 +2572,10 @@ class Store {
2556
2572
  const id = genId();
2557
2573
  const status = input.status ?? "queued";
2558
2574
  this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
2559
- subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
2575
+ subject_ref, project_key, project_group, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
2560
2576
  workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
2561
2577
  VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
2562
- $projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
2578
+ $projectKey, $projectGroup, $routeScope, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
2563
2579
  $lastReason, $created, $updated)
2564
2580
  ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
2565
2581
  invocation_id=excluded.invocation_id,
@@ -2568,6 +2584,7 @@ class Store {
2568
2584
  subject_ref=excluded.subject_ref,
2569
2585
  project_key=excluded.project_key,
2570
2586
  project_group=excluded.project_group,
2587
+ route_scope=excluded.route_scope,
2571
2588
  priority=excluded.priority,
2572
2589
  status=CASE
2573
2590
  WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
@@ -2609,6 +2626,7 @@ class Store {
2609
2626
  $subjectRef: input.subjectRef,
2610
2627
  $projectKey: input.projectKey ?? null,
2611
2628
  $projectGroup: input.projectGroup ?? null,
2629
+ $routeScope: input.routeScope ?? null,
2612
2630
  $priority: input.priority ?? 0,
2613
2631
  $status: status,
2614
2632
  $nextAttemptAt: input.nextAttemptAt ?? null,
@@ -2646,11 +2664,21 @@ class Store {
2646
2664
  countActiveWorkflowWorkItems(args = {}) {
2647
2665
  const active = ["admitted", "running"];
2648
2666
  const placeholders = active.map(() => "?").join(",");
2649
- const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
2667
+ const routeScope = args.routeScope?.trim() || undefined;
2668
+ const global = routeScope ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND route_scope = ?`).get(...active, routeScope)?.count ?? 0 : this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
2650
2669
  const project = args.projectKey ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_key = ?`).get(...active, args.projectKey)?.count ?? 0 : 0;
2651
2670
  const projectGroup = args.projectGroup ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_group = ?`).get(...active, args.projectGroup)?.count ?? 0 : undefined;
2652
2671
  return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
2653
2672
  }
2673
+ countRunningWorkflowStepsByAuthProfile() {
2674
+ const rows = this.db.query("SELECT account_profile, COUNT(*) AS count FROM workflow_step_runs WHERE status = 'running' AND account_profile IS NOT NULL GROUP BY account_profile").all();
2675
+ const counts = {};
2676
+ for (const row of rows) {
2677
+ if (row.account_profile)
2678
+ counts[row.account_profile] = row.count;
2679
+ }
2680
+ return counts;
2681
+ }
2654
2682
  requeueWorkflowWorkItem(id, patch = {}) {
2655
2683
  const current = this.getWorkflowWorkItem(id);
2656
2684
  if (!current)
@@ -3086,6 +3114,9 @@ class Store {
3086
3114
  }
3087
3115
  input.workflow.steps.forEach((step, sequence) => {
3088
3116
  const account = step.account ?? step.target.account;
3117
+ const agentTarget = step.target.type === "agent" ? step.target : undefined;
3118
+ const resolvedProfile = account?.profile ?? agentTarget?.authProfile ?? null;
3119
+ const resolvedTool = account?.tool ?? (agentTarget?.authProfile ? agentTarget.provider : null);
3089
3120
  this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
3090
3121
  exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
3091
3122
  VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
@@ -3094,8 +3125,8 @@ class Store {
3094
3125
  $workflowRunId: runId,
3095
3126
  $stepId: step.id,
3096
3127
  $sequence: sequence,
3097
- $accountProfile: account?.profile ?? null,
3098
- $accountTool: account?.tool ?? null,
3128
+ $accountProfile: resolvedProfile,
3129
+ $accountTool: resolvedTool,
3099
3130
  $created: now,
3100
3131
  $updated: now
3101
3132
  });
@@ -4235,7 +4266,7 @@ class Store {
4235
4266
  // package.json
4236
4267
  var package_default = {
4237
4268
  name: "@hasna/loops",
4238
- version: "0.4.11",
4269
+ version: "0.4.13",
4239
4270
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4240
4271
  type: "module",
4241
4272
  main: "dist/index.js",
@@ -4426,6 +4457,50 @@ function sourceOfTruthForMode(mode) {
4426
4457
  return "self_hosted_control_plane";
4427
4458
  return "cloud_control_plane";
4428
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
+ }
4429
4504
  function displayControlPlaneUrl(value) {
4430
4505
  if (!value)
4431
4506
  return;
@@ -4451,6 +4526,9 @@ function buildDeploymentStatus(opts = {}) {
4451
4526
  if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
4452
4527
  warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
4453
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
+ }
4454
4532
  if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
4455
4533
  warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
4456
4534
  }
@@ -4484,6 +4562,13 @@ function buildDeploymentStatus(opts = {}) {
4484
4562
  required: deploymentMode !== "local",
4485
4563
  role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
4486
4564
  },
4565
+ schedulerState: schedulerStateForMode({
4566
+ deploymentMode,
4567
+ sourceOfTruth: sourceOfTruthForMode(deploymentMode),
4568
+ localRole: deploymentMode === "local" ? "authoritative" : "cache_and_spool",
4569
+ controlPlaneConfigured,
4570
+ config
4571
+ }),
4487
4572
  warnings
4488
4573
  };
4489
4574
  }
@@ -4496,6 +4581,7 @@ function deploymentStatusLine(status) {
4496
4581
  `source=${status.deploymentModeSource}`,
4497
4582
  `truth=${status.sourceOfTruth}`,
4498
4583
  `local=${status.localStore.role}`,
4584
+ `scheduler=${status.schedulerState.routeAdmission.stateStore}`,
4499
4585
  `control_plane=${configured}`
4500
4586
  ].join(" ");
4501
4587
  }
@@ -5796,6 +5882,21 @@ function runDoctor(store) {
5796
5882
  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" });
5797
5883
  const failedRuns = store.countRuns("failed");
5798
5884
  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` });
5885
+ const deployment = buildDeploymentStatus();
5886
+ const schedulerState = deployment.schedulerState;
5887
+ checks.push({
5888
+ id: "scheduler-state",
5889
+ status: deployment.deploymentMode === "local" || deployment.controlPlane.configured ? "ok" : "warn",
5890
+ message: `scheduler state authority=${schedulerState.authority} local=${schedulerState.localStore.role} remote=${schedulerState.remoteStore.backend}`,
5891
+ detail: [
5892
+ `route_state=${schedulerState.routeAdmission.stateStore}`,
5893
+ `active_statuses=${schedulerState.routeAdmission.activeStatuses.join(",")}`,
5894
+ `gates=${schedulerState.routeAdmission.gates.join(",")}`,
5895
+ `artifacts=${schedulerState.localStore.runArtifacts}`,
5896
+ `remote_artifacts=${schedulerState.remoteStore.objectArtifacts}`,
5897
+ `remote_apply=${String(schedulerState.remoteStore.applySupported)}`
5898
+ ].join(" ")
5899
+ });
5799
5900
  for (const loop of store.listLoops({ status: "active" })) {
5800
5901
  try {
5801
5902
  if (loop.target.type === "workflow") {
package/dist/types.d.ts CHANGED
@@ -104,6 +104,9 @@ export interface AgentRoutingSpec {
104
104
  eventId?: string;
105
105
  eventType?: string;
106
106
  eventSource?: string;
107
+ /** Lifecycle role of this agent step (triage/planner/worker/verifier). Used
108
+ * for per-account attribution and least-loaded auth-profile pool selection. */
109
+ role?: "triage" | "planner" | "worker" | "verifier";
107
110
  }
108
111
  export interface AgentPromptSource {
109
112
  type: "file";
@@ -209,6 +212,12 @@ export interface WorkflowWorkItem {
209
212
  subjectRef: string;
210
213
  projectKey?: string;
211
214
  projectGroup?: string;
215
+ /**
216
+ * The drain/route identity (loop name) that admitted this item. Used to scope
217
+ * the `--max-active` global admission count to a single route instead of the
218
+ * whole store. Undefined for items created before route-scope support.
219
+ */
220
+ routeScope?: string;
212
221
  priority: number;
213
222
  status: WorkflowWorkItemStatus;
214
223
  attempts: number;
@@ -230,6 +239,7 @@ export interface UpsertWorkflowWorkItemInput {
230
239
  subjectRef: string;
231
240
  projectKey?: string;
232
241
  projectGroup?: string;
242
+ routeScope?: string;
233
243
  priority?: number;
234
244
  status?: Extract<WorkflowWorkItemStatus, "queued" | "deferred">;
235
245
  nextAttemptAt?: string;
@@ -68,7 +68,7 @@ loops import ./loops-export.json --apply
68
68
  Human status output is intentionally compact:
69
69
 
70
70
  ```text
71
- deploymentMode=local active source=default truth=local_sqlite local=authoritative control_plane=none
71
+ deploymentMode=local active source=default truth=local_sqlite local=authoritative scheduler=local_sqlite control_plane=none
72
72
  ```
73
73
 
74
74
  JSON uses these field names:
@@ -85,6 +85,22 @@ JSON uses these field names:
85
85
  presence signal.
86
86
  - `controlPlane.apiUrl`: a display-safe URL without credentials, query string,
87
87
  or fragment.
88
+ - `schedulerState.localStore`: always names the local SQLite store and local
89
+ run artifact files. In `local` mode this store is authoritative; in
90
+ non-local modes it is a cache, offline spool, and audit copy.
91
+ - `schedulerState.remoteStore`: names the remote scheduler contract:
92
+ `api_control_plane_contract`, `postgres_contract`,
93
+ `hosted_control_plane_contract`, `unconfigured`, or `none`. Remote apply is
94
+ `false` in this public package until a control-plane host wires a full
95
+ storage adapter.
96
+ - `schedulerState.remoteStore.objectArtifacts`: `object_store_contract` means
97
+ remote artifact/object storage is a control-plane contract. The public package
98
+ does not create or mutate S3 buckets, AWS resources, or hosted credentials.
99
+ - `schedulerState.routeAdmission`: names the active route-state store and the
100
+ bounded gates (`max_dispatch`, `max_active`, `max_active_per_project`,
101
+ `max_active_per_project_group`, `max_active_scope`, and `max_per_profile`).
102
+ Live active counts use admitted/running work items; dry-runs do not open or
103
+ migrate the live store to compute counts.
88
104
 
89
105
  `loops-api` is a separate process in the same public package. It is not a
90
106
  separate package at this stage because self-hosted users and the hosted service
@@ -150,6 +166,12 @@ would generate new ids, so it is not a no-loss migration. Local SQLite remains
150
166
  authoritative until a safe import is applied; in non-local modes it may remain
151
167
  a cache, offline spool, and audit copy.
152
168
 
169
+ `LOOPS_DATABASE_URL` selects the self-hosted Postgres scheduler-state contract,
170
+ but it does not make the standalone CLI mutate a remote database by itself.
171
+ Remote execution still flows through a configured control-plane API and runner
172
+ protocol. `loops-runner` needs `LOOPS_API_URL` or `HASNA_LOOPS_API_URL` to claim
173
+ work; a database URL alone is migration/readiness configuration.
174
+
153
175
  `loops self-hosted runner-register` is also preview-only unless `--apply` is
154
176
  present. The dry run prints the runner id, machine id, labels, and
155
177
  capabilities that would be posted, without exposing tokens.
package/docs/USAGE.md CHANGED
@@ -25,6 +25,16 @@ planes:
25
25
  status only, and requires `LOOPS_CLOUD_API_URL` plus `LOOPS_CLOUD_TOKEN` or
26
26
  `HASNA_LOOPS_CLOUD_TOKEN` before status can report ready.
27
27
 
28
+ Scheduler state is explicit in status JSON. `schedulerState.localStore` is
29
+ SQLite plus local run artifact files: authoritative in `local`, cache/spool in
30
+ non-local modes. `schedulerState.remoteStore` names the non-local contract
31
+ (`api_control_plane_contract`, `postgres_contract`, or
32
+ `hosted_control_plane_contract`) and reports `applySupported=false` because this
33
+ public package does not directly mutate remote Postgres, S3/object storage, AWS
34
+ resources, or hosted credentials. Route admission remains bounded by
35
+ `max_dispatch`, `max_active`, `max_active_per_project`,
36
+ `max_active_per_project_group`, `max_active_scope`, and `max_per_profile`.
37
+
28
38
  Useful status commands:
29
39
 
30
40
  ```bash
@@ -674,11 +684,12 @@ idempotency key before rendering worktree plans or checking route limits. In
674
684
  dry-run mode, throttle counts are not evaluated because opening the live loop
675
685
  store can create or migrate the local database.
676
686
  Terminal routed work items such as failed, dead-letter, cancelled, or succeeded
677
- history remain deduped until an operator runs `loops routes requeue
678
- <work-item-id> --reason "<cause fixed>"`; a later drain should not create
679
- another worker just because the previous workflow ended. The next route-created
680
- output records `requeue` evidence with the previous work item id, previous
681
- attempts, operator reason, new attempt, workflow id, and loop id.
687
+ history are re-admitted only when the todos task is still actionable, the
688
+ per-attempt backoff has elapsed, and the redispatch cap has not been reached.
689
+ Operators can still force a retry with `loops routes requeue <work-item-id>
690
+ --reason "<cause fixed>"`. The next route-created output records `requeue`
691
+ evidence with the previous work item id, previous attempts, reason, new attempt,
692
+ workflow id, and loop id.
682
693
 
683
694
  When a sandboxed Codewith/Codex worker must update app stores outside the repo
684
695
  worktree, pass those stores explicitly with `--add-dir` or template `addDirs`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",