aiden-runtime 4.0.2 → 4.1.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 +11 -7
- package/config/hardware.json +2 -2
- package/dist/api/server.js +50 -52
- package/dist/cli/v4/aidenCLI.js +421 -5
- package/dist/cli/v4/aidenPrompt.js +317 -0
- package/dist/cli/v4/box.js +105 -39
- package/dist/cli/v4/callbacks.js +39 -6
- package/dist/cli/v4/chatSession.js +256 -55
- package/dist/cli/v4/citationFooter.js +97 -0
- package/dist/cli/v4/commands/channel.js +656 -0
- package/dist/cli/v4/commands/clear.js +1 -1
- package/dist/cli/v4/commands/compress.js +1 -1
- package/dist/cli/v4/commands/cron.js +44 -16
- package/dist/cli/v4/commands/fanout.js +236 -0
- package/dist/cli/v4/commands/help.js +15 -4
- package/dist/cli/v4/commands/history.js +84 -0
- package/dist/cli/v4/commands/index.js +16 -1
- package/dist/cli/v4/commands/mcp.js +358 -0
- package/dist/cli/v4/commands/show.js +43 -0
- package/dist/cli/v4/commands/skills.js +169 -4
- package/dist/cli/v4/commands/status.js +84 -0
- package/dist/cli/v4/commands/subagent.js +78 -0
- package/dist/cli/v4/commands/verbose.js +1 -1
- package/dist/cli/v4/commands/voice.js +218 -0
- package/dist/cli/v4/cronCli.js +103 -0
- package/dist/cli/v4/display.js +297 -13
- package/dist/cli/v4/doctor.js +41 -0
- package/dist/cli/v4/envSources.js +105 -0
- package/dist/cli/v4/ghostMatch.js +74 -0
- package/dist/cli/v4/historyStore.js +163 -0
- package/dist/cli/v4/pasteCompression.js +124 -0
- package/dist/cli/v4/pasteIntercept.js +203 -0
- package/dist/cli/v4/replyRenderer.js +209 -0
- package/dist/cli/v4/resizeGuard.js +92 -0
- package/dist/cli/v4/shellInterpolation.js +139 -0
- package/dist/cli/v4/skinEngine.js +21 -1
- package/dist/cli/v4/streamingPrefix.js +121 -0
- package/dist/cli/v4/syntaxHighlight.js +345 -0
- package/dist/cli/v4/table.js +216 -0
- package/dist/cli/v4/themeDetect.js +81 -0
- package/dist/cli/v4/uiBuild.js +74 -0
- package/dist/cli/v4/voiceCli.js +113 -0
- package/dist/cli/v4/voicePromptApi.js +196 -0
- package/dist/core/channels/discord.js +16 -10
- package/dist/core/channels/email.js +13 -9
- package/dist/core/channels/imessage.js +13 -9
- package/dist/core/channels/manager.js +25 -7
- package/dist/core/channels/pdf-extract.js +180 -0
- package/dist/core/channels/photo-vision.js +157 -0
- package/dist/core/channels/signal.js +11 -7
- package/dist/core/channels/slack.js +13 -10
- package/dist/core/channels/telegram-commands.js +154 -0
- package/dist/core/channels/telegram-groups.js +198 -0
- package/dist/core/channels/telegram-rate-limit.js +124 -0
- package/dist/core/channels/telegram.js +1980 -0
- package/dist/core/channels/twilio.js +11 -7
- package/dist/core/channels/webhook.js +9 -5
- package/dist/core/channels/whatsapp.js +15 -11
- package/dist/core/channels/whisper-transcribe.js +163 -0
- package/dist/core/cronManager.js +33 -294
- package/dist/core/gateway.js +29 -8
- package/dist/core/playwrightBridge.js +90 -0
- package/dist/core/v4/aidenAgent.js +35 -0
- package/dist/core/v4/auxiliaryClient.js +2 -2
- package/dist/core/v4/cron/atomicWrite.js +18 -4
- package/dist/core/v4/cron/cronExecute.js +300 -0
- package/dist/core/v4/cron/cronManager.js +502 -0
- package/dist/core/v4/cron/cronState.js +314 -0
- package/dist/core/v4/cron/cronTick.js +90 -0
- package/dist/core/v4/cron/diagnostics.js +104 -0
- package/dist/core/v4/cron/graceWindow.js +79 -0
- package/dist/core/v4/logger/factory.js +110 -0
- package/dist/core/v4/logger/index.js +22 -0
- package/dist/core/v4/logger/logger.js +101 -0
- package/dist/core/v4/logger/sinks/fileSink.js +110 -0
- package/dist/core/v4/logger/sinks/multiSink.js +43 -0
- package/dist/core/v4/logger/sinks/nullSink.js +53 -0
- package/dist/core/v4/logger/sinks/stdSink.js +81 -0
- package/dist/core/v4/mcp/server/diagnostics.js +40 -0
- package/dist/core/v4/mcp/server/skillBridge.js +94 -0
- package/dist/core/v4/mcp/server/stdioServer.js +119 -0
- package/dist/core/v4/mcp/server/toolBridge.js +168 -0
- package/dist/core/v4/platformPaths.js +105 -0
- package/dist/core/v4/providerFallback.js +25 -0
- package/dist/core/v4/skillLoader.js +21 -5
- package/dist/core/v4/skillMining/candidateStore.js +164 -0
- package/dist/core/v4/skillMining/extractorPrompt.js +111 -0
- package/dist/core/v4/skillMining/proposalBuilder.js +139 -0
- package/dist/core/v4/skillMining/skillMiner.js +191 -0
- package/dist/core/v4/skillMining/traceFingerprint.js +51 -0
- package/dist/core/v4/subagent/budget.js +76 -0
- package/dist/core/v4/subagent/diagnostics.js +22 -0
- package/dist/core/v4/subagent/fanout.js +216 -0
- package/dist/core/v4/subagent/merger.js +148 -0
- package/dist/core/v4/subagent/providerRotation.js +54 -0
- package/dist/core/v4/voice/audioStream.js +373 -0
- package/dist/core/v4/voice/cliVoice.js +393 -0
- package/dist/core/v4/voice/diagnostics.js +66 -0
- package/dist/core/v4/voice/ttsStream.js +193 -0
- package/dist/core/version.js +1 -1
- package/dist/core/visionAnalyze.js +291 -90
- package/dist/core/voice/audio.js +61 -5
- package/dist/core/voice/audioBackend.js +134 -0
- package/dist/core/voice/stt.js +61 -6
- package/dist/core/voice/tts.js +19 -3
- package/dist/tools/v4/index.js +32 -1
- package/dist/tools/v4/subagent/subagentFanout.js +166 -0
- package/package.json +11 -2
|
@@ -0,0 +1,314 @@
|
|
|
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/cron/cronState.ts — Phase v4.1-cron
|
|
10
|
+
*
|
|
11
|
+
* Durable state for the cron scheduler. One JSON file at
|
|
12
|
+
* `~/.aiden/cron_jobs.json` with shape:
|
|
13
|
+
*
|
|
14
|
+
* {
|
|
15
|
+
* schemaVersion: 2,
|
|
16
|
+
* updatedAt: "2026-05-09T..",
|
|
17
|
+
* jobs: [<CronJob>, ...]
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Three responsibilities:
|
|
21
|
+
*
|
|
22
|
+
* 1. Whole-state file lock via `proper-lockfile`. Lock path:
|
|
23
|
+
* `<state>.lock`. Non-blocking acquire (retries=0). Two
|
|
24
|
+
* processes racing → first wins, second gets `lockHeld`. The
|
|
25
|
+
* heartbeat skips silently when locked; user-driven API calls
|
|
26
|
+
* surface a clear error so the user can retry.
|
|
27
|
+
*
|
|
28
|
+
* 2. Schema migration. v1 = bare array `[CronJob, ...]`,
|
|
29
|
+
* v2 = enveloped. Detected on first read; auto-migrated
|
|
30
|
+
* transparently with one stderr line per process boot.
|
|
31
|
+
*
|
|
32
|
+
* 3. Auto-repair on JSON corruption. Try strict parse → fallback
|
|
33
|
+
* strip-trailing-commas → fallback empty + rename original
|
|
34
|
+
* to `.bak.<ts>`. Mirrors prior multi-agent systems' lesson:
|
|
35
|
+
* a partial write or external editor truncation should NOT
|
|
36
|
+
* leave the user with no scheduled jobs.
|
|
37
|
+
*
|
|
38
|
+
* Stateless module — every call opens, reads, mutates, writes,
|
|
39
|
+
* closes. The in-memory cache lives in `cronManager.ts` and is
|
|
40
|
+
* refreshed by the heartbeat.
|
|
41
|
+
*/
|
|
42
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
43
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
44
|
+
};
|
|
45
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
exports.defaultCronPaths = defaultCronPaths;
|
|
47
|
+
exports.migrateToV2 = migrateToV2;
|
|
48
|
+
exports.readCronState = readCronState;
|
|
49
|
+
exports.writeCronState = writeCronState;
|
|
50
|
+
exports.acquireCronLock = acquireCronLock;
|
|
51
|
+
exports.isCronLockHeld = isCronLockHeld;
|
|
52
|
+
exports.__resetMigrationLogForTests = __resetMigrationLogForTests;
|
|
53
|
+
const node_fs_1 = require("node:fs");
|
|
54
|
+
const node_fs_2 = require("node:fs");
|
|
55
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
56
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
57
|
+
const atomicWrite_1 = require("./atomicWrite");
|
|
58
|
+
const diagnostics_1 = require("./diagnostics");
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
60
|
+
const lockfile = require('proper-lockfile');
|
|
61
|
+
function defaultCronPaths(homeOverride) {
|
|
62
|
+
// Honor AIDEN_HOME (used by tests + multi-profile workflows). When
|
|
63
|
+
// set, paths root IS AIDEN_HOME directly (no `.aiden` suffix —
|
|
64
|
+
// mirrors `core/v4/paths.ts::resolveAidenRoot`). Otherwise default
|
|
65
|
+
// to ~/.aiden.
|
|
66
|
+
let root;
|
|
67
|
+
if (homeOverride && homeOverride.length > 0) {
|
|
68
|
+
root = homeOverride;
|
|
69
|
+
}
|
|
70
|
+
else if (process.env.AIDEN_HOME && process.env.AIDEN_HOME.trim().length > 0) {
|
|
71
|
+
root = process.env.AIDEN_HOME.trim();
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
root = node_path_1.default.join(node_os_1.default.homedir(), '.aiden');
|
|
75
|
+
}
|
|
76
|
+
const stateFile = node_path_1.default.join(root, 'cron_jobs.json');
|
|
77
|
+
return {
|
|
78
|
+
stateFile,
|
|
79
|
+
lockFile: `${stateFile}.lock`,
|
|
80
|
+
logsDir: node_path_1.default.join(root, 'cron-logs'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// ── Migration ────────────────────────────────────────────────────────────
|
|
84
|
+
let _migrationLogged = false;
|
|
85
|
+
/** Migrate a parsed v1 (bare array) to v2 envelope. Idempotent —
|
|
86
|
+
* v2 envelopes pass through unchanged. Mutates in place + returns
|
|
87
|
+
* the new envelope. Logs ONE stderr line per process boot. */
|
|
88
|
+
function migrateToV2(parsed) {
|
|
89
|
+
// v2 envelope — pass through.
|
|
90
|
+
if (parsed
|
|
91
|
+
&& typeof parsed === 'object'
|
|
92
|
+
&& !Array.isArray(parsed)
|
|
93
|
+
&& 'schemaVersion' in parsed
|
|
94
|
+
&& parsed.schemaVersion === diagnostics_1.CRON_SCHEMA_VERSION) {
|
|
95
|
+
return enrichV2(parsed);
|
|
96
|
+
}
|
|
97
|
+
// v1 bare array — wrap.
|
|
98
|
+
if (Array.isArray(parsed)) {
|
|
99
|
+
if (!_migrationLogged) {
|
|
100
|
+
try {
|
|
101
|
+
process.stderr.write('v4.1-cron: migrated cron_jobs.json schema v1 → v2\n');
|
|
102
|
+
}
|
|
103
|
+
catch { /* non-fatal */ }
|
|
104
|
+
_migrationLogged = true;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
|
|
108
|
+
updatedAt: new Date().toISOString(),
|
|
109
|
+
jobs: parsed.map(migrateJobToV2).filter((j) => j !== null),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// Anything else — empty registry.
|
|
113
|
+
return {
|
|
114
|
+
schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
|
|
115
|
+
updatedAt: new Date().toISOString(),
|
|
116
|
+
jobs: [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Per-job migration. Adds default values for fields that didn't
|
|
120
|
+
* exist in v1. Drops malformed records (returns null). */
|
|
121
|
+
function migrateJobToV2(raw) {
|
|
122
|
+
if (!raw || typeof raw !== 'object')
|
|
123
|
+
return null;
|
|
124
|
+
const o = raw;
|
|
125
|
+
if (typeof o.id !== 'string' || !o.id)
|
|
126
|
+
return null;
|
|
127
|
+
if (typeof o.action !== 'string')
|
|
128
|
+
return null;
|
|
129
|
+
// Detect kind if missing (pre-v4.1-cron legacy).
|
|
130
|
+
let kind = 'interval';
|
|
131
|
+
if (typeof o.kind === 'string'
|
|
132
|
+
&& (o.kind === 'interval' || o.kind === 'cron' || o.kind === 'oneshot')) {
|
|
133
|
+
kind = o.kind;
|
|
134
|
+
}
|
|
135
|
+
else if (typeof o.cronExpr === 'string')
|
|
136
|
+
kind = 'cron';
|
|
137
|
+
else if (typeof o.oneshotIso === 'string')
|
|
138
|
+
kind = 'oneshot';
|
|
139
|
+
// Discriminated state — derive from `enabled` when absent.
|
|
140
|
+
const enabled = typeof o.enabled === 'boolean' ? o.enabled : true;
|
|
141
|
+
let state = enabled ? 'scheduled' : 'paused';
|
|
142
|
+
if (typeof o.state === 'string'
|
|
143
|
+
&& (o.state === 'scheduled' || o.state === 'paused'
|
|
144
|
+
|| o.state === 'completed' || o.state === 'error')) {
|
|
145
|
+
state = o.state;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
id: String(o.id),
|
|
149
|
+
description: typeof o.description === 'string' ? o.description : '',
|
|
150
|
+
schedule: typeof o.schedule === 'string' ? o.schedule : '',
|
|
151
|
+
kind,
|
|
152
|
+
intervalMs: typeof o.intervalMs === 'number' ? o.intervalMs : undefined,
|
|
153
|
+
cronExpr: typeof o.cronExpr === 'string' ? o.cronExpr : undefined,
|
|
154
|
+
oneshotIso: typeof o.oneshotIso === 'string' ? o.oneshotIso : undefined,
|
|
155
|
+
action: String(o.action),
|
|
156
|
+
enabled,
|
|
157
|
+
state,
|
|
158
|
+
pausedAt: typeof o.pausedAt === 'string' ? o.pausedAt : null,
|
|
159
|
+
pausedReason: typeof o.pausedReason === 'string' ? o.pausedReason : null,
|
|
160
|
+
createdAt: typeof o.createdAt === 'string' ? o.createdAt : new Date().toISOString(),
|
|
161
|
+
lastRun: typeof o.lastRun === 'string' ? o.lastRun : undefined,
|
|
162
|
+
lastResult: typeof o.lastResult === 'string' ? o.lastResult : undefined,
|
|
163
|
+
lastOutput: typeof o.lastOutput === 'string' ? o.lastOutput : undefined,
|
|
164
|
+
lastError: typeof o.lastError === 'string' ? o.lastError : null,
|
|
165
|
+
lastDeliveryError: typeof o.lastDeliveryError === 'string' ? o.lastDeliveryError : null,
|
|
166
|
+
nextRun: typeof o.nextRun === 'string' ? o.nextRun : undefined,
|
|
167
|
+
runCount: typeof o.runCount === 'number' ? o.runCount : 0,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** v2-shaped envelope — make sure all jobs have the new fields with
|
|
171
|
+
* defaults. Catches the "user manually edited cron_jobs.json"
|
|
172
|
+
* case. */
|
|
173
|
+
function enrichV2(env) {
|
|
174
|
+
return {
|
|
175
|
+
schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
|
|
176
|
+
updatedAt: env.updatedAt ?? new Date().toISOString(),
|
|
177
|
+
jobs: (env.jobs ?? []).map((j) => ({
|
|
178
|
+
...j,
|
|
179
|
+
state: j.state ?? (j.enabled ? 'scheduled' : 'paused'),
|
|
180
|
+
pausedAt: j.pausedAt ?? null,
|
|
181
|
+
pausedReason: j.pausedReason ?? null,
|
|
182
|
+
lastError: j.lastError ?? null,
|
|
183
|
+
lastDeliveryError: j.lastDeliveryError ?? null,
|
|
184
|
+
})),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// ── Auto-repair / load ────────────────────────────────────────────────────
|
|
188
|
+
/** Read state from disk. Auto-migrates v1 → v2; auto-repairs on
|
|
189
|
+
* corrupt JSON. Returns an empty envelope when the file doesn't
|
|
190
|
+
* exist. NEVER throws — corrupt-state is ALWAYS recoverable. */
|
|
191
|
+
async function readCronState(stateFile) {
|
|
192
|
+
let raw;
|
|
193
|
+
try {
|
|
194
|
+
raw = await node_fs_1.promises.readFile(stateFile, 'utf-8');
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
if (err.code === 'ENOENT') {
|
|
198
|
+
return {
|
|
199
|
+
schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
|
|
200
|
+
updatedAt: new Date().toISOString(),
|
|
201
|
+
jobs: [],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// Permission / EBUSY — leave caller to handle, but do NOT lose state.
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
// Try strict parse.
|
|
208
|
+
try {
|
|
209
|
+
return migrateToV2(JSON.parse(raw));
|
|
210
|
+
}
|
|
211
|
+
catch { /* fall through to repair */ }
|
|
212
|
+
// Auto-repair: strip trailing commas (most common bare-edit corruption).
|
|
213
|
+
try {
|
|
214
|
+
const stripped = raw
|
|
215
|
+
.replace(/,(\s*[}\]])/g, '$1') // trailing comma in object/array
|
|
216
|
+
.replace(/^\s*\/\/.*$/gm, ''); // line comments (defensive)
|
|
217
|
+
return migrateToV2(JSON.parse(stripped));
|
|
218
|
+
}
|
|
219
|
+
catch { /* fall through to bak-and-empty */ }
|
|
220
|
+
// Last resort: rename the corrupt file aside, return empty.
|
|
221
|
+
const bak = `${stateFile}.bak.${Date.now()}`;
|
|
222
|
+
try {
|
|
223
|
+
await node_fs_1.promises.rename(stateFile, bak);
|
|
224
|
+
}
|
|
225
|
+
catch { /* noop */ }
|
|
226
|
+
try {
|
|
227
|
+
process.stderr.write(`v4.1-cron: cron_jobs.json corrupt — moved to ${node_path_1.default.basename(bak)}, starting empty\n`);
|
|
228
|
+
}
|
|
229
|
+
catch { /* noop */ }
|
|
230
|
+
return {
|
|
231
|
+
schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
|
|
232
|
+
updatedAt: new Date().toISOString(),
|
|
233
|
+
jobs: [],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** Write state to disk via atomicWrite. Updates `updatedAt` to now. */
|
|
237
|
+
async function writeCronState(stateFile, state) {
|
|
238
|
+
const next = {
|
|
239
|
+
...state,
|
|
240
|
+
schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
|
|
241
|
+
updatedAt: new Date().toISOString(),
|
|
242
|
+
};
|
|
243
|
+
await (0, atomicWrite_1.writeJsonAtomic)(stateFile, next);
|
|
244
|
+
}
|
|
245
|
+
/** Acquire the whole-cron-state lock. Returns null when contended
|
|
246
|
+
* (failFast) or after retry exhausted (non-failFast). NEVER
|
|
247
|
+
* throws — caller checks for null. */
|
|
248
|
+
async function acquireCronLock(paths, opts = {}) {
|
|
249
|
+
// proper-lockfile requires the target to exist. Touch it.
|
|
250
|
+
try {
|
|
251
|
+
await node_fs_1.promises.mkdir(node_path_1.default.dirname(paths.stateFile), { recursive: true });
|
|
252
|
+
if (!(0, node_fs_2.existsSync)(paths.stateFile)) {
|
|
253
|
+
await node_fs_1.promises.writeFile(paths.stateFile, JSON.stringify({
|
|
254
|
+
schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
|
|
255
|
+
updatedAt: new Date().toISOString(),
|
|
256
|
+
jobs: [],
|
|
257
|
+
}, null, 2), { flag: 'wx', encoding: 'utf-8' }).catch(() => undefined);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch { /* non-fatal */ }
|
|
261
|
+
const lockOpts = {
|
|
262
|
+
realpath: true,
|
|
263
|
+
stale: 20000, // proper-lockfile default 10s; bump to 20s
|
|
264
|
+
retries: opts.failFast ? 0 : 1,
|
|
265
|
+
lockfilePath: paths.lockFile,
|
|
266
|
+
};
|
|
267
|
+
let release = null;
|
|
268
|
+
try {
|
|
269
|
+
release = await lockfile.lock(paths.stateFile, lockOpts);
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
273
|
+
// proper-lockfile throws ELOCKED — that's a "skip" signal, not
|
|
274
|
+
// an error. Other errors (permission, etc.) are silently NULLed
|
|
275
|
+
// — caller checks for null and proceeds in degraded mode.
|
|
276
|
+
if (!/lock(ed)?/i.test(msg)) {
|
|
277
|
+
// Surface unusual failures via stderr but never crash.
|
|
278
|
+
try {
|
|
279
|
+
process.stderr.write(`v4.1-cron: lock acquire failed: ${msg}\n`);
|
|
280
|
+
}
|
|
281
|
+
catch { /* noop */ }
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
let released = false;
|
|
286
|
+
return {
|
|
287
|
+
async release() {
|
|
288
|
+
if (released)
|
|
289
|
+
return;
|
|
290
|
+
released = true;
|
|
291
|
+
try {
|
|
292
|
+
await release();
|
|
293
|
+
}
|
|
294
|
+
catch { /* best effort; OS releases on process exit anyway */ }
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/** Best-effort check: is the lock currently held? Used by /cron
|
|
299
|
+
* status diagnostics. Never throws. */
|
|
300
|
+
async function isCronLockHeld(paths) {
|
|
301
|
+
try {
|
|
302
|
+
return await lockfile.check(paths.stateFile, {
|
|
303
|
+
realpath: true,
|
|
304
|
+
lockfilePath: paths.lockFile,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// ── Test hook ────────────────────────────────────────────────────────────
|
|
312
|
+
function __resetMigrationLogForTests() {
|
|
313
|
+
_migrationLogged = false;
|
|
314
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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/cron/cronTick.ts — Phase v4.1-cron
|
|
10
|
+
*
|
|
11
|
+
* 60-second heartbeat. The hybrid tick architecture:
|
|
12
|
+
*
|
|
13
|
+
* - Per-job `setTimeout` arms each job for its specific next-
|
|
14
|
+
* fire time (existing legacy behaviour preserved). Sub-second
|
|
15
|
+
* precision, no extra latency.
|
|
16
|
+
*
|
|
17
|
+
* - This heartbeat re-reads `cron_jobs.json` every 60s under
|
|
18
|
+
* lock. If another process (or the user editing the file)
|
|
19
|
+
* added / removed / paused jobs, this picks up the change
|
|
20
|
+
* and re-arms timers accordingly.
|
|
21
|
+
*
|
|
22
|
+
* - When the lock is held by another process, the tick skips
|
|
23
|
+
* silently with a logged "skipped: lock held" line and a
|
|
24
|
+
* diagnostics increment.
|
|
25
|
+
*
|
|
26
|
+
* - Fast-forward / catch-up after sleep: when the heartbeat
|
|
27
|
+
* wakes after a long pause (laptop slept past nextRun for
|
|
28
|
+
* multiple jobs), graceWindow.evaluateRecurring decides
|
|
29
|
+
* whether to fire-now or skip-and-fast-forward per job.
|
|
30
|
+
*
|
|
31
|
+
* The heartbeat is a singleton — calling `startHeartbeat()`
|
|
32
|
+
* twice is a no-op. `stopHeartbeat()` clears the timer; the
|
|
33
|
+
* caller is responsible for calling it on graceful shutdown
|
|
34
|
+
* (CLI signal handler, REPL exit, etc.).
|
|
35
|
+
*/
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.startHeartbeat = startHeartbeat;
|
|
38
|
+
exports.stopHeartbeat = stopHeartbeat;
|
|
39
|
+
exports.isHeartbeatActive = isHeartbeatActive;
|
|
40
|
+
const cronState_1 = require("./cronState");
|
|
41
|
+
const diagnostics_1 = require("./diagnostics");
|
|
42
|
+
// Module-level singleton — one heartbeat per process.
|
|
43
|
+
let _heartbeatTimer = null;
|
|
44
|
+
let _heartbeatActive = false;
|
|
45
|
+
/** Start the 60s heartbeat. Idempotent — second call no-ops. */
|
|
46
|
+
function startHeartbeat(opts) {
|
|
47
|
+
if (_heartbeatTimer)
|
|
48
|
+
return;
|
|
49
|
+
const intervalMs = opts.intervalMs ?? (0, diagnostics_1.resolveTickMs)();
|
|
50
|
+
const tick = async () => {
|
|
51
|
+
const lock = await (0, cronState_1.acquireCronLock)(opts.paths, { failFast: true });
|
|
52
|
+
if (!lock) {
|
|
53
|
+
(0, diagnostics_1.noteSkippedTick)();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
(0, diagnostics_1.noteHeartbeat)(true);
|
|
58
|
+
const state = await (0, cronState_1.readCronState)(opts.paths.stateFile);
|
|
59
|
+
await opts.onTick(state.jobs);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Heartbeat must never throw out — caller's onTick is
|
|
63
|
+
// best-effort. Errors land in the in-process logger via
|
|
64
|
+
// the caller's own logging.
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await lock.release();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
_heartbeatActive = true;
|
|
71
|
+
// Fire once immediately (the tick is the catch-up boundary).
|
|
72
|
+
void tick();
|
|
73
|
+
_heartbeatTimer = setInterval(() => { void tick(); }, intervalMs);
|
|
74
|
+
// Don't keep the event loop alive just for the heartbeat.
|
|
75
|
+
if (typeof _heartbeatTimer.unref === 'function') {
|
|
76
|
+
_heartbeatTimer.unref();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Stop the heartbeat. Idempotent. */
|
|
80
|
+
function stopHeartbeat() {
|
|
81
|
+
if (_heartbeatTimer) {
|
|
82
|
+
clearInterval(_heartbeatTimer);
|
|
83
|
+
_heartbeatTimer = null;
|
|
84
|
+
}
|
|
85
|
+
_heartbeatActive = false;
|
|
86
|
+
(0, diagnostics_1.noteHeartbeat)(false);
|
|
87
|
+
}
|
|
88
|
+
function isHeartbeatActive() {
|
|
89
|
+
return _heartbeatActive;
|
|
90
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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/cron/diagnostics.ts — Phase v4.1-cron
|
|
10
|
+
*
|
|
11
|
+
* Build fingerprint + diagnostics envelope for `/cron status`,
|
|
12
|
+
* `aiden cron status`, and the heartbeat tracker. Bump on every
|
|
13
|
+
* shipped phase. Format: `v4.1-cron[+suffix]`.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.RECENT_FIRES_KEEP = exports.DEFAULT_TIMEOUT_MS = exports.DEFAULT_TICK_MS = exports.CRON_SCHEMA_VERSION = exports.AIDEN_CRON_BUILD = void 0;
|
|
17
|
+
exports.noteHeartbeat = noteHeartbeat;
|
|
18
|
+
exports.noteSkippedTick = noteSkippedTick;
|
|
19
|
+
exports.noteFireStarted = noteFireStarted;
|
|
20
|
+
exports.recordFire = recordFire;
|
|
21
|
+
exports.getDiagnosticsSnapshot = getDiagnosticsSnapshot;
|
|
22
|
+
exports.resolveTickMs = resolveTickMs;
|
|
23
|
+
exports.resolveTimeoutMs = resolveTimeoutMs;
|
|
24
|
+
exports.__resetDiagnosticsForTests = __resetDiagnosticsForTests;
|
|
25
|
+
/** Build fingerprint — bump per phase. */
|
|
26
|
+
exports.AIDEN_CRON_BUILD = 'v4.1-cron';
|
|
27
|
+
/** Schema version — bumped when on-disk format changes. v1 = bare
|
|
28
|
+
* array, v2 = enveloped `{ jobs: [...], updatedAt, schemaVersion }`. */
|
|
29
|
+
exports.CRON_SCHEMA_VERSION = 2;
|
|
30
|
+
/** Default 60s heartbeat — env override `AIDEN_CRON_TICK_MS`. */
|
|
31
|
+
exports.DEFAULT_TICK_MS = 60000;
|
|
32
|
+
/** Default per-fire timeout — env override `AIDEN_CRON_TIMEOUT_MS`.
|
|
33
|
+
* Long-running shell_exec (web research, deep file ops) legitimately
|
|
34
|
+
* takes minutes; 600s gives ample headroom. */
|
|
35
|
+
exports.DEFAULT_TIMEOUT_MS = 600000;
|
|
36
|
+
/** Recent-fires retention — diagnostics surface the last N. */
|
|
37
|
+
exports.RECENT_FIRES_KEEP = 5;
|
|
38
|
+
/** In-process diagnostics ring buffer. Module singleton — survives
|
|
39
|
+
* across calls but resets on process boot. */
|
|
40
|
+
const _state = {
|
|
41
|
+
heartbeatActive: false,
|
|
42
|
+
lastHeartbeatAt: null,
|
|
43
|
+
skippedTicks: 0,
|
|
44
|
+
firesStarted: 0,
|
|
45
|
+
recentFires: [],
|
|
46
|
+
};
|
|
47
|
+
function noteHeartbeat(active, at = new Date()) {
|
|
48
|
+
_state.heartbeatActive = active;
|
|
49
|
+
_state.lastHeartbeatAt = at.toISOString();
|
|
50
|
+
}
|
|
51
|
+
function noteSkippedTick() {
|
|
52
|
+
_state.skippedTicks += 1;
|
|
53
|
+
}
|
|
54
|
+
function noteFireStarted() {
|
|
55
|
+
_state.firesStarted += 1;
|
|
56
|
+
}
|
|
57
|
+
function recordFire(rec) {
|
|
58
|
+
_state.recentFires.unshift(rec);
|
|
59
|
+
if (_state.recentFires.length > exports.RECENT_FIRES_KEEP) {
|
|
60
|
+
_state.recentFires.length = exports.RECENT_FIRES_KEEP;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function getDiagnosticsSnapshot(opts) {
|
|
64
|
+
return {
|
|
65
|
+
build: exports.AIDEN_CRON_BUILD,
|
|
66
|
+
schemaVersion: opts.schemaVersion,
|
|
67
|
+
tickMs: resolveTickMs(),
|
|
68
|
+
timeoutMs: resolveTimeoutMs(),
|
|
69
|
+
heartbeatActive: _state.heartbeatActive,
|
|
70
|
+
lastHeartbeatAt: _state.lastHeartbeatAt,
|
|
71
|
+
skippedTicks: _state.skippedTicks,
|
|
72
|
+
firesStarted: _state.firesStarted,
|
|
73
|
+
recentFires: [..._state.recentFires],
|
|
74
|
+
lock: { path: opts.lockPath, held: opts.lockHeld },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Resolve tick interval — env override > default. */
|
|
78
|
+
function resolveTickMs(env = process.env) {
|
|
79
|
+
const raw = env.AIDEN_CRON_TICK_MS;
|
|
80
|
+
if (raw && /^\d+$/.test(raw)) {
|
|
81
|
+
const n = Number.parseInt(raw, 10);
|
|
82
|
+
if (n >= 1000 && n <= 3600000)
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
return exports.DEFAULT_TICK_MS;
|
|
86
|
+
}
|
|
87
|
+
/** Resolve per-fire timeout — env override > default. */
|
|
88
|
+
function resolveTimeoutMs(env = process.env) {
|
|
89
|
+
const raw = env.AIDEN_CRON_TIMEOUT_MS;
|
|
90
|
+
if (raw && /^\d+$/.test(raw)) {
|
|
91
|
+
const n = Number.parseInt(raw, 10);
|
|
92
|
+
if (n >= 1000 && n <= 24 * 3600000)
|
|
93
|
+
return n;
|
|
94
|
+
}
|
|
95
|
+
return exports.DEFAULT_TIMEOUT_MS;
|
|
96
|
+
}
|
|
97
|
+
/** Test-only: reset diagnostics state. */
|
|
98
|
+
function __resetDiagnosticsForTests() {
|
|
99
|
+
_state.heartbeatActive = false;
|
|
100
|
+
_state.lastHeartbeatAt = null;
|
|
101
|
+
_state.skippedTicks = 0;
|
|
102
|
+
_state.firesStarted = 0;
|
|
103
|
+
_state.recentFires = [];
|
|
104
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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/cron/graceWindow.ts — Phase v4.1-cron
|
|
10
|
+
*
|
|
11
|
+
* Adaptive grace window for fast-forward / catch-up after sleep.
|
|
12
|
+
*
|
|
13
|
+
* Hard-won lesson (port from prior multi-agent systems): a fixed
|
|
14
|
+
* global grace cap is wrong for cron. Daily jobs that ran at 9am
|
|
15
|
+
* yesterday and the laptop slept past 9am today should still fire
|
|
16
|
+
* within a reasonable window (up to ~2h late). But sub-hourly jobs
|
|
17
|
+
* (every 5 minutes) should NOT fire 30 missed instances after a
|
|
18
|
+
* long sleep — that's a thundering-herd disaster.
|
|
19
|
+
*
|
|
20
|
+
* Solution: scale the grace window to the schedule period. Half
|
|
21
|
+
* the period, capped at 2h, floored at 2 minutes. Then SKIP, don't
|
|
22
|
+
* REPLAY: when a job is overdue beyond its grace, fast-forward
|
|
23
|
+
* `nextRunAt` to the next future occurrence and skip this firing
|
|
24
|
+
* entirely. "One missed run lost; no thundering-herd risk."
|
|
25
|
+
*
|
|
26
|
+
* grace = max(120s, min(period/2, 7200s))
|
|
27
|
+
*
|
|
28
|
+
* This module is a pure function — no I/O, no state. Caller
|
|
29
|
+
* threads the snapshot and decides what to do based on the
|
|
30
|
+
* verdict.
|
|
31
|
+
*/
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.ONESHOT_GRACE_MS = exports.GRACE_CEIL_MS = exports.GRACE_FLOOR_MS = void 0;
|
|
34
|
+
exports.computeGraceMs = computeGraceMs;
|
|
35
|
+
exports.evaluateRecurring = evaluateRecurring;
|
|
36
|
+
exports.evaluateOneShot = evaluateOneShot;
|
|
37
|
+
/** Constants — exposed for tests. */
|
|
38
|
+
exports.GRACE_FLOOR_MS = 120 * 1000; // 2 minutes
|
|
39
|
+
exports.GRACE_CEIL_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
40
|
+
/** One-shot jobs get a fixed 2-minute grace window — they cannot
|
|
41
|
+
* fast-forward (no recurring schedule), so a delivery that hits
|
|
42
|
+
* the second after a one-shot's `runAt` should still fire. */
|
|
43
|
+
exports.ONESHOT_GRACE_MS = 120 * 1000;
|
|
44
|
+
/** Compute the grace window for a recurring schedule. `periodMs` is
|
|
45
|
+
* the interval between fires (e.g. interval=300_000 for every 5
|
|
46
|
+
* minutes, or the croner-computed gap between successive cron
|
|
47
|
+
* fires). */
|
|
48
|
+
function computeGraceMs(periodMs) {
|
|
49
|
+
if (!Number.isFinite(periodMs) || periodMs <= 0)
|
|
50
|
+
return exports.GRACE_FLOOR_MS;
|
|
51
|
+
const half = Math.floor(periodMs / 2);
|
|
52
|
+
const clamped = Math.max(exports.GRACE_FLOOR_MS, Math.min(half, exports.GRACE_CEIL_MS));
|
|
53
|
+
return clamped;
|
|
54
|
+
}
|
|
55
|
+
/** Determine whether a recurring job should fire, skip-and-advance,
|
|
56
|
+
* or wait. Pure — no clock injection issue: caller passes `now`. */
|
|
57
|
+
function evaluateRecurring(args) {
|
|
58
|
+
const { nextRunAtMs, periodMs, nowMs } = args;
|
|
59
|
+
if (nowMs < nextRunAtMs)
|
|
60
|
+
return { kind: 'wait' };
|
|
61
|
+
const overdueMs = nowMs - nextRunAtMs;
|
|
62
|
+
const grace = computeGraceMs(periodMs);
|
|
63
|
+
if (overdueMs <= grace)
|
|
64
|
+
return { kind: 'fire' };
|
|
65
|
+
return { kind: 'skip-fast-forward' };
|
|
66
|
+
}
|
|
67
|
+
/** One-shot variant — different grace window, no fast-forward. */
|
|
68
|
+
function evaluateOneShot(args) {
|
|
69
|
+
const { runAtMs, nowMs } = args;
|
|
70
|
+
if (nowMs < runAtMs)
|
|
71
|
+
return { kind: 'wait' };
|
|
72
|
+
// One-shot: fire if within ONESHOT_GRACE_MS, else "skip" — the
|
|
73
|
+
// caller flips `enabled=false` on this job rather than advancing
|
|
74
|
+
// because there's no next occurrence.
|
|
75
|
+
const overdueMs = nowMs - runAtMs;
|
|
76
|
+
if (overdueMs <= exports.ONESHOT_GRACE_MS)
|
|
77
|
+
return { kind: 'fire' };
|
|
78
|
+
return { kind: 'skip-fast-forward' };
|
|
79
|
+
}
|