@hasna/loops 0.4.26 → 0.4.28

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.
@@ -1571,6 +1571,7 @@ var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
1571
1571
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
1572
1572
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
1573
1573
  var SCHEMA_USER_VERSION = 8;
1574
+ var BREAKING_SCHEMA_FLOOR = 7;
1574
1575
  var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
1575
1576
  var PRUNE_BATCH_SIZE = 400;
1576
1577
  var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
@@ -1713,6 +1714,7 @@ function rowToWorkflowWorkItem(row) {
1713
1714
  priority: row.priority,
1714
1715
  status: row.status,
1715
1716
  attempts: row.attempts,
1717
+ gateDeaths: row.gate_deaths ?? 0,
1716
1718
  nextAttemptAt: row.next_attempt_at ?? undefined,
1717
1719
  leaseExpiresAt: row.lease_expires_at ?? undefined,
1718
1720
  workflowId: row.workflow_id ?? undefined,
@@ -1862,6 +1864,23 @@ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
1862
1864
  }
1863
1865
  return;
1864
1866
  }
1867
+ var WORK_ITEM_TEMPFAIL_EXIT_CODE = 75;
1868
+ var GATE_STEP_IDS = new Set(["triage", "planner", "plan"]);
1869
+ var GATE_DEATH_MAX_DURATION_MS = 60000;
1870
+ var GATE_DEATH_CEILING = 20;
1871
+ function classifyNonProductiveStepFailure(steps) {
1872
+ const failing = [...steps].reverse().find((step) => step.status === "failed" || step.status === "timed_out");
1873
+ if (!failing)
1874
+ return;
1875
+ if (failing.exitCode === WORK_ITEM_TEMPFAIL_EXIT_CODE)
1876
+ return "tempfail";
1877
+ if (typeof failing.error === "string" && failing.error.includes("worktree preparation failed"))
1878
+ return "gate-death";
1879
+ const fast = failing.durationMs === undefined || failing.durationMs < GATE_DEATH_MAX_DURATION_MS;
1880
+ if (GATE_STEP_IDS.has(failing.stepId) && fast)
1881
+ return "gate-death";
1882
+ return;
1883
+ }
1865
1884
  function scrubbedOrNull(value) {
1866
1885
  return value == null ? null : scrubSecrets(value);
1867
1886
  }
@@ -1962,11 +1981,21 @@ class Store {
1962
1981
  id TEXT PRIMARY KEY,
1963
1982
  applied_at TEXT NOT NULL
1964
1983
  );
1984
+ CREATE TABLE IF NOT EXISTS schema_compat (
1985
+ id INTEGER PRIMARY KEY CHECK (id = 1),
1986
+ min_compatible_user_version INTEGER NOT NULL
1987
+ );
1965
1988
  `);
1966
1989
  const versionRow = this.db.query("PRAGMA user_version").get();
1967
1990
  const userVersion = versionRow?.user_version ?? 0;
1968
1991
  if (userVersion > SCHEMA_USER_VERSION) {
1969
- throw new Error(`loops database schema version ${userVersion} is newer than this binary supports (${SCHEMA_USER_VERSION}); upgrade open-loops before opening this database`);
1992
+ const floorRow = this.db.query("SELECT min_compatible_user_version FROM schema_compat WHERE id = 1").get();
1993
+ if (!floorRow) {
1994
+ throw new Error(`loops database schema version ${userVersion} is newer than this binary supports (${SCHEMA_USER_VERSION}) and carries no compatibility floor; upgrade open-loops before opening this database`);
1995
+ }
1996
+ if (SCHEMA_USER_VERSION < floorRow.min_compatible_user_version) {
1997
+ throw new Error(`loops database schema version ${userVersion} requires a binary with schema support >= ${floorRow.min_compatible_user_version} (this binary supports ${SCHEMA_USER_VERSION}); upgrade open-loops before opening this database`);
1998
+ }
1970
1999
  }
1971
2000
  const applied = new Set(this.db.query("SELECT id FROM schema_migrations").all().map((row) => row.id));
1972
2001
  for (const migration of this.migrations()) {
@@ -1977,8 +2006,10 @@ class Store {
1977
2006
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run(migration.id, nowIso());
1978
2007
  }
1979
2008
  }
1980
- if (userVersion !== SCHEMA_USER_VERSION)
2009
+ if (userVersion < SCHEMA_USER_VERSION)
1981
2010
  this.db.exec(`PRAGMA user_version = ${SCHEMA_USER_VERSION}`);
2011
+ this.db.query(`INSERT INTO schema_compat (id, min_compatible_user_version) VALUES (1, ?)
2012
+ ON CONFLICT(id) DO UPDATE SET min_compatible_user_version = MAX(min_compatible_user_version, excluded.min_compatible_user_version)`).run(BREAKING_SCHEMA_FLOOR);
1982
2013
  }
1983
2014
  migrations() {
1984
2015
  return [
@@ -2045,6 +2076,12 @@ class Store {
2045
2076
  this.addColumnIfMissing("workflow_work_items", "machine_id", "TEXT");
2046
2077
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_machine ON workflow_work_items(machine_id, status)");
2047
2078
  }
2079
+ },
2080
+ {
2081
+ id: "0011_work_item_gate_deaths",
2082
+ apply: () => {
2083
+ this.addColumnIfMissing("workflow_work_items", "gate_deaths", "INTEGER NOT NULL DEFAULT 0");
2084
+ }
2048
2085
  }
2049
2086
  ];
2050
2087
  }
@@ -3175,7 +3212,7 @@ class Store {
3175
3212
  }
3176
3213
  upsertWorkflowWorkItem(input) {
3177
3214
  const now = nowIso();
3178
- const id = genId();
3215
+ const id = input.id ?? genId();
3179
3216
  const status = input.status ?? "queued";
3180
3217
  this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
3181
3218
  subject_ref, project_key, project_group, machine_id, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
@@ -3303,6 +3340,7 @@ class Store {
3303
3340
  const placeholders = requeueableStatuses.map(() => "?").join(",");
3304
3341
  const res = this.db.query(`UPDATE workflow_work_items
