aiden-runtime 4.8.0 → 4.9.0
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/README.md +88 -1
- package/dist/cli/v4/aidenCLI.js +35 -4
- package/dist/cli/v4/chatSession.js +43 -16
- package/dist/cli/v4/commands/daemon.js +47 -2
- package/dist/cli/v4/commands/daemonDoctor.js +212 -0
- package/dist/cli/v4/commands/daemonStatus.js +1 -1
- package/dist/cli/v4/commands/help.js +2 -0
- package/dist/cli/v4/commands/hooks.js +428 -0
- package/dist/cli/v4/commands/index.js +5 -1
- package/dist/cli/v4/commands/mcp.js +89 -1
- package/dist/cli/v4/commands/mcpClientInstall.js +359 -0
- package/dist/cli/v4/commands/memory.js +702 -0
- package/dist/cli/v4/commands/recovery.js +1 -1
- package/dist/cli/v4/commands/skin.js +7 -0
- package/dist/cli/v4/commands/theme.js +217 -0
- package/dist/cli/v4/commands/trigger.js +1 -1
- package/dist/cli/v4/commands/update.js +14 -2
- package/dist/cli/v4/design/tokens.js +52 -4
- package/dist/cli/v4/display.js +102 -46
- package/dist/cli/v4/pasteIntercept.js +214 -70
- package/dist/cli/v4/replyRenderer.js +145 -5
- package/dist/cli/v4/skinEngine.js +67 -0
- package/dist/core/v4/aidenAgent.js +45 -2
- package/dist/core/v4/daemon/api/runs.js +131 -0
- package/dist/core/v4/daemon/bootstrap.js +368 -13
- package/dist/core/v4/daemon/db/migrations.js +169 -0
- package/dist/core/v4/daemon/idempotency/runIdempotencyStore.js +128 -0
- package/dist/core/v4/daemon/incarnationStore.js +47 -0
- package/dist/core/v4/daemon/runs/attemptStore.js +67 -0
- package/dist/core/v4/daemon/runs/reclaim.js +88 -0
- package/dist/core/v4/daemon/runs/retryPolicy.js +73 -0
- package/dist/core/v4/daemon/runs/runWithRetry.js +80 -0
- package/dist/core/v4/daemon/runs/stuckAttemptWatchdog.js +72 -0
- package/dist/core/v4/daemon/spans/spanHelpers.js +272 -0
- package/dist/core/v4/daemon/spans/spanStore.js +113 -0
- package/dist/core/v4/daemon/triggerBus.js +50 -19
- package/dist/core/v4/hooks/auditQuery.js +67 -0
- package/dist/core/v4/hooks/dispatcher.js +286 -0
- package/dist/core/v4/hooks/index.js +46 -0
- package/dist/core/v4/hooks/lifecycle.js +27 -0
- package/dist/core/v4/hooks/manifest.js +142 -0
- package/dist/core/v4/hooks/registry.js +149 -0
- package/dist/core/v4/hooks/runtime/subprocessRunner.js +188 -0
- package/dist/core/v4/hooks/toolHookGate.js +76 -0
- package/dist/core/v4/hooks/trust.js +14 -0
- package/dist/core/v4/identity/contextManager.js +83 -0
- package/dist/core/v4/identity/daemonId.js +85 -0
- package/dist/core/v4/identity/enforcement.js +103 -0
- package/dist/core/v4/identity/executionContext.js +153 -0
- package/dist/core/v4/identity/hookExecution.js +62 -0
- package/dist/core/v4/identity/httpContext.js +68 -0
- package/dist/core/v4/identity/ids.js +185 -0
- package/dist/core/v4/identity/index.js +60 -0
- package/dist/core/v4/identity/subprocessContext.js +98 -0
- package/dist/core/v4/identity/traceparent.js +114 -0
- package/dist/core/v4/logger/index.js +3 -1
- package/dist/core/v4/logger/logger.js +28 -1
- package/dist/core/v4/logger/redact.js +149 -0
- package/dist/core/v4/logger/sinks/fileSink.js +13 -0
- package/dist/core/v4/logger/sinks/stdSink.js +19 -1
- package/dist/core/v4/mcp/install/backup.js +78 -0
- package/dist/core/v4/mcp/install/clientPaths.js +90 -0
- package/dist/core/v4/mcp/install/clients.js +203 -0
- package/dist/core/v4/mcp/install/healthCheck.js +83 -0
- package/dist/core/v4/mcp/install/jsoncMerge.js +174 -0
- package/dist/core/v4/mcp/install/profiles.js +109 -0
- package/dist/core/v4/mcp/install/wslDetect.js +62 -0
- package/dist/core/v4/memory/namespaceRegistry.js +117 -0
- package/dist/core/v4/memory/projectRoot.js +76 -0
- package/dist/core/v4/memory/reviewer/index.js +162 -0
- package/dist/core/v4/memory/reviewer/pendingStore.js +136 -0
- package/dist/core/v4/memory/reviewer/prompt.js +105 -0
- package/dist/core/v4/memory/reviewer/skipRules.js +92 -0
- package/dist/core/v4/memoryManager.js +57 -10
- package/dist/core/v4/paths.js +2 -0
- package/dist/core/v4/promptBuilder.js +6 -0
- package/dist/core/v4/subagent/spawnSubAgent.js +20 -7
- package/dist/core/v4/theme/bundledThemes.js +106 -0
- package/dist/core/v4/theme/themeLoader.js +160 -0
- package/dist/core/v4/theme/themeRegistry.js +97 -0
- package/dist/core/v4/theme/themeWatcher.js +95 -0
- package/dist/core/v4/toolRegistry.js +71 -8
- package/dist/core/v4/update/executeInstall.js +10 -6
- package/dist/core/v4/update/installMethodDetect.js +7 -0
- package/dist/core/version.js +67 -2
- package/dist/moat/approvalEngine.js +4 -0
- package/dist/moat/memoryGuard.js +8 -1
- package/dist/providers/v4/anthropicAdapter.js +10 -4
- package/dist/tools/v4/backends/local.js +19 -2
- package/dist/tools/v4/sessions/recallSession.js +6 -1
- package/package.json +3 -3
- package/plugins/aiden-plugin-chatgpt-plus/index.js +10 -1
- package/themes/default.yaml +52 -0
- package/themes/dracula.yaml +32 -0
- package/themes/light.yaml +32 -0
- package/themes/monochrome.yaml +31 -0
- package/themes/tokyo-night.yaml +32 -0
- package/dist/core/pluginSystem.js +0 -121
- package/dist/tools/v4/ui/_uiSmokeTool.js +0 -60
|
@@ -316,6 +316,170 @@ CREATE INDEX IF NOT EXISTS idx_recovery_reports_signature
|
|
|
316
316
|
CREATE INDEX IF NOT EXISTS idx_recovery_reports_run
|
|
317
317
|
ON recovery_reports(run_id);
|
|
318
318
|
`;
|
|
319
|
+
// v4.9.0 Slice 4 — daemon_incarnations table. Source of truth lives at
|
|
320
|
+
// `core/v4/daemon/db/schema/v8.sql`; kept in sync via the migrations
|
|
321
|
+
// test snapshot check. Distinct from the v1 `daemon_instances` table
|
|
322
|
+
// (which keeps its random-UUID instance_id intact for existing
|
|
323
|
+
// `evaluateBootState` / `reclaimStuckRuns` consumers); v8 introduces
|
|
324
|
+
// the persistent daemon identity + per-boot incarnation correlation.
|
|
325
|
+
const V8_SQL = `
|
|
326
|
+
CREATE TABLE IF NOT EXISTS daemon_incarnations (
|
|
327
|
+
incarnation_id TEXT PRIMARY KEY,
|
|
328
|
+
daemon_id TEXT NOT NULL,
|
|
329
|
+
pid INTEGER NOT NULL,
|
|
330
|
+
started_at TEXT NOT NULL,
|
|
331
|
+
ended_at TEXT,
|
|
332
|
+
exit_reason TEXT,
|
|
333
|
+
exit_code INTEGER,
|
|
334
|
+
aiden_version TEXT,
|
|
335
|
+
node_version TEXT
|
|
336
|
+
);
|
|
337
|
+
CREATE INDEX IF NOT EXISTS idx_incarnations_daemon
|
|
338
|
+
ON daemon_incarnations(daemon_id, started_at DESC);
|
|
339
|
+
`;
|
|
340
|
+
// v4.9.0 Slice 5 — durable run queue. Source of truth lives at
|
|
341
|
+
// `core/v4/daemon/db/schema/v9.sql`; kept in sync via the migrations
|
|
342
|
+
// test snapshot check.
|
|
343
|
+
const V9_SQL = `
|
|
344
|
+
CREATE TABLE IF NOT EXISTS run_attempts (
|
|
345
|
+
attempt_id TEXT PRIMARY KEY,
|
|
346
|
+
run_id INTEGER NOT NULL,
|
|
347
|
+
attempt_number INTEGER NOT NULL,
|
|
348
|
+
incarnation_id TEXT NOT NULL,
|
|
349
|
+
started_at TEXT NOT NULL,
|
|
350
|
+
ended_at TEXT,
|
|
351
|
+
status TEXT NOT NULL,
|
|
352
|
+
finish_reason TEXT,
|
|
353
|
+
error_class TEXT,
|
|
354
|
+
error_message TEXT,
|
|
355
|
+
FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE
|
|
356
|
+
);
|
|
357
|
+
CREATE INDEX IF NOT EXISTS idx_run_attempts_run
|
|
358
|
+
ON run_attempts(run_id, attempt_number);
|
|
359
|
+
CREATE INDEX IF NOT EXISTS idx_run_attempts_incarnation
|
|
360
|
+
ON run_attempts(incarnation_id);
|
|
361
|
+
|
|
362
|
+
CREATE TABLE IF NOT EXISTS spans (
|
|
363
|
+
span_id TEXT PRIMARY KEY,
|
|
364
|
+
trace_id TEXT NOT NULL,
|
|
365
|
+
parent_span_id TEXT,
|
|
366
|
+
run_id INTEGER,
|
|
367
|
+
attempt_id TEXT,
|
|
368
|
+
incarnation_id TEXT NOT NULL,
|
|
369
|
+
kind TEXT NOT NULL,
|
|
370
|
+
name TEXT NOT NULL,
|
|
371
|
+
started_at TEXT NOT NULL,
|
|
372
|
+
ended_at TEXT,
|
|
373
|
+
status TEXT,
|
|
374
|
+
attrs_json TEXT,
|
|
375
|
+
error_class TEXT,
|
|
376
|
+
error_message TEXT
|
|
377
|
+
);
|
|
378
|
+
CREATE INDEX IF NOT EXISTS idx_spans_trace ON spans(trace_id, started_at);
|
|
379
|
+
CREATE INDEX IF NOT EXISTS idx_spans_run ON spans(run_id, started_at);
|
|
380
|
+
CREATE INDEX IF NOT EXISTS idx_spans_parent ON spans(parent_span_id);
|
|
381
|
+
|
|
382
|
+
CREATE TABLE IF NOT EXISTS run_idempotency_keys (
|
|
383
|
+
namespace TEXT NOT NULL,
|
|
384
|
+
key TEXT NOT NULL,
|
|
385
|
+
fingerprint TEXT NOT NULL,
|
|
386
|
+
run_id INTEGER,
|
|
387
|
+
trigger_event_id INTEGER,
|
|
388
|
+
span_id TEXT,
|
|
389
|
+
status TEXT NOT NULL,
|
|
390
|
+
created_at TEXT NOT NULL,
|
|
391
|
+
expires_at TEXT,
|
|
392
|
+
result_ref TEXT,
|
|
393
|
+
PRIMARY KEY (namespace, key)
|
|
394
|
+
);
|
|
395
|
+
CREATE INDEX IF NOT EXISTS idx_idempotency_expires
|
|
396
|
+
ON run_idempotency_keys(expires_at) WHERE expires_at IS NOT NULL;
|
|
397
|
+
CREATE INDEX IF NOT EXISTS idx_idempotency_run
|
|
398
|
+
ON run_idempotency_keys(run_id) WHERE run_id IS NOT NULL;
|
|
399
|
+
`;
|
|
400
|
+
// v4.9.0 Slice 7 — external trace adoption. Adds `external_trace_id`
|
|
401
|
+
// to `spans` + `runs` for W3C traceparent adoption alongside Aiden's
|
|
402
|
+
// typed `trc_<uuidv7>`. Source of truth: `db/schema/v10.sql`.
|
|
403
|
+
const V10_SQL = `
|
|
404
|
+
ALTER TABLE spans ADD COLUMN external_trace_id TEXT;
|
|
405
|
+
ALTER TABLE runs ADD COLUMN external_trace_id TEXT;
|
|
406
|
+
CREATE INDEX IF NOT EXISTS idx_spans_external_trace
|
|
407
|
+
ON spans(external_trace_id) WHERE external_trace_id IS NOT NULL;
|
|
408
|
+
`;
|
|
409
|
+
// v4.9.0 Slice 12a — Hook system tables. Source of truth lives at
|
|
410
|
+
// `core/v4/daemon/db/schema/v11.sql`.
|
|
411
|
+
const V11_SQL = `
|
|
412
|
+
CREATE TABLE IF NOT EXISTS hooks (
|
|
413
|
+
hook_id TEXT PRIMARY KEY,
|
|
414
|
+
name TEXT NOT NULL,
|
|
415
|
+
version TEXT,
|
|
416
|
+
source TEXT NOT NULL,
|
|
417
|
+
runtime TEXT NOT NULL,
|
|
418
|
+
manifest_path TEXT NOT NULL,
|
|
419
|
+
code_hash TEXT NOT NULL,
|
|
420
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
421
|
+
trust_state TEXT NOT NULL,
|
|
422
|
+
created_at TEXT NOT NULL,
|
|
423
|
+
updated_at TEXT NOT NULL,
|
|
424
|
+
UNIQUE(manifest_path)
|
|
425
|
+
);
|
|
426
|
+
CREATE TABLE IF NOT EXISTS hook_subscriptions (
|
|
427
|
+
subscription_id TEXT PRIMARY KEY,
|
|
428
|
+
hook_id TEXT NOT NULL REFERENCES hooks(hook_id) ON DELETE CASCADE,
|
|
429
|
+
event TEXT NOT NULL,
|
|
430
|
+
matcher_json TEXT,
|
|
431
|
+
authority TEXT NOT NULL,
|
|
432
|
+
mode TEXT NOT NULL,
|
|
433
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
434
|
+
timeout_ms INTEGER NOT NULL,
|
|
435
|
+
on_error TEXT NOT NULL,
|
|
436
|
+
on_timeout TEXT NOT NULL,
|
|
437
|
+
enabled INTEGER NOT NULL DEFAULT 1
|
|
438
|
+
);
|
|
439
|
+
CREATE INDEX IF NOT EXISTS idx_hook_subscriptions_event ON hook_subscriptions(event, enabled);
|
|
440
|
+
CREATE TABLE IF NOT EXISTS hook_capability_grants (
|
|
441
|
+
grant_id TEXT PRIMARY KEY,
|
|
442
|
+
hook_id TEXT NOT NULL REFERENCES hooks(hook_id) ON DELETE CASCADE,
|
|
443
|
+
capability TEXT NOT NULL,
|
|
444
|
+
scope_json TEXT NOT NULL,
|
|
445
|
+
granted_by TEXT,
|
|
446
|
+
granted_at TEXT NOT NULL,
|
|
447
|
+
revoked_at TEXT
|
|
448
|
+
);
|
|
449
|
+
CREATE TABLE IF NOT EXISTS hook_executions (
|
|
450
|
+
hook_execution_id TEXT PRIMARY KEY,
|
|
451
|
+
hook_id TEXT NOT NULL REFERENCES hooks(hook_id),
|
|
452
|
+
subscription_id TEXT REFERENCES hook_subscriptions(subscription_id),
|
|
453
|
+
event TEXT NOT NULL,
|
|
454
|
+
run_id TEXT,
|
|
455
|
+
trace_id TEXT,
|
|
456
|
+
span_id TEXT,
|
|
457
|
+
parent_span_id TEXT,
|
|
458
|
+
tool_call_id TEXT,
|
|
459
|
+
status TEXT NOT NULL,
|
|
460
|
+
decision TEXT,
|
|
461
|
+
elapsed_ms INTEGER NOT NULL,
|
|
462
|
+
cpu_ms INTEGER,
|
|
463
|
+
max_rss_kb INTEGER,
|
|
464
|
+
exit_code INTEGER,
|
|
465
|
+
payload_hash TEXT,
|
|
466
|
+
response_hash TEXT,
|
|
467
|
+
stdout_preview TEXT,
|
|
468
|
+
stderr_preview TEXT,
|
|
469
|
+
error_kind TEXT,
|
|
470
|
+
error_message TEXT,
|
|
471
|
+
started_at TEXT NOT NULL,
|
|
472
|
+
finished_at TEXT NOT NULL
|
|
473
|
+
);
|
|
474
|
+
CREATE INDEX IF NOT EXISTS idx_hook_executions_run ON hook_executions(run_id, started_at);
|
|
475
|
+
CREATE INDEX IF NOT EXISTS idx_hook_executions_hook ON hook_executions(hook_id, started_at);
|
|
476
|
+
CREATE INDEX IF NOT EXISTS idx_hook_executions_event ON hook_executions(event, started_at);
|
|
477
|
+
`;
|
|
478
|
+
// v4.9.0 Slice 12b — auto-disable rail. Just an ADD COLUMN; full
|
|
479
|
+
// rationale lives in `core/v4/daemon/db/schema/v12.sql`.
|
|
480
|
+
const V12_SQL = `
|
|
481
|
+
ALTER TABLE hooks ADD COLUMN consecutive_failures INTEGER NOT NULL DEFAULT 0;
|
|
482
|
+
`;
|
|
319
483
|
const MIGRATIONS = [
|
|
320
484
|
{ version: 1, name: 'phase 1 — daemon foundation', sql: V1_SQL },
|
|
321
485
|
{ version: 2, name: 'phase 2 — file watcher observations', sql: V2_SQL },
|
|
@@ -324,6 +488,11 @@ const MIGRATIONS = [
|
|
|
324
488
|
{ version: 5, name: 'phase 5b — scheduled workflows', sql: V5_SQL },
|
|
325
489
|
{ version: 6, name: 'v4.6 phase 1 — sub-agent lineage', sql: V6_SQL },
|
|
326
490
|
{ version: 7, name: 'v4.6 phase 3b — self-improvement loop', sql: V7_SQL },
|
|
491
|
+
{ version: 8, name: 'v4.9 slice 4 — daemon identity + incarnations', sql: V8_SQL },
|
|
492
|
+
{ version: 9, name: 'v4.9 slice 5 — durable run queue', sql: V9_SQL },
|
|
493
|
+
{ version: 10, name: 'v4.9 slice 7 — external trace adoption', sql: V10_SQL },
|
|
494
|
+
{ version: 11, name: 'v4.9 slice 12a — hook system', sql: V11_SQL },
|
|
495
|
+
{ version: 12, name: 'v4.9 slice 12b — hook auto-disable counter', sql: V12_SQL },
|
|
327
496
|
];
|
|
328
497
|
exports.LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
|
329
498
|
function getCurrentVersion(db) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/daemon/idempotency/runIdempotencyStore.ts — v4.9.0 Slice 5.
|
|
10
|
+
*
|
|
11
|
+
* Durable ingress / write-side idempotency. Distinct from the v1
|
|
12
|
+
* `idempotency_keys` response-replay cache (which stores HTTP response
|
|
13
|
+
* bodies for exact-byte replay). This store tracks (namespace, key) →
|
|
14
|
+
* acceptance outcome so a duplicate webhook / email / file / API
|
|
15
|
+
* request never creates a second `runs` row.
|
|
16
|
+
*
|
|
17
|
+
* `acquire()` is the central primitive: atomic insert against the
|
|
18
|
+
* (namespace, key) PK. Three outcomes:
|
|
19
|
+
*
|
|
20
|
+
* - 'accepted' — fresh row inserted, caller proceeds with
|
|
21
|
+
* run/trigger creation.
|
|
22
|
+
* - 'duplicate' — row exists with the SAME fingerprint;
|
|
23
|
+
* caller should reuse the linked run/event id.
|
|
24
|
+
* - 'rejected_conflict' — row exists with a DIFFERENT fingerprint;
|
|
25
|
+
* caller is reusing a key for different work
|
|
26
|
+
* and should reject with a loud error.
|
|
27
|
+
*
|
|
28
|
+
* Call `link()` after the run/trigger row exists to back-fill the FKs.
|
|
29
|
+
* `complete()` patches the final result_ref (e.g. span_id of the
|
|
30
|
+
* outcome). `sweepExpired()` is the GC tick for keys with `expires_at`
|
|
31
|
+
* in the past.
|
|
32
|
+
*/
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.fingerprintCanonical = fingerprintCanonical;
|
|
35
|
+
exports.acquire = acquire;
|
|
36
|
+
exports.link = link;
|
|
37
|
+
exports.complete = complete;
|
|
38
|
+
exports.sweepExpired = sweepExpired;
|
|
39
|
+
exports.getKey = getKey;
|
|
40
|
+
const node_crypto_1 = require("node:crypto");
|
|
41
|
+
/**
|
|
42
|
+
* Canonical fingerprint helper — sort keys, stringify, SHA-256 hex.
|
|
43
|
+
* Drops `undefined` values to keep the hash stable across optional
|
|
44
|
+
* fields. Use this when the caller doesn't already have a domain-
|
|
45
|
+
* specific hash (e.g. webhook deliveries already compute one in
|
|
46
|
+
* `webhookIdempotency.ts`).
|
|
47
|
+
*/
|
|
48
|
+
function fingerprintCanonical(payload) {
|
|
49
|
+
const canon = canonicalise(payload);
|
|
50
|
+
return (0, node_crypto_1.createHash)('sha256').update(JSON.stringify(canon)).digest('hex');
|
|
51
|
+
}
|
|
52
|
+
function canonicalise(v) {
|
|
53
|
+
if (v === null || v === undefined)
|
|
54
|
+
return null;
|
|
55
|
+
if (Array.isArray(v))
|
|
56
|
+
return v.map(canonicalise);
|
|
57
|
+
if (typeof v === 'object') {
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const k of Object.keys(v).sort()) {
|
|
60
|
+
const val = v[k];
|
|
61
|
+
if (val !== undefined)
|
|
62
|
+
out[k] = canonicalise(val);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
return v;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Attempt to claim (namespace, key) for an incoming request. Atomic
|
|
70
|
+
* via INSERT OR IGNORE against the (namespace, key) PK + a follow-up
|
|
71
|
+
* SELECT for the existing row.
|
|
72
|
+
*/
|
|
73
|
+
function acquire(db, opts) {
|
|
74
|
+
const nowMs = (opts.now ?? Date.now)();
|
|
75
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
76
|
+
const expires = opts.ttlMs ? new Date(nowMs + opts.ttlMs).toISOString() : null;
|
|
77
|
+
const result = db.prepare(`INSERT OR IGNORE INTO run_idempotency_keys
|
|
78
|
+
(namespace, key, fingerprint, status, created_at, expires_at)
|
|
79
|
+
VALUES (?, ?, ?, 'accepted', ?, ?)`).run(opts.namespace, opts.key, opts.fingerprint, nowIso, expires);
|
|
80
|
+
if (result.changes > 0) {
|
|
81
|
+
const row = readRow(db, opts.namespace, opts.key);
|
|
82
|
+
return { outcome: 'accepted', row };
|
|
83
|
+
}
|
|
84
|
+
// Collision — read existing and compare fingerprint.
|
|
85
|
+
const existing = readRow(db, opts.namespace, opts.key);
|
|
86
|
+
if (!existing) {
|
|
87
|
+
// Defensive: should not happen, the INSERT OR IGNORE only skips
|
|
88
|
+
// when the PK conflicts.
|
|
89
|
+
throw new Error('runIdempotencyStore.acquire: INSERT OR IGNORE skipped but no row found');
|
|
90
|
+
}
|
|
91
|
+
if (existing.fingerprint === opts.fingerprint) {
|
|
92
|
+
return { outcome: 'duplicate', existing };
|
|
93
|
+
}
|
|
94
|
+
return { outcome: 'rejected_conflict', existing };
|
|
95
|
+
}
|
|
96
|
+
/** Back-fill the run/trigger/span FKs once those rows exist. */
|
|
97
|
+
function link(db, opts) {
|
|
98
|
+
db.prepare(`UPDATE run_idempotency_keys
|
|
99
|
+
SET run_id = COALESCE(?, run_id),
|
|
100
|
+
trigger_event_id = COALESCE(?, trigger_event_id),
|
|
101
|
+
span_id = COALESCE(?, span_id)
|
|
102
|
+
WHERE namespace = ? AND key = ?`).run(opts.runId ?? null, opts.triggerEventId ?? null, opts.spanId ?? null, opts.namespace, opts.key);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Patch the terminal status + optional result_ref. Caller picks the
|
|
106
|
+
* status: `'completed'` for success, `'failed'` for terminal failure.
|
|
107
|
+
*/
|
|
108
|
+
function complete(db, opts) {
|
|
109
|
+
db.prepare(`UPDATE run_idempotency_keys
|
|
110
|
+
SET status = ?,
|
|
111
|
+
result_ref = COALESCE(?, result_ref)
|
|
112
|
+
WHERE namespace = ? AND key = ?`).run(opts.status, opts.resultRef ?? null, opts.namespace, opts.key);
|
|
113
|
+
}
|
|
114
|
+
/** Delete keys whose `expires_at` is in the past. Returns the count. */
|
|
115
|
+
function sweepExpired(db, now) {
|
|
116
|
+
const nowIso = new Date(now ?? Date.now()).toISOString();
|
|
117
|
+
const r = db.prepare(`DELETE FROM run_idempotency_keys
|
|
118
|
+
WHERE expires_at IS NOT NULL AND expires_at < ?`).run(nowIso);
|
|
119
|
+
return { deleted: Number(r.changes ?? 0) };
|
|
120
|
+
}
|
|
121
|
+
/** Diagnostic — single-key lookup. */
|
|
122
|
+
function getKey(db, namespace, key) {
|
|
123
|
+
return readRow(db, namespace, key);
|
|
124
|
+
}
|
|
125
|
+
function readRow(db, namespace, key) {
|
|
126
|
+
const r = db.prepare(`SELECT * FROM run_idempotency_keys WHERE namespace = ? AND key = ?`).get(namespace, key);
|
|
127
|
+
return r ?? null;
|
|
128
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/daemon/incarnationStore.ts — v4.9.0 Slice 4.
|
|
10
|
+
*
|
|
11
|
+
* Thin DDL wrapper around the `daemon_incarnations` table (schema v8).
|
|
12
|
+
* Three operations:
|
|
13
|
+
*
|
|
14
|
+
* 1. `insertIncarnation()` — boot. Writes one row with `started_at`
|
|
15
|
+
* = now ISO. Idempotent on PRIMARY KEY collision (safe re-call).
|
|
16
|
+
* 2. `markEnded()` — clean shutdown. Patches `ended_at`, `exit_reason`,
|
|
17
|
+
* `exit_code` for this incarnation. No-op when the row is missing
|
|
18
|
+
* (defensive — e.g. SQLite died before insert).
|
|
19
|
+
* 3. `lastForDaemon()` — diagnostic. Returns the most recent row for
|
|
20
|
+
* a given daemon_id. Used by `aiden doctor`-style surfaces (not
|
|
21
|
+
* wired in this slice).
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.insertIncarnation = insertIncarnation;
|
|
25
|
+
exports.markEnded = markEnded;
|
|
26
|
+
exports.lastForDaemon = lastForDaemon;
|
|
27
|
+
function insertIncarnation(db, opts) {
|
|
28
|
+
const started = opts.startedAt ?? new Date().toISOString();
|
|
29
|
+
db.prepare(`INSERT OR IGNORE INTO daemon_incarnations
|
|
30
|
+
(incarnation_id, daemon_id, pid, started_at, aiden_version, node_version)
|
|
31
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(opts.incarnationId, opts.daemonId, opts.pid, started, opts.aidenVersion, opts.nodeVersion);
|
|
32
|
+
}
|
|
33
|
+
function markEnded(db, opts) {
|
|
34
|
+
const ended = opts.endedAt ?? new Date().toISOString();
|
|
35
|
+
db.prepare(`UPDATE daemon_incarnations
|
|
36
|
+
SET ended_at = COALESCE(ended_at, ?),
|
|
37
|
+
exit_reason = COALESCE(exit_reason, ?),
|
|
38
|
+
exit_code = COALESCE(exit_code, ?)
|
|
39
|
+
WHERE incarnation_id = ?`).run(ended, opts.exitReason, opts.exitCode, opts.incarnationId);
|
|
40
|
+
}
|
|
41
|
+
function lastForDaemon(db, daemonId) {
|
|
42
|
+
const r = db.prepare(`SELECT * FROM daemon_incarnations
|
|
43
|
+
WHERE daemon_id = ?
|
|
44
|
+
ORDER BY started_at DESC
|
|
45
|
+
LIMIT 1`).get(daemonId);
|
|
46
|
+
return r ?? null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/daemon/runs/attemptStore.ts — v4.9.0 Slice 5.
|
|
10
|
+
*
|
|
11
|
+
* One row per execution attempt of a run. `runs.id` (numeric, autoincrement)
|
|
12
|
+
* stays the canonical run identifier; `run_attempts.attempt_id`
|
|
13
|
+
* (`att_<uuidv7>`) is the per-try identifier. attempt_number is 1-indexed
|
|
14
|
+
* and stable per (run_id) — the store computes it via MAX+1 on insert.
|
|
15
|
+
*
|
|
16
|
+
* Slice 5 lands schema + writers. Retry-policy logic (which attempts
|
|
17
|
+
* get retried, with what cooldown) is deferred to Slice 6+.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.createAttempt = createAttempt;
|
|
21
|
+
exports.completeAttempt = completeAttempt;
|
|
22
|
+
exports.listAttemptsForRun = listAttemptsForRun;
|
|
23
|
+
exports.getAttempt = getAttempt;
|
|
24
|
+
const identity_1 = require("../../identity");
|
|
25
|
+
/**
|
|
26
|
+
* Create a fresh attempt for the given run. `attempt_number` is derived
|
|
27
|
+
* by MAX+1 inside a transaction so concurrent creators don't collide.
|
|
28
|
+
* Returns the new attempt id.
|
|
29
|
+
*/
|
|
30
|
+
function createAttempt(db, opts) {
|
|
31
|
+
const attemptId = opts.attemptId ?? (0, identity_1.newAttemptId)();
|
|
32
|
+
const startedAt = opts.startedAt ?? new Date().toISOString();
|
|
33
|
+
const tx = db.transaction(() => {
|
|
34
|
+
const row = db.prepare(`SELECT COALESCE(MAX(attempt_number), 0) AS n FROM run_attempts WHERE run_id = ?`).get(opts.runId);
|
|
35
|
+
const nextNumber = row.n + 1;
|
|
36
|
+
db.prepare(`INSERT INTO run_attempts
|
|
37
|
+
(attempt_id, run_id, attempt_number, incarnation_id, started_at, status)
|
|
38
|
+
VALUES (?, ?, ?, ?, ?, 'running')`).run(attemptId, opts.runId, nextNumber, opts.incarnationId, startedAt);
|
|
39
|
+
});
|
|
40
|
+
tx();
|
|
41
|
+
return attemptId;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Patch an attempt with a terminal status. COALESCE-protected so the
|
|
45
|
+
* first-wins semantics match the Slice 4 incarnation pattern: if the
|
|
46
|
+
* attempt already has an `ended_at`, this call is a no-op for those
|
|
47
|
+
* fields (status update only applies to the in-flight case).
|
|
48
|
+
*/
|
|
49
|
+
function completeAttempt(db, opts) {
|
|
50
|
+
const endedAt = opts.endedAt ?? new Date().toISOString();
|
|
51
|
+
db.prepare(`UPDATE run_attempts
|
|
52
|
+
SET status = ?,
|
|
53
|
+
ended_at = COALESCE(ended_at, ?),
|
|
54
|
+
finish_reason = COALESCE(finish_reason, ?),
|
|
55
|
+
error_class = COALESCE(error_class, ?),
|
|
56
|
+
error_message = COALESCE(error_message, ?)
|
|
57
|
+
WHERE attempt_id = ?`).run(opts.status, endedAt, opts.finishReason ?? null, opts.errorClass ?? null, opts.errorMessage ?? null, opts.attemptId);
|
|
58
|
+
}
|
|
59
|
+
/** List all attempts for a run in attempt-number order. */
|
|
60
|
+
function listAttemptsForRun(db, runId) {
|
|
61
|
+
return db.prepare(`SELECT * FROM run_attempts WHERE run_id = ? ORDER BY attempt_number ASC`).all(runId);
|
|
62
|
+
}
|
|
63
|
+
/** Diagnostic — single-attempt lookup. */
|
|
64
|
+
function getAttempt(db, attemptId) {
|
|
65
|
+
const r = db.prepare(`SELECT * FROM run_attempts WHERE attempt_id = ?`).get(attemptId);
|
|
66
|
+
return r ?? null;
|
|
67
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/daemon/runs/reclaim.ts — v4.9.0 Slice 3.
|
|
10
|
+
*
|
|
11
|
+
* Mark runs orphaned by a crashed daemon as `interrupted` so a follow-up
|
|
12
|
+
* boot (or a human looking at `aiden runs list`) sees an unambiguous
|
|
13
|
+
* post-mortem instead of an eternally-`running` row.
|
|
14
|
+
*
|
|
15
|
+
* Called from two sites:
|
|
16
|
+
* 1. Process-wide crash handlers (`uncaughtException` /
|
|
17
|
+
* `unhandledRejection`) installed in `bootstrap.ts`. Before the
|
|
18
|
+
* handler calls `process.exit(1)`, it reclaims this incarnation's
|
|
19
|
+
* still-running rows. The DB connection is the same handle the
|
|
20
|
+
* dispatcher used; the write is one statement so even a crashed
|
|
21
|
+
* process can finish it.
|
|
22
|
+
* 2. Boot-time, against ANY non-current instance — defence in depth
|
|
23
|
+
* for the case where `evaluateBootState` didn't sweep a row (e.g.
|
|
24
|
+
* the prior crash happened after BootState ran but before the
|
|
25
|
+
* run completed). Idempotent: a no-op when no rows match.
|
|
26
|
+
*
|
|
27
|
+
* Uses existing schema columns only — NO new tables, NO new RunStatus
|
|
28
|
+
* value. Sets `status='interrupted', finish_reason='daemon_crashed',
|
|
29
|
+
* resume_pending=1, resume_reason='daemon_crashed', completed_at=now`.
|
|
30
|
+
* Matches the semantics `evaluateBootState` uses for prior-instance
|
|
31
|
+
* crash recovery, so downstream consumers (`aiden runs list`, restart
|
|
32
|
+
* resume logic) see one consistent shape.
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.reclaimStuckRuns = reclaimStuckRuns;
|
|
36
|
+
/**
|
|
37
|
+
* Reclaim stuck `runs.status='running'` rows. Returns the rows touched
|
|
38
|
+
* so the caller can emit `run_events` entries (skipped on the crash
|
|
39
|
+
* path — the inserts could themselves throw, and the UPDATE is the
|
|
40
|
+
* authoritative outcome).
|
|
41
|
+
*/
|
|
42
|
+
function reclaimStuckRuns(db, opts) {
|
|
43
|
+
const now = (opts.now ?? Date.now)();
|
|
44
|
+
// Phase 1 — select the candidate rows. We pull ids first so the
|
|
45
|
+
// caller can act on them (logging, event emission) without a second
|
|
46
|
+
// round-trip. Using a single WHERE clause keeps the index hit on
|
|
47
|
+
// idx_runs_active (`status IN ('queued','running')`).
|
|
48
|
+
let candidates;
|
|
49
|
+
if (opts.instanceId !== undefined) {
|
|
50
|
+
candidates = db.prepare(`SELECT id FROM runs WHERE status = 'running' AND instance_id = ?`).all(opts.instanceId);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
if (!opts.currentInstanceId) {
|
|
54
|
+
throw new Error('reclaimStuckRuns: currentInstanceId required when instanceId omitted');
|
|
55
|
+
}
|
|
56
|
+
candidates = db.prepare(`SELECT id FROM runs WHERE status = 'running' AND instance_id != ?`).all(opts.currentInstanceId);
|
|
57
|
+
}
|
|
58
|
+
if (candidates.length === 0) {
|
|
59
|
+
return { reclaimed: 0, runIds: [] };
|
|
60
|
+
}
|
|
61
|
+
// Phase 2 — single UPDATE for the matching predicate. We re-derive
|
|
62
|
+
// the WHERE from `opts` rather than passing ids inline (would blow
|
|
63
|
+
// past SQLite's bound-parameter cap on a pathologically large run
|
|
64
|
+
// table — unlikely, but cheap to avoid).
|
|
65
|
+
let updateResult;
|
|
66
|
+
if (opts.instanceId !== undefined) {
|
|
67
|
+
updateResult = db.prepare(`UPDATE runs
|
|
68
|
+
SET status = 'interrupted',
|
|
69
|
+
finish_reason = 'daemon_crashed',
|
|
70
|
+
completed_at = ?,
|
|
71
|
+
resume_pending = 1,
|
|
72
|
+
resume_reason = 'daemon_crashed'
|
|
73
|
+
WHERE status = 'running' AND instance_id = ?`).run(now, opts.instanceId);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
updateResult = db.prepare(`UPDATE runs
|
|
77
|
+
SET status = 'interrupted',
|
|
78
|
+
finish_reason = 'daemon_crashed',
|
|
79
|
+
completed_at = ?,
|
|
80
|
+
resume_pending = 1,
|
|
81
|
+
resume_reason = 'daemon_crashed'
|
|
82
|
+
WHERE status = 'running' AND instance_id != ?`).run(now, opts.currentInstanceId);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
reclaimed: Number(updateResult.changes ?? candidates.length),
|
|
86
|
+
runIds: candidates.map((c) => c.id),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/daemon/runs/retryPolicy.ts — v4.9.0 Slice 8.
|
|
10
|
+
*
|
|
11
|
+
* Pure policy primitives — `shouldRetry` + `computeBackoffMs`. The
|
|
12
|
+
* orchestrator (`runWithRetry`) lives next door; this module is
|
|
13
|
+
* test-friendly with no side effects.
|
|
14
|
+
*
|
|
15
|
+
* Defaults chosen to match the implicit policy across the rest of
|
|
16
|
+
* v4: max 3 attempts, exponential backoff with full jitter capped
|
|
17
|
+
* at 30s. NetworkError / TimeoutError / ResourceExhausted are
|
|
18
|
+
* retryable; auth + permission + validation are terminal.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.DEFAULT_RETRY_POLICY = void 0;
|
|
22
|
+
exports.shouldRetry = shouldRetry;
|
|
23
|
+
exports.computeBackoffMs = computeBackoffMs;
|
|
24
|
+
exports.DEFAULT_RETRY_POLICY = {
|
|
25
|
+
maxAttempts: 3,
|
|
26
|
+
baseDelayMs: 1000,
|
|
27
|
+
maxDelayMs: 30000,
|
|
28
|
+
jitter: 'full',
|
|
29
|
+
retryableErrorClasses: ['NetworkError', 'TimeoutError', 'ResourceExhausted'],
|
|
30
|
+
nonRetryableErrorClasses: ['AuthError', 'PermissionDenied', 'ValidationError'],
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Decide whether to retry given the error class + attempt number.
|
|
34
|
+
*
|
|
35
|
+
* Precedence:
|
|
36
|
+
* 1. attemptNumber >= maxAttempts → false (cap reached)
|
|
37
|
+
* 2. nonRetryableErrorClasses hit → false (terminal)
|
|
38
|
+
* 3. retryableErrorClasses hit → true
|
|
39
|
+
* 4. unknown error class → false (fail closed)
|
|
40
|
+
*
|
|
41
|
+
* Treating unknown errors as non-retryable matches Aiden's safety
|
|
42
|
+
* stance: a retry burst on an unknown failure can multiply damage.
|
|
43
|
+
*/
|
|
44
|
+
function shouldRetry(errorClass, attemptNumber, policy = exports.DEFAULT_RETRY_POLICY) {
|
|
45
|
+
if (attemptNumber >= policy.maxAttempts)
|
|
46
|
+
return false;
|
|
47
|
+
if (policy.nonRetryableErrorClasses.includes(errorClass))
|
|
48
|
+
return false;
|
|
49
|
+
if (policy.retryableErrorClasses.includes(errorClass))
|
|
50
|
+
return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Exponential backoff with optional jitter, capped at `maxDelayMs`.
|
|
55
|
+
* attemptNumber=1 → base
|
|
56
|
+
* attemptNumber=2 → base*2
|
|
57
|
+
* attemptNumber=3 → base*4
|
|
58
|
+
* Jitter modes:
|
|
59
|
+
* 'none' — exact value
|
|
60
|
+
* 'equal' — value/2 + random(0..value/2)
|
|
61
|
+
* 'full' — random(0..value)
|
|
62
|
+
*/
|
|
63
|
+
function computeBackoffMs(attemptNumber, policy = exports.DEFAULT_RETRY_POLICY, rng = Math.random) {
|
|
64
|
+
if (attemptNumber < 1)
|
|
65
|
+
return 0;
|
|
66
|
+
const exp = policy.baseDelayMs * Math.pow(2, attemptNumber - 1);
|
|
67
|
+
const capped = Math.min(exp, policy.maxDelayMs);
|
|
68
|
+
switch (policy.jitter) {
|
|
69
|
+
case 'none': return capped;
|
|
70
|
+
case 'equal': return Math.floor(capped / 2 + rng() * (capped / 2));
|
|
71
|
+
case 'full': return Math.floor(rng() * capped);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) 2026 Shiva Deore (Taracod).
|
|
4
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
5
|
+
*
|
|
6
|
+
* Aiden — local-first agent.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* core/v4/daemon/runs/runWithRetry.ts — v4.9.0 Slice 8.
|
|
10
|
+
*
|
|
11
|
+
* Attempt orchestration on top of Slice 5's `run_attempts` table.
|
|
12
|
+
* Caller supplies an existing run row id + a `RetryPolicy`; this
|
|
13
|
+
* function creates one `run_attempts` row per attempt, runs `fn`
|
|
14
|
+
* inside an incrementing-attempt context, and on retryable error
|
|
15
|
+
* sleeps + tries again. On non-retryable error OR max-attempts cap,
|
|
16
|
+
* returns `'dead_letter'` so the caller can route to a poison queue.
|
|
17
|
+
*
|
|
18
|
+
* The "what's retryable" decision lives in `retryPolicy.ts` so the
|
|
19
|
+
* orchestrator stays small + testable.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.runWithRetry = runWithRetry;
|
|
23
|
+
const attemptStore_1 = require("./attemptStore");
|
|
24
|
+
const retryPolicy_1 = require("./retryPolicy");
|
|
25
|
+
function defaultSleep(ms) {
|
|
26
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Run `fn` with retry policy. Each attempt gets its own `run_attempts`
|
|
30
|
+
* row + an incremented `ctx.attempt` value visible to the callee.
|
|
31
|
+
*/
|
|
32
|
+
async function runWithRetry(db, ctx, opts, fn) {
|
|
33
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
34
|
+
let lastError = new Error('runWithRetry: no attempts made');
|
|
35
|
+
let attemptCount = 0;
|
|
36
|
+
for (let attemptNumber = 1; attemptNumber <= opts.policy.maxAttempts; attemptNumber += 1) {
|
|
37
|
+
attemptCount = attemptNumber;
|
|
38
|
+
const attemptId = (0, attemptStore_1.createAttempt)(db, {
|
|
39
|
+
runId: opts.runId,
|
|
40
|
+
incarnationId: opts.incarnationId,
|
|
41
|
+
});
|
|
42
|
+
const attemptCtx = { ...ctx, attempt: attemptNumber };
|
|
43
|
+
try {
|
|
44
|
+
const value = await fn(attemptCtx, attemptNumber);
|
|
45
|
+
(0, attemptStore_1.completeAttempt)(db, { attemptId, status: 'completed' });
|
|
46
|
+
return { outcome: 'completed', value, attempts: attemptNumber };
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
50
|
+
lastError = error;
|
|
51
|
+
const errorClass = error.name || 'Error';
|
|
52
|
+
const terminalStatus = 'failed';
|
|
53
|
+
(0, attemptStore_1.completeAttempt)(db, {
|
|
54
|
+
attemptId,
|
|
55
|
+
status: terminalStatus,
|
|
56
|
+
errorClass,
|
|
57
|
+
errorMessage: error.message,
|
|
58
|
+
});
|
|
59
|
+
// Three cases:
|
|
60
|
+
// (a) Class is in nonRetryableErrorClasses → dead_letter (terminal class)
|
|
61
|
+
// (b) Class is in retryableErrorClasses → retry IF cap not reached; else dead_letter (exhausted)
|
|
62
|
+
// (c) Class is unknown → failed (caller introspects; no auto-retry)
|
|
63
|
+
const isNonRetryable = opts.policy.nonRetryableErrorClasses.includes(errorClass);
|
|
64
|
+
const isRetryable = opts.policy.retryableErrorClasses.includes(errorClass);
|
|
65
|
+
if (isNonRetryable) {
|
|
66
|
+
return { outcome: 'dead_letter', attempts: attemptNumber, lastError: error };
|
|
67
|
+
}
|
|
68
|
+
if (!isRetryable) {
|
|
69
|
+
return { outcome: 'failed', attempts: attemptNumber, lastError: error };
|
|
70
|
+
}
|
|
71
|
+
if (attemptNumber >= opts.policy.maxAttempts) {
|
|
72
|
+
return { outcome: 'dead_letter', attempts: attemptNumber, lastError: error };
|
|
73
|
+
}
|
|
74
|
+
// Retryable + cap not reached: back off and try again.
|
|
75
|
+
await sleep((0, retryPolicy_1.computeBackoffMs)(attemptNumber, opts.policy));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Defensive fallback — loop exited without explicit return.
|
|
79
|
+
return { outcome: 'dead_letter', attempts: attemptCount, lastError };
|
|
80
|
+
}
|