@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.
- package/CHANGELOG.md +121 -0
- package/README.md +6 -0
- package/dist/api/index.js +689 -20
- package/dist/cli/index.js +509 -85
- package/dist/daemon/index.js +118 -11
- package/dist/index.js +525 -64
- package/dist/lib/executor.d.ts +9 -0
- package/dist/lib/migration.d.ts +66 -0
- package/dist/lib/mode.js +3 -3
- package/dist/lib/route/fields.d.ts +8 -0
- package/dist/lib/storage/index.js +197 -7
- package/dist/lib/storage/postgres-loop-storage.d.ts +2 -2
- package/dist/lib/storage/postgres-schema.js +3 -0
- package/dist/lib/storage/postgres.js +3 -0
- package/dist/lib/storage/sqlite.js +83 -3
- package/dist/lib/store/index.d.ts +3 -0
- package/dist/lib/store.d.ts +77 -0
- package/dist/lib/store.js +89 -4
- package/dist/mcp/index.js +118 -11
- package/dist/runner/index.js +35 -8
- package/dist/sdk/http.d.ts +153 -1
- package/dist/sdk/http.js +78 -1
- package/dist/sdk/index.js +411 -60
- package/dist/serve/index.js +919 -60
- package/dist/types.d.ts +11 -0
- package/docs/DEPLOYMENT_MODES.md +6 -0
- package/docs/USAGE.md +11 -8
- package/package.json +3 -3
package/dist/lib/store.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
6243
|
-
' git -C "$repo"
|
|
6244
|
-
"
|
|
6245
|
-
|
|
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
|
|
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}` : ""}` };
|
package/dist/runner/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "@hasna/loops",
|
|
6
|
-
version: "0.4.
|
|
6
|
+
version: "0.4.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
|
-
|
|
1423
|
-
' git -C "$repo"
|
|
1424
|
-
"
|
|
1425
|
-
|
|
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
|
|
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}` : ""}` };
|