3305
3342
  SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
3343
+ ${patch.resetAttempts ? "attempts=0, gate_deaths=0," : ""}
3306
3344
  next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
3307
3345
  WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
3308
3346
  const item = this.getWorkflowWorkItem(id);
@@ -3312,6 +3350,46 @@ class Store {
3312
3350
  throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
3313
3351
  return item;
3314
3352
  }
3353
+ deadLetterWorkflowWorkItem(id, patch = {}) {
3354
+ const now = nowIso();
3355
+ const reason = patch.reason?.trim() || "redispatch cap reached; dead-lettered";
3356
+ this.db.query(`UPDATE workflow_work_items
3357
+ SET status='dead_letter', next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
3358
+ WHERE id=? AND status IN ('succeeded','failed','cancelled')`).run(reason, now, id);
3359
+ const item = this.getWorkflowWorkItem(id);
3360
+ if (!item)
3361
+ throw new Error(`workflow work item not found after dead-letter: ${id}`);
3362
+ return item;
3363
+ }
3364
+ demoteNonProductiveWorkItems(workflowRunId, finishedAt) {
3365
+ const kind = classifyNonProductiveStepFailure(this.listWorkflowStepRuns(workflowRunId));
3366
+ if (!kind) {
3367
+ this.db.query("UPDATE workflow_work_items SET gate_deaths=0, updated_at=? WHERE workflow_run_id=? AND status='failed' AND gate_deaths > 0").run(finishedAt, workflowRunId);
3368
+ return;
3369
+ }
3370
+ if (kind === "tempfail") {
3371
+ this.db.query(`UPDATE workflow_work_items
3372
+ SET status='queued', attempts=CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END,
3373
+ gate_deaths=0,
3374
+ workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
3375
+ next_attempt_at=NULL, lease_expires_at=NULL,
3376
+ last_reason='worker exited 75 (tempfail): requeued for retry; attempt refunded (does not count toward redispatch cap)',
3377
+ updated_at=?
3378
+ WHERE workflow_run_id=? AND status='failed'`).run(finishedAt, workflowRunId);
3379
+ return;
3380
+ }
3381
+ this.db.query(`UPDATE workflow_work_items
3382
+ SET attempts=CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END,
3383
+ gate_deaths=gate_deaths + 1,
3384
+ status=CASE WHEN gate_deaths + 1 >= ${GATE_DEATH_CEILING} THEN 'dead_letter' ELSE status END,
3385
+ last_reason=CASE
3386
+ WHEN gate_deaths + 1 >= ${GATE_DEATH_CEILING}
3387
+ THEN 'gate-death ceiling reached (' || (gate_deaths + 1) || '/${GATE_DEATH_CEILING} consecutive runs died at worktree prep / triage / planner without reaching the worker): dead-lettered \u2014 the infrastructure fault needs an operator; ''loops routes requeue'' resets and retries'
3388
+ ELSE 'gate death before real work (worktree prep / triage / planner): attempt refunded (does not count toward redispatch cap); consecutive gate deaths: ' || (gate_deaths + 1) || '/${GATE_DEATH_CEILING}'
3389
+ END,
3390
+ updated_at=?
3391
+ WHERE workflow_run_id=? AND status='failed'`).run(finishedAt, workflowRunId);
3392
+ }
3315
3393
  admitWorkflowWorkItem(id, patch) {
3316
3394
  const now = nowIso();
3317
3395
  const res = this.db.query(`UPDATE workflow_work_items
@@ -4023,6 +4101,8 @@ class Store {
4023
4101
  if (changed) {
4024
4102
  const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
4025
4103
  this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, error, finishedAt);
4104
+ if (itemStatus === "failed")
4105
+ this.demoteNonProductiveWorkItems(workflowRunId, finishedAt);
4026
4106
  this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
4027
4107
  }
4028
4108
  this.db.exec("COMMIT");
@@ -6425,11 +6505,30 @@ function remoteWorktreePrepareLines(worktree) {
6425
6505
  ' git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "worktree repoRoot is not a git repository: $repo" >&2; return 1; }',
6426
6506
  ' mkdir -p "$(dirname "$path")" || return 1',
6427
6507
  " # Preparation chatter goes to stderr so run stdout stays the agent's.",
6428
- ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
6429
- ' git -C "$repo" worktree add "$path" "$branch" 1>&2 || return 1',
6430
- " else",
6431
- ' git -C "$repo" worktree add -b "$branch" "$path" HEAD 1>&2 || return 1',
6508
+ " __openloops_worktree_add() {",
6509
+ ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
6510
+ ' git -C "$repo" worktree add "$path" "$branch"',
6511
+ " else",
6512
+ ' git -C "$repo" worktree add -b "$branch" "$path" HEAD',
6513
+ " fi",
6514
+ " }",
6515
+ " local __ol_add_out",
6516
+ ' if __ol_add_out="$(__openloops_worktree_add 2>&1)"; then',
6517
+ ' if [ -n "$__ol_add_out" ]; then printf "%s\\n" "$__ol_add_out" >&2; fi',
6518
+ " return 0",
6432
6519
  " fi",
6520
+ ' printf "%s\\n" "$__ol_add_out" >&2',
6521
+ " # Self-heal git's own remedy for a stale 'missing but already registered",
6522
+ " # worktree': prune the dead registration (metadata-only; the directory is",
6523
+ " # already gone) and retry the add exactly once, then fail honestly.",
6524
+ ' case "$__ol_add_out" in',
6525
+ ' *"missing but already registered worktree"*)',
6526
+ ' git -C "$repo" worktree prune 1>&2 || true',
6527
+ " __openloops_worktree_add 1>&2 || return 1",
6528
+ " return 0",
6529
+ " ;;",
6530
+ " esac",
6531
+ " return 1",
6433
6532
  "}"
6434
6533
  ];
6435
6534
  }
@@ -6569,6 +6668,9 @@ async function preflightNativeAuthProfile(spec, env) {
6569
6668
  function spawnDetail(result) {
6570
6669
  return (result.stderr || result.stdout || result.error || "").toString().trim();
6571
6670
  }
6671
+ function isStaleWorktreeRegistration(detail) {
6672
+ return typeof detail === "string" && /missing but already registered worktree/i.test(detail);
6673
+ }
6572
6674
  function resolvedDirEquals(left, right) {
6573
6675
  try {
6574
6676
  return realpathSync(left) === realpathSync(right);
@@ -6670,7 +6772,12 @@ async function ensureLocalWorktree(worktree, env) {
6670
6772
  return { error: `could not create worktree parent directory: ${error instanceof Error ? error.message : String(error)}` };
6671
6773
  }
6672
6774
  const hasBranch = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
6673
- const add = hasBranch.status === 0 ? await git(["-C", repoRoot, "worktree", "add", path, branch]) : await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
6775
+ const runAdd = () => hasBranch.status === 0 ? git(["-C", repoRoot, "worktree", "add", path, branch]) : git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
6776
+ let add = await runAdd();
6777
+ if ((add.error || (add.status ?? 1) !== 0) && isStaleWorktreeRegistration(spawnDetail(add))) {
6778
+ await git(["-C", repoRoot, "worktree", "prune"]);
6779
+ add = await runAdd();
6780
+ }
6674
6781
  if (add.error || (add.status ?? 1) !== 0) {
6675
6782
  const detail = spawnDetail(add);
6676
6783
  return { error: `git worktree add failed for ${path}${detail ? `: ${detail}` : ""}` };
@@ -8934,7 +9041,7 @@ function enableStartup(result) {
8934
9041
  // package.json
8935
9042
  var package_default = {
8936
9043
  name: "@hasna/loops",
8937
- version: "0.4.26",
9044
+ version: "0.4.28",
8938
9045
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
8939
9046
  type: "module",
8940
9047
  main: "dist/index.js",
@@ -9044,6 +9151,7 @@ var package_default = {
9044
9151
  bun: ">=1.0.0"
9045
9152
  },
9046
9153
  dependencies: {
9154
+ "@hasna/contracts": "^0.5.1",
9047
9155
  "@hasna/events": "^0.1.9",
9048
9156
  "@modelcontextprotocol/sdk": "^1.29.0",
9049
9157
  "@openrouter/ai-sdk-provider": "2.9.1",
@@ -9053,8 +9161,7 @@ var package_default = {
9053
9161
  zod: "4.4.3"
9054
9162
  },
9055
9163
  optionalDependencies: {
9056
- "@hasna/machines": "0.0.49",
9057
- "@hasna/contracts": "^0.4.2"
9164
+ "@hasna/machines": "0.0.49"
9058
9165
  },
9059
9166
  devDependencies: {
9060
9167
  "@types/bun": "latest",