aiden-runtime 4.8.1 → 4.9.1

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.
Files changed (100) hide show
  1. package/README.md +88 -1
  2. package/dist/cli/v4/aidenCLI.js +37 -6
  3. package/dist/cli/v4/chatSession.js +53 -13
  4. package/dist/cli/v4/commands/daemon.js +53 -3
  5. package/dist/cli/v4/commands/daemonDoctor.js +212 -0
  6. package/dist/cli/v4/commands/daemonStatus.js +45 -26
  7. package/dist/cli/v4/commands/help.js +5 -0
  8. package/dist/cli/v4/commands/hooks.js +466 -0
  9. package/dist/cli/v4/commands/hooksSlash.js +33 -0
  10. package/dist/cli/v4/commands/index.js +13 -1
  11. package/dist/cli/v4/commands/mcp.js +89 -1
  12. package/dist/cli/v4/commands/mcpClientInstall.js +359 -0
  13. package/dist/cli/v4/commands/memory.js +707 -0
  14. package/dist/cli/v4/commands/memorySlash.js +38 -0
  15. package/dist/cli/v4/commands/recovery.js +1 -1
  16. package/dist/cli/v4/commands/skin.js +7 -0
  17. package/dist/cli/v4/commands/theme.js +217 -0
  18. package/dist/cli/v4/commands/trigger.js +1 -1
  19. package/dist/cli/v4/design/tokens.js +52 -4
  20. package/dist/cli/v4/display.js +39 -26
  21. package/dist/cli/v4/replyRenderer.js +6 -5
  22. package/dist/cli/v4/skinEngine.js +67 -0
  23. package/dist/cli/v4/ui/progressBar.js +179 -0
  24. package/dist/cli/v4/util/closestAction.js +48 -0
  25. package/dist/core/v4/aidenAgent.js +45 -2
  26. package/dist/core/v4/daemon/api/runs.js +131 -0
  27. package/dist/core/v4/daemon/bootstrap.js +368 -13
  28. package/dist/core/v4/daemon/db/migrations.js +169 -0
  29. package/dist/core/v4/daemon/idempotency/runIdempotencyStore.js +128 -0
  30. package/dist/core/v4/daemon/incarnationStore.js +47 -0
  31. package/dist/core/v4/daemon/runs/attemptStore.js +67 -0
  32. package/dist/core/v4/daemon/runs/reclaim.js +88 -0
  33. package/dist/core/v4/daemon/runs/retryPolicy.js +73 -0
  34. package/dist/core/v4/daemon/runs/runWithRetry.js +80 -0
  35. package/dist/core/v4/daemon/runs/stuckAttemptWatchdog.js +72 -0
  36. package/dist/core/v4/daemon/spans/spanHelpers.js +272 -0
  37. package/dist/core/v4/daemon/spans/spanStore.js +113 -0
  38. package/dist/core/v4/daemon/triggerBus.js +50 -19
  39. package/dist/core/v4/hooks/auditQuery.js +67 -0
  40. package/dist/core/v4/hooks/dispatcher.js +286 -0
  41. package/dist/core/v4/hooks/index.js +46 -0
  42. package/dist/core/v4/hooks/lifecycle.js +27 -0
  43. package/dist/core/v4/hooks/manifest.js +142 -0
  44. package/dist/core/v4/hooks/registry.js +149 -0
  45. package/dist/core/v4/hooks/runtime/subprocessRunner.js +188 -0
  46. package/dist/core/v4/hooks/toolHookGate.js +76 -0
  47. package/dist/core/v4/hooks/trust.js +14 -0
  48. package/dist/core/v4/identity/contextManager.js +83 -0
  49. package/dist/core/v4/identity/daemonId.js +85 -0
  50. package/dist/core/v4/identity/enforcement.js +103 -0
  51. package/dist/core/v4/identity/executionContext.js +153 -0
  52. package/dist/core/v4/identity/hookExecution.js +62 -0
  53. package/dist/core/v4/identity/httpContext.js +68 -0
  54. package/dist/core/v4/identity/ids.js +185 -0
  55. package/dist/core/v4/identity/index.js +60 -0
  56. package/dist/core/v4/identity/subprocessContext.js +98 -0
  57. package/dist/core/v4/identity/traceparent.js +114 -0
  58. package/dist/core/v4/logger/index.js +3 -1
  59. package/dist/core/v4/logger/logger.js +28 -1
  60. package/dist/core/v4/logger/redact.js +149 -0
  61. package/dist/core/v4/logger/sinks/fileSink.js +13 -0
  62. package/dist/core/v4/logger/sinks/stdSink.js +19 -1
  63. package/dist/core/v4/mcp/install/backup.js +78 -0
  64. package/dist/core/v4/mcp/install/clientPaths.js +90 -0
  65. package/dist/core/v4/mcp/install/clients.js +203 -0
  66. package/dist/core/v4/mcp/install/healthCheck.js +83 -0
  67. package/dist/core/v4/mcp/install/jsoncMerge.js +174 -0
  68. package/dist/core/v4/mcp/install/profiles.js +109 -0
  69. package/dist/core/v4/mcp/install/wslDetect.js +62 -0
  70. package/dist/core/v4/memory/namespaceRegistry.js +117 -0
  71. package/dist/core/v4/memory/projectRoot.js +76 -0
  72. package/dist/core/v4/memory/reviewer/index.js +162 -0
  73. package/dist/core/v4/memory/reviewer/pendingStore.js +136 -0
  74. package/dist/core/v4/memory/reviewer/prompt.js +105 -0
  75. package/dist/core/v4/memory/reviewer/skipRules.js +92 -0
  76. package/dist/core/v4/memoryManager.js +57 -10
  77. package/dist/core/v4/paths.js +2 -0
  78. package/dist/core/v4/subagent/spawnSubAgent.js +20 -7
  79. package/dist/core/v4/theme/bundledThemes.js +106 -0
  80. package/dist/core/v4/theme/themeLoader.js +160 -0
  81. package/dist/core/v4/theme/themeRegistry.js +97 -0
  82. package/dist/core/v4/theme/themeWatcher.js +95 -0
  83. package/dist/core/v4/toolRegistry.js +71 -8
  84. package/dist/core/v4/update/depWarningFilter.js +76 -0
  85. package/dist/core/v4/update/executeInstall.js +41 -35
  86. package/dist/core/v4/update/platformInstructions.js +128 -0
  87. package/dist/moat/approvalEngine.js +4 -0
  88. package/dist/moat/memoryGuard.js +8 -1
  89. package/dist/providers/v4/anthropicAdapter.js +10 -4
  90. package/dist/tools/v4/backends/local.js +19 -2
  91. package/dist/tools/v4/sessions/recallSession.js +6 -1
  92. package/package.json +3 -1
  93. package/plugins/aiden-plugin-chatgpt-plus/index.js +10 -1
  94. package/themes/default.yaml +52 -0
  95. package/themes/dracula.yaml +32 -0
  96. package/themes/light.yaml +32 -0
  97. package/themes/monochrome.yaml +31 -0
  98. package/themes/tokyo-night.yaml +32 -0
  99. package/dist/core/pluginSystem.js +0 -121
  100. package/dist/tools/v4/ui/_uiSmokeTool.js +0 -60
