@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.
@@ -133,6 +133,8 @@ export interface WorkflowWorkItemRow {
133
133
  priority: number;
134
134
  status: string;
135
135
  attempts: number;
136
+ /** Nullable for rows read before migration 0011 backfills the column. */
137
+ gate_deaths: number | null;
136
138
  next_attempt_at: string | null;
137
139
  lease_expires_at: string | null;
138
140
  workflow_id: string | null;
@@ -368,6 +370,49 @@ export interface RecordGoalEventInput {
368
370
  rawResponse?: unknown;
369
371
  }
370
372
  export declare function workItemStatusForLoopRun(status: RunStatus, attempt: number, maxAttempts: number | undefined): WorkflowWorkItemStatus | undefined;
373
+ /**
374
+ * `exit(75)` = `EX_TEMPFAIL`: the sysexits.h "temporary failure, retry later"
375
+ * code. A gate/worker step that exits 75 (e.g. an account-quota probe that is
376
+ * still dry) is signalling "not now", not a real failed attempt — so it must
377
+ * not burn the todos-task redispatch cap and must leave the work item
378
+ * requeueable rather than persisting as a terminal, dedupe-forever row.
379
+ */
380
+ export declare const WORK_ITEM_TEMPFAIL_EXIT_CODE = 75;
381
+ /**
382
+ * Workflow step ids that run BEFORE the worker does any real work. A failure in
383
+ * one of these (triage/planner gate, or the pre-step worktree preparation) that
384
+ * dies quickly is a "gate death": the run never executed the worker, so it must
385
+ * not count toward the redispatch cap (otherwise a purely infrastructural fault
386
+ * — e.g. a stale worktree registration — silently dead-letters a task that
387
+ * never actually ran).
388
+ */
389
+ export declare const GATE_STEP_IDS: ReadonlySet<string>;
390
+ /** A gate-step failure only counts as a gate death when it dies before any real
391
+ * work could have happened. Worktree-prep deaths are always gate deaths (they
392
+ * fail before the agent is spawned) regardless of this bound. */
393
+ export declare const GATE_DEATH_MAX_DURATION_MS = 60000;
394
+ /**
395
+ * Secondary ceiling for CONSECUTIVE gate deaths. Gate deaths refund their
396
+ * redispatch attempt (the worker never ran), which is correct for transient
397
+ * infrastructure faults — but a deterministic fault (e.g. a permanently broken
398
+ * repo path) would otherwise retry forever at the backoff floor. After this
399
+ * many consecutive gate deaths the work item is dead-lettered (visible in
400
+ * drain reports) instead of spinning; any run that reaches the worker resets
401
+ * the streak, and an operator requeue (attempts reset) re-arms it. At the
402
+ * ~2–4 minute refunded-attempts backoff this bounds a deterministic fault to
403
+ * roughly an hour of auto-retry before it demands an operator.
404
+ */
405
+ export declare const GATE_DEATH_CEILING = 20;
406
+ export type NonProductiveFailureKind = "tempfail" | "gate-death";
407
+ type ClassifiableStepRun = Pick<WorkflowStepRun, "stepId" | "status" | "exitCode" | "durationMs" | "error">;
408
+ /**
409
+ * Classify a just-finalized *failed* workflow run's decisive failing step. A
410
+ * non-productive finish (a tempfail retry-signal, or a gate death before the
411
+ * worker ran) must not count toward the todos-task redispatch cap. Returns the
412
+ * non-productive kind, or `undefined` when the run represents a real worker
413
+ * attempt that legitimately counts toward the cap. Pure/exported for tests.
414
+ */
415
+ export declare function classifyNonProductiveStepFailure(steps: ClassifiableStepRun[]): NonProductiveFailureKind | undefined;
371
416
  export declare function scrubbedOrNull(value: string | undefined | null): string | null;
372
417
  /** Scrub secrets then bound size before persisting run stdout/stderr. */
373
418
  export declare function persistedRunOutput(value: string | undefined | null): string | null;
@@ -490,9 +535,41 @@ export declare class Store {
490
535
  * step on that account right now — exactly the concurrency to bound.
491
536
  */
492
537
  countRunningWorkflowStepsByAuthProfile(): Record<string, number>;
538
+ /**
539
+ * Requeue a terminal admission work item for the next task/event delivery.
540
+ * By default `attempts` is preserved (used by the bounded stale-terminal
541
+ * re-admission on the route path, which must keep counting toward the cap).
542
+ * Pass `resetAttempts: true` for the operator unwedge (`loops routes requeue`)
543
+ * so a manual requeue is DURABLE rather than one-shot: without the reset a
544
+ * capped item that finishes terminal once more re-caps instantly.
545
+ */
493
546
  requeueWorkflowWorkItem(id: string, patch?: {
494
547
  reason?: string;
548
+ resetAttempts?: boolean;
549
+ }): WorkflowWorkItem;
550
+ /**
551
+ * Transition a terminal admission work item to `dead_letter`. Used by the
552
+ * route path when a still-actionable todos task has exhausted the redispatch
553
+ * cap: instead of silently deduping the same terminal row forever (the "black
554
+ * hole" — `considered=N created=0` with no signal), the item is moved to a
555
+ * visible `dead_letter` state so drain reports can surface + count it and an
556
+ * operator can `loops routes requeue` it. Idempotent: a no-op on an item that
557
+ * is already dead-lettered.
558
+ */
559
+ deadLetterWorkflowWorkItem(id: string, patch?: {
560
+ reason?: string;
495
561
  }): WorkflowWorkItem;
562
+ /**
563
+ * Refund a redispatch attempt for a *failed* run that never did real work.
564
+ * Called from {@link finalizeWorkflowRun} the moment a work item is set
565
+ * `failed`, so the todos-task redispatch cap only ever counts real worker
566
+ * attempts. A tempfail (`exit 75`) is additionally made requeueable (dropped
567
+ * back to `queued`, bindings cleared) so its "retry later" contract fires on
568
+ * the next drain instead of persisting as a terminal, dedupe-forever row. A
569
+ * gate death stays `failed` (the bounded re-admission picks it up after
570
+ * backoff once the underlying infra fault clears). Both floor attempts at 0.
571
+ */
572
+ private demoteNonProductiveWorkItems;
496
573
  admitWorkflowWorkItem(id: string, patch: {
497
574
  workflowId: string;
498
575
  loopId: string;
package/dist/lib/store.js CHANGED
@@ -1569,6 +1569,7 @@ var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
1569
1569
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
1570
1570
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
1571
1571
  var SCHEMA_USER_VERSION = 8;
1572
+ var BREAKING_SCHEMA_FLOOR = 7;
1572
1573
  var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
1573
1574
  var PRUNE_BATCH_SIZE = 400;
1574
1575
  var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
@@ -1711,6 +1712,7 @@ function rowToWorkflowWorkItem(row) {
1711
1712
  priority: row.priority,
1712
1713
  status: row.status,
1713
1714
  attempts: row.attempts,
1715
+ gateDeaths: row.gate_deaths ?? 0,
1714
1716
  nextAttemptAt: row.next_attempt_at ?? undefined,
1715
1717
  leaseExpiresAt: row.lease_expires_at ?? undefined,
1716
1718
  workflowId: row.workflow_id ?? undefined,
@@ -1860,6 +1862,23 @@ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
1860
1862
  }
1861
1863
  return;
1862
1864
  }
1865
+ var WORK_ITEM_TEMPFAIL_EXIT_CODE = 75;
1866
+ var GATE_STEP_IDS = new Set(["triage", "planner", "plan"]);
1867
+ var GATE_DEATH_MAX_DURATION_MS = 60000;
1868
+ var GATE_DEATH_CEILING = 20;
1869
+ function classifyNonProductiveStepFailure(steps) {
1870
+ const failing = [...steps].reverse().find((step) => step.status === "failed" || step.status === "timed_out");
1871
+ if (!failing)
1872
+ return;
1873
+ if (failing.exitCode === WORK_ITEM_TEMPFAIL_EXIT_CODE)
1874
+ return "tempfail";
1875
+ if (typeof failing.error === "string" && failing.error.includes("worktree preparation failed"))
1876
+ return "gate-death";
1877
+ const fast = failing.durationMs === undefined || failing.durationMs < GATE_DEATH_MAX_DURATION_MS;
1878
+ if (GATE_STEP_IDS.has(failing.stepId) && fast)
1879
+ return "gate-death";
1880
+ return;
1881
+ }
1863
1882
  function scrubbedOrNull(value) {
1864
1883
  return value == null ? null : scrubSecrets(value);
1865
1884
  }
@@ -1960,11 +1979,21 @@ class Store {
1960
1979
  id TEXT PRIMARY KEY,
1961
1980
  applied_at TEXT NOT NULL
1962
1981
  );
1982
+ CREATE TABLE IF NOT EXISTS schema_compat (
1983
+ id INTEGER PRIMARY KEY CHECK (id = 1),
1984
+ min_compatible_user_version INTEGER NOT NULL
1985
+ );
1963
1986
  `);
1964
1987
  const versionRow = this.db.query("PRAGMA user_version").get();
1965
1988
  const userVersion = versionRow?.user_version ?? 0;
1966
1989
  if (userVersion > SCHEMA_USER_VERSION) {
1967
- throw new Error(`loops database schema version ${userVersion} is newer than this binary supports (${SCHEMA_USER_VERSION}); upgrade open-loops before opening this database`);
1990
+ const floorRow = this.db.query("SELECT min_compatible_user_version FROM schema_compat WHERE id = 1").get();
1991
+ if (!floorRow) {
1992
+ 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`);
1993
+ }
1994
+ if (SCHEMA_USER_VERSION < floorRow.min_compatible_user_version) {
1995
+ 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`);
1996
+ }
1968
1997
  }
1969
1998
  const applied = new Set(this.db.query("SELECT id FROM schema_migrations").all().map((row) => row.id));
1970
1999
  for (const migration of this.migrations()) {
@@ -1975,8 +2004,10 @@ class Store {
1975
2004
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run(migration.id, nowIso());
1976
2005
  }
1977
2006
  }
1978
- if (userVersion !== SCHEMA_USER_VERSION)
2007
+ if (userVersion < SCHEMA_USER_VERSION)
1979
2008
  this.db.exec(`PRAGMA user_version = ${SCHEMA_USER_VERSION}`);
2009
+ this.db.query(`INSERT INTO schema_compat (id, min_compatible_user_version) VALUES (1, ?)
2010
+ ON CONFLICT(id) DO UPDATE SET min_compatible_user_version = MAX(min_compatible_user_version, excluded.min_compatible_user_version)`).run(BREAKING_SCHEMA_FLOOR);
1980
2011
  }
1981
2012
  migrations() {
1982
2013
  return [
@@ -2043,6 +2074,12 @@ class Store {
2043
2074
  this.addColumnIfMissing("workflow_work_items", "machine_id", "TEXT");
2044
2075
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_machine ON workflow_work_items(machine_id, status)");
2045
2076
  }
2077
+ },
2078
+ {
2079
+ id: "0011_work_item_gate_deaths",
2080
+ apply: () => {
2081
+ this.addColumnIfMissing("workflow_work_items", "gate_deaths", "INTEGER NOT NULL DEFAULT 0");
2082
+ }
2046
2083
  }
2047
2084
  ];
2048
2085
  }
@@ -3173,7 +3210,7 @@ class Store {
3173
3210
  }
3174
3211
  upsertWorkflowWorkItem(input) {
3175
3212
  const now = nowIso();
3176
- const id = genId();
3213
+ const id = input.id ?? genId();
3177
3214
  const status = input.status ?? "queued";
3178
3215
  this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
3179
3216
  subject_ref, project_key, project_group, machine_id, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
@@ -3301,6 +3338,7 @@ class Store {
3301
3338
  const placeholders = requeueableStatuses.map(() => "?").join(",");
3302
3339
  const res = this.db.query(`UPDATE workflow_work_items
3303
3340
  SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
3341
+ ${patch.resetAttempts ? "attempts=0, gate_deaths=0," : ""}
3304
3342
  next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
3305
3343
  WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
3306
3344
  const item = this.getWorkflowWorkItem(id);
@@ -3310,6 +3348,46 @@ class Store {
3310
3348
  throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
3311
3349
  return item;
3312
3350
  }
3351
+ deadLetterWorkflowWorkItem(id, patch = {}) {
3352
+ const now = nowIso();
3353
+ const reason = patch.reason?.trim() || "redispatch cap reached; dead-lettered";
3354
+ this.db.query(`UPDATE workflow_work_items
3355
+ SET status='dead_letter', next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
3356
+ WHERE id=? AND status IN ('succeeded','failed','cancelled')`).run(reason, now, id);
3357
+ const item = this.getWorkflowWorkItem(id);
3358
+ if (!item)
3359
+ throw new Error(`workflow work item not found after dead-letter: ${id}`);
3360
+ return item;
3361
+ }
3362
+ demoteNonProductiveWorkItems(workflowRunId, finishedAt) {
3363
+ const kind = classifyNonProductiveStepFailure(this.listWorkflowStepRuns(workflowRunId));
3364
+ if (!kind) {
3365
+ 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);
3366
+ return;
3367
+ }
3368
+ if (kind === "tempfail") {
3369
+ this.db.query(`UPDATE workflow_work_items
3370
+ SET status='queued', attempts=CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END,
3371
+ gate_deaths=0,
3372
+ workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
3373
+ next_attempt_at=NULL, lease_expires_at=NULL,
3374
+ last_reason='worker exited 75 (tempfail): requeued for retry; attempt refunded (does not count toward redispatch cap)',
3375
+ updated_at=?
3376
+ WHERE workflow_run_id=? AND status='failed'`).run(finishedAt, workflowRunId);
3377
+ return;
3378
+ }
3379
+ this.db.query(`UPDATE workflow_work_items
3380
+ SET attempts=CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END,
3381
+ gate_deaths=gate_deaths + 1,
3382
+ status=CASE WHEN gate_deaths + 1 >= ${GATE_DEATH_CEILING} THEN 'dead_letter' ELSE status END,
3383
+ last_reason=CASE
3384
+ WHEN gate_deaths + 1 >= ${GATE_DEATH_CEILING}
3385
+ 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'
3386
+ 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}'
3387
+ END,
3388
+ updated_at=?
3389
+ WHERE workflow_run_id=? AND status='failed'`).run(finishedAt, workflowRunId);
3390
+ }
3313
3391
  admitWorkflowWorkItem(id, patch) {
3314
3392
  const now = nowIso();
3315
3393
  const res = this.db.query(`UPDATE workflow_work_items
@@ -4021,6 +4099,8 @@ class Store {
4021
4099
  if (changed) {
4022
4100
  const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
4023
4101
  this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, error, finishedAt);
4102
+ if (itemStatus === "failed")
4103
+ this.demoteNonProductiveWorkItems(workflowRunId, finishedAt);
4024
4104
  this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
4025
4105
  }
4026
4106
  this.db.exec("COMMIT");
@@ -4984,5 +5064,10 @@ export {
4984
5064
  rowToGoalPlanNode,
4985
5065
  rowToGoal,
4986
5066
  persistedRunOutput,
4987
- Store
5067
+ classifyNonProductiveStepFailure,
5068
+ WORK_ITEM_TEMPFAIL_EXIT_CODE,
5069
+ Store,
5070
+ GATE_STEP_IDS,
5071
+ GATE_DEATH_MAX_DURATION_MS,
5072
+ GATE_DEATH_CEILING
4988
5073
  };
package/dist/mcp/index.js CHANGED
@@ -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");
@@ -4972,7 +5052,7 @@ class Store {
4972
5052
  // package.json
4973
5053
  var package_default = {
4974
5054
  name: "@hasna/loops",
4975
- version: "0.4.26",
5055
+ version: "0.4.28",
4976
5056
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4977
5057
  type: "module",
4978
5058
  main: "dist/index.js",
@@ -5082,6 +5162,7 @@ var package_default = {
5082
5162
  bun: ">=1.0.0"
5083
5163
  },
5084
5164
  dependencies: {
5165
+ "@hasna/contracts": "^0.5.1",
5085
5166
  "@hasna/events": "^0.1.9",
5086
5167
  "@modelcontextprotocol/sdk": "^1.29.0",
5087
5168
  "@openrouter/ai-sdk-provider": "2.9.1",
@@ -5091,8 +5172,7 @@ var package_default = {
5091
5172
  zod: "4.4.3"
5092
5173
  },
5093
5174
  optionalDependencies: {
5094
- "@hasna/machines": "0.0.49",
5095
- "@hasna/contracts": "^0.4.2"
5175
+ "@hasna/machines": "0.0.49"
5096
5176
  },
5097
5177
  devDependencies: {
5098
5178
  "@types/bun": "latest",
@@ -6239,11 +6319,30 @@ function remoteWorktreePrepareLines(worktree) {
6239
6319
  ' 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; }',
6240
6320
  ' mkdir -p "$(dirname "$path")" || return 1',
6241
6321
  " # Preparation chatter goes to stderr so run stdout stays the agent's.",
6242
- ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
6243
- ' git -C "$repo" worktree add "$path" "$branch" 1>&2 || return 1',
6244
- " else",
6245
- ' git -C "$repo" worktree add -b "$branch" "$path" HEAD 1>&2 || return 1',
6322
+ " __openloops_worktree_add() {",
6323
+ ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
6324
+ ' git -C "$repo" worktree add "$path" "$branch"',
6325
+ " else",
6326
+ ' git -C "$repo" worktree add -b "$branch" "$path" HEAD',
6327
+ " fi",
6328
+ " }",
6329
+ " local __ol_add_out",
6330
+ ' if __ol_add_out="$(__openloops_worktree_add 2>&1)"; then',
6331
+ ' if [ -n "$__ol_add_out" ]; then printf "%s\\n" "$__ol_add_out" >&2; fi',
6332
+ " return 0",
6246
6333
  " fi",
6334
+ ' printf "%s\\n" "$__ol_add_out" >&2',
6335
+ " # Self-heal git's own remedy for a stale 'missing but already registered",
6336
+ " # worktree': prune the dead registration (metadata-only; the directory is",
6337
+ " # already gone) and retry the add exactly once, then fail honestly.",
6338
+ ' case "$__ol_add_out" in',
6339
+ ' *"missing but already registered worktree"*)',
6340
+ ' git -C "$repo" worktree prune 1>&2 || true',
6341
+ " __openloops_worktree_add 1>&2 || return 1",
6342
+ " return 0",
6343
+ " ;;",
6344
+ " esac",
6345
+ " return 1",
6247
6346
  "}"
6248
6347
  ];
6249
6348
  }
@@ -6383,6 +6482,9 @@ async function preflightNativeAuthProfile(spec, env) {
6383
6482
  function spawnDetail(result) {
6384
6483
  return (result.stderr || result.stdout || result.error || "").toString().trim();
6385
6484
  }
6485
+ function isStaleWorktreeRegistration(detail) {
6486
+ return typeof detail === "string" && /missing but already registered worktree/i.test(detail);
6487
+ }
6386
6488
  function resolvedDirEquals(left, right) {
6387
6489
  try {
6388
6490
  return realpathSync(left) === realpathSync(right);
@@ -6484,7 +6586,12 @@ async function ensureLocalWorktree(worktree, env) {
6484
6586
  return { error: `could not create worktree parent directory: ${error instanceof Error ? error.message : String(error)}` };
6485
6587
  }
6486
6588
  const hasBranch = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
6487
- const add = hasBranch.status === 0 ? await git(["-C", repoRoot, "worktree", "add", path, branch]) : await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
6589
+ const runAdd = () => hasBranch.status === 0 ? git(["-C", repoRoot, "worktree", "add", path, branch]) : git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
6590
+ let add = await runAdd();
6591
+ if ((add.error || (add.status ?? 1) !== 0) && isStaleWorktreeRegistration(spawnDetail(add))) {
6592
+ await git(["-C", repoRoot, "worktree", "prune"]);
6593
+ add = await runAdd();
6594
+ }
6488
6595
  if (add.error || (add.status ?? 1) !== 0) {
6489
6596
  const detail = spawnDetail(add);
6490
6597
  return { error: `git worktree add failed for ${path}${detail ? `: ${detail}` : ""}` };
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@hasna/loops",
6
- version: "0.4.26",
6
+ version: "0.4.28",
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",
@@ -113,6 +113,7 @@ var package_default = {
113
113
  bun: ">=1.0.0"
114
114
  },
115
115
  dependencies: {
116
+ "@hasna/contracts": "^0.5.1",
116
117
  "@hasna/events": "^0.1.9",
117
118
  "@modelcontextprotocol/sdk": "^1.29.0",
118
119
  "@openrouter/ai-sdk-provider": "2.9.1",
@@ -122,8 +123,7 @@ var package_default = {
122
123
  zod: "4.4.3"
123
124
  },
124
125
  optionalDependencies: {
125
- "@hasna/machines": "0.0.49",
126
- "@hasna/contracts": "^0.4.2"
126
+ "@hasna/machines": "0.0.49"
127
127
  },
128
128
  devDependencies: {
129
129
  "@types/bun": "latest",
@@ -1419,11 +1419,30 @@ function remoteWorktreePrepareLines(worktree) {
1419
1419
  ' 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; }',
1420
1420
  ' mkdir -p "$(dirname "$path")" || return 1',
1421
1421
  " # Preparation chatter goes to stderr so run stdout stays the agent's.",
1422
- ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
1423
- ' git -C "$repo" worktree add "$path" "$branch" 1>&2 || return 1',
1424
- " else",
1425
- ' git -C "$repo" worktree add -b "$branch" "$path" HEAD 1>&2 || return 1',
1422
+ " __openloops_worktree_add() {",
1423
+ ' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
1424
+ ' git -C "$repo" worktree add "$path" "$branch"',
1425
+ " else",
1426
+ ' git -C "$repo" worktree add -b "$branch" "$path" HEAD',
1427
+ " fi",
1428
+ " }",
1429
+ " local __ol_add_out",
1430
+ ' if __ol_add_out="$(__openloops_worktree_add 2>&1)"; then',
1431
+ ' if [ -n "$__ol_add_out" ]; then printf "%s\\n" "$__ol_add_out" >&2; fi',
1432
+ " return 0",
1426
1433
  " fi",
1434
+ ' printf "%s\\n" "$__ol_add_out" >&2',
1435
+ " # Self-heal git's own remedy for a stale 'missing but already registered",
1436
+ " # worktree': prune the dead registration (metadata-only; the directory is",
1437
+ " # already gone) and retry the add exactly once, then fail honestly.",
1438
+ ' case "$__ol_add_out" in',
1439
+ ' *"missing but already registered worktree"*)',
1440
+ ' git -C "$repo" worktree prune 1>&2 || true',
1441
+ " __openloops_worktree_add 1>&2 || return 1",
1442
+ " return 0",
1443
+ " ;;",
1444
+ " esac",
1445
+ " return 1",
1427
1446
  "}"
1428
1447
  ];
1429
1448
  }
@@ -1563,6 +1582,9 @@ async function preflightNativeAuthProfile(spec, env) {
1563
1582
  function spawnDetail(result) {
1564
1583
  return (result.stderr || result.stdout || result.error || "").toString().trim();
1565
1584
  }
1585
+ function isStaleWorktreeRegistration(detail) {
1586
+ return typeof detail === "string" && /missing but already registered worktree/i.test(detail);
1587
+ }
1566
1588
  function resolvedDirEquals(left, right) {
1567
1589
  try {
1568
1590
  return realpathSync(left) === realpathSync(right);
@@ -1664,7 +1686,12 @@ async function ensureLocalWorktree(worktree, env) {
1664
1686
  return { error: `could not create worktree parent directory: ${error instanceof Error ? error.message : String(error)}` };
1665
1687
  }
1666
1688
  const hasBranch = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
1667
- const add = hasBranch.status === 0 ? await git(["-C", repoRoot, "worktree", "add", path, branch]) : await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
1689
+ const runAdd = () => hasBranch.status === 0 ? git(["-C", repoRoot, "worktree", "add", path, branch]) : git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
1690
+ let add = await runAdd();
1691
+ if ((add.error || (add.status ?? 1) !== 0) && isStaleWorktreeRegistration(spawnDetail(add))) {
1692
+ await git(["-C", repoRoot, "worktree", "prune"]);
1693
+ add = await runAdd();
1694
+ }
1668
1695
  if (add.error || (add.status ?? 1) !== 0) {
1669
1696
  const detail = spawnDetail(add);
1670
1697
  return { error: `git worktree add failed for ${path}${detail ? `: ${detail}` : ""}` };