@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/daemon/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");
|
|
@@ -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
|
-
|
|
6429
|
-
' git -C "$repo"
|
|
6430
|
-
"
|
|
6431
|
-
|
|
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
|
|
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.
|
|
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",
|