@@ -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
+ }
@@ -0,0 +1,72 @@
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/stuckAttemptWatchdog.ts — v4.9.0 Slice 8.
10
+ *
11
+ * Sweeps `run_attempts` (and `spans`) that are stuck `running` from
12
+ * a previous incarnation past `STUCK_THRESHOLD_MS`. Marks them
13
+ * `crashed` so post-mortem queries get one consistent shape rather
14
+ * than mixing "in-flight" with "abandoned".
15
+ *
16
+ * Wired as a `setInterval` ticker from bootstrap. Cadence default 5
17
+ * min, configurable via `AIDEN_STUCK_ATTEMPT_CHECK_MS`. Threshold 30
18
+ * min default, configurable via `AIDEN_STUCK_ATTEMPT_THRESHOLD_MS`.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.sweepStuckAttempts = sweepStuckAttempts;
22
+ const DEFAULT_THRESHOLD_MS = 30 * 60 * 1000;
23
+ /**
24
+ * Run a single sweep pass. Returns the count + ids of rows touched.
25
+ * Idempotent — calling twice in a row sweeps zero on the second call.
26
+ */
27
+ function sweepStuckAttempts(db, opts) {
28
+ const now = (opts.now ?? Date.now)();
29
+ const thresholdMs = opts.thresholdMs ?? DEFAULT_THRESHOLD_MS;
30
+ const cutoffIso = new Date(now - thresholdMs).toISOString();
31
+ const endedAtIso = new Date(now).toISOString();
32
+ // ── attempts ────────────────────────────────────────────────────────────
33
+ const attemptRows = db.prepare(`SELECT attempt_id FROM run_attempts
34
+ WHERE status = 'running'
35
+ AND incarnation_id != ?
36
+ AND started_at < ?`).all(opts.currentIncarnationId, cutoffIso);
37
+ const attemptIds = attemptRows.map((r) => r.attempt_id);
38
+ if (attemptIds.length > 0) {
39
+ db.prepare(`UPDATE run_attempts
40
+ SET status = 'crashed',
41
+ ended_at = COALESCE(ended_at, ?),
42
+ finish_reason = COALESCE(finish_reason, 'stuck_attempt_swept')
43
+ WHERE status = 'running'
44
+ AND incarnation_id != ?
45
+ AND started_at < ?`).run(endedAtIso, opts.currentIncarnationId, cutoffIso);
46
+ }
47
+ // ── spans ──────────────────────────────────────────────────────────────
48
+ // Open spans (status NULL = in-flight) from a non-current incarnation.
49
+ // We don't apply the threshold here — any open span owned by a dead
50
+ // incarnation is by definition stuck; the parent process is gone.
51
+ const spanRows = db.prepare(`SELECT span_id FROM spans
52
+ WHERE status IS NULL
53
+ AND ended_at IS NULL
54
+ AND incarnation_id != ?`).all(opts.currentIncarnationId);
55
+ const spanIds = spanRows.map((r) => r.span_id);
56
+ if (spanIds.length > 0) {
57
+ db.prepare(`UPDATE spans
58
+ SET status = 'cancelled',
59
+ ended_at = COALESCE(ended_at, ?),
60
+ error_class = COALESCE(error_class, 'OrphanedSpan'),
61
+ error_message = COALESCE(error_message, 'incarnation died with span open')
62
+ WHERE status IS NULL
63
+ AND ended_at IS NULL
64
+ AND incarnation_id != ?`).run(endedAtIso, opts.currentIncarnationId);
65
+ }
66
+ return {
67
+ reclaimedAttempts: attemptIds.length,
68
+ reclaimedSpans: spanIds.length,
69
+ attemptIds,
70
+ spanIds,
71
+ };
72
+ }