@ouro.bot/cli 0.1.0-alpha.147 → 0.1.0-alpha.149
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.json +27 -0
- package/dist/heart/daemon/cadence.js +70 -0
- package/dist/heart/daemon/daemon-cli.js +141 -17
- package/dist/heart/daemon/daemon-entry.js +67 -17
- package/dist/heart/daemon/daemon-tombstone.js +106 -0
- package/dist/heart/daemon/daemon.js +71 -2
- package/dist/heart/daemon/habit-migration.js +181 -0
- package/dist/heart/daemon/habit-parser.js +119 -0
- package/dist/heart/daemon/habit-scheduler.js +244 -0
- package/dist/heart/daemon/hatch-flow.js +13 -31
- package/dist/heart/daemon/os-cron-deps.js +134 -0
- package/dist/heart/daemon/process-manager.js +51 -0
- package/dist/heart/daemon/specialist-tools.js +1 -1
- package/dist/heart/daemon/task-scheduler.js +3 -25
- package/dist/mind/prompt.js +9 -5
- package/dist/nerves/index.js +47 -2
- package/dist/repertoire/tasks/parser.js +0 -2
- package/dist/repertoire/tasks/transitions.js +0 -18
- package/dist/senses/contextual-heartbeat.js +5 -1
- package/dist/senses/inner-dialog-worker.js +84 -20
- package/dist/senses/inner-dialog.js +145 -39
- package/package.json +1 -1
- package/dist/heart/daemon/heartbeat-timer.js +0 -132
package/changelog.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.149",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Daemon tombstone: writeDaemonTombstone persists crash context (reason, stack, uptime) to ~/.ouro-cli/daemon-death.json on uncaught exceptions and startup failures.",
|
|
8
|
+
"Global exception handlers: daemon-entry installs uncaughtException (tombstone + graceful 5s shutdown) and unhandledRejection (warn, no exit) handlers.",
|
|
9
|
+
"Log rotation: ndjson file sink rotates logs at 50MB (configurable), keeping .1 and .2 backups. Throttled stat checks via byte counter.",
|
|
10
|
+
"Socket connection error handling: connection.on('error') prevents server crash on client disconnect, connection.end() wrapped for EPIPE safety, persistent server.on('error') after listen.",
|
|
11
|
+
"Crash context in status: DaemonAgentSnapshot includes lastExitCode/lastSignal, Workers table shows last exit info, stopped-daemon status reads tombstone for 'Last death' line.",
|
|
12
|
+
"Cooldown recovery: after restart exhaustion, agents schedule recovery after cooldownRecoveryMs (default 5min). Reset crash history on recovery. maxCooldownRetries (default 3) before permanent give-up.",
|
|
13
|
+
"Defensive handleCommand: extracted handleCommandInner, outer handleCommand wraps with try/catch logging daemon.command_error with kind/error/stack before re-throw."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.1.0-alpha.148",
|
|
18
|
+
"changes": [
|
|
19
|
+
"Habits extracted from task system as first-class bundle concept at habits/ (peer to diary/, friends/, tasks/).",
|
|
20
|
+
"Name-based identity: heartbeat.md, not YYYY-MM-DD-HHMM-heartbeat.md. Simple schema: title, cadence, status (active/paused), body, lastRun, created.",
|
|
21
|
+
"HabitScheduler with OS cron (LaunchdCronManager/CrontabCronManager): each habit fires independently via its own cron entry.",
|
|
22
|
+
"Event-driven discovery: fs.watch + CLI notify trigger reconcile. No polling.",
|
|
23
|
+
"Worker rerun queue replaces single-slot mechanism, prevents dropped pokes. Each habit turn includes also-due line for agent triage.",
|
|
24
|
+
"Contextual heartbeat includes habit body: agent sees own instructions when heartbeat fires.",
|
|
25
|
+
"Daemon auto-migration from tasks/habits/ on startup. Strips timestamp prefix, maps statuses, removes task-only fields.",
|
|
26
|
+
"ouro habit list and ouro habit create CLI commands. Parse error reporting via nerves events + inner dialog nudge.",
|
|
27
|
+
"Metacognitive framing teaches agent about habits/ directory, rhythms, schema, read_file/write_file, and CLI commands.",
|
|
28
|
+
"HeartbeatTimer deleted, replaced by HabitScheduler. habit type removed from task system entirely."
|
|
29
|
+
]
|
|
30
|
+
},
|
|
4
31
|
{
|
|
5
32
|
"version": "0.1.0-alpha.147",
|
|
6
33
|
"changes": [
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_CADENCE_MS = void 0;
|
|
4
|
+
exports.parseCadenceToCron = parseCadenceToCron;
|
|
5
|
+
exports.parseCadenceToMs = parseCadenceToMs;
|
|
6
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
7
|
+
exports.DEFAULT_CADENCE_MS = 30 * 60 * 1000; // 30 minutes
|
|
8
|
+
/**
|
|
9
|
+
* Parse a cadence shorthand (e.g. "30m", "2h", "1d") into a cron string.
|
|
10
|
+
* Also accepts raw cron strings (5 space-separated fields) and passes them through.
|
|
11
|
+
* Returns null for invalid input.
|
|
12
|
+
*/
|
|
13
|
+
function parseCadenceToCron(raw) {
|
|
14
|
+
if (typeof raw !== "string")
|
|
15
|
+
return null;
|
|
16
|
+
const value = raw.trim();
|
|
17
|
+
if (!value)
|
|
18
|
+
return null;
|
|
19
|
+
// Cron format (minute hour day month weekday)
|
|
20
|
+
if (/^\S+\s+\S+\s+\S+\s+\S+\s+\S+$/.test(value)) {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
const cadenceMatch = /^(\d+)(m|h|d)$/.exec(value);
|
|
24
|
+
if (!cadenceMatch)
|
|
25
|
+
return null;
|
|
26
|
+
const interval = Number.parseInt(cadenceMatch[1], 10);
|
|
27
|
+
if (!Number.isFinite(interval) || interval <= 0)
|
|
28
|
+
return null;
|
|
29
|
+
(0, runtime_1.emitNervesEvent)({
|
|
30
|
+
event: "daemon.cadence_parsed",
|
|
31
|
+
component: "daemon",
|
|
32
|
+
message: "parsed cadence shorthand to cron",
|
|
33
|
+
meta: { raw: value, interval, unit: cadenceMatch[2] },
|
|
34
|
+
});
|
|
35
|
+
const unit = cadenceMatch[2];
|
|
36
|
+
if (unit === "m")
|
|
37
|
+
return `*/${interval} * * * *`;
|
|
38
|
+
if (unit === "h")
|
|
39
|
+
return `0 */${interval} * * *`;
|
|
40
|
+
return `0 0 */${interval} * *`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Parse a cadence shorthand (e.g. "30m", "2h", "1d") into milliseconds.
|
|
44
|
+
* Returns null for invalid input.
|
|
45
|
+
*/
|
|
46
|
+
function parseCadenceToMs(raw) {
|
|
47
|
+
if (typeof raw !== "string")
|
|
48
|
+
return null;
|
|
49
|
+
const value = raw.trim();
|
|
50
|
+
if (!value)
|
|
51
|
+
return null;
|
|
52
|
+
const match = /^(\d+)(m|h|d)$/.exec(value);
|
|
53
|
+
if (!match)
|
|
54
|
+
return null;
|
|
55
|
+
const interval = Number.parseInt(match[1], 10);
|
|
56
|
+
if (!Number.isFinite(interval) || interval <= 0)
|
|
57
|
+
return null;
|
|
58
|
+
(0, runtime_1.emitNervesEvent)({
|
|
59
|
+
event: "daemon.cadence_parsed_ms",
|
|
60
|
+
component: "daemon",
|
|
61
|
+
message: "parsed cadence shorthand to milliseconds",
|
|
62
|
+
meta: { raw: value, interval, unit: match[2] },
|
|
63
|
+
});
|
|
64
|
+
const unit = match[2];
|
|
65
|
+
if (unit === "m")
|
|
66
|
+
return interval * 60 * 1000;
|
|
67
|
+
if (unit === "h")
|
|
68
|
+
return interval * 60 * 60 * 1000;
|
|
69
|
+
return interval * 24 * 60 * 60 * 1000;
|
|
70
|
+
}
|
|
@@ -71,6 +71,7 @@ const thoughts_1 = require("./thoughts");
|
|
|
71
71
|
const ouro_bot_global_installer_1 = require("./ouro-bot-global-installer");
|
|
72
72
|
const launchd_1 = require("./launchd");
|
|
73
73
|
const socket_client_1 = require("./socket-client");
|
|
74
|
+
const daemon_tombstone_1 = require("./daemon-tombstone");
|
|
74
75
|
const session_activity_1 = require("../session-activity");
|
|
75
76
|
const auth_flow_1 = require("./auth-flow");
|
|
76
77
|
function stringField(value) {
|
|
@@ -145,6 +146,8 @@ function parseStatusPayload(data) {
|
|
|
145
146
|
status,
|
|
146
147
|
pid,
|
|
147
148
|
restartCount,
|
|
149
|
+
lastExitCode: numberField(row.lastExitCode) ?? null,
|
|
150
|
+
lastSignal: stringField(row.lastSignal) ?? null,
|
|
148
151
|
};
|
|
149
152
|
});
|
|
150
153
|
if (parsedSenses.some((row) => row === null) || parsedWorkers.some((row) => row === null))
|
|
@@ -198,13 +201,23 @@ function formatDaemonStatusOutput(response, fallback) {
|
|
|
198
201
|
row.status,
|
|
199
202
|
row.detail,
|
|
200
203
|
]);
|
|
201
|
-
const workerRows = payload.workers.map((row) =>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
row.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
const workerRows = payload.workers.map((row) => {
|
|
205
|
+
/* v8 ignore start — exit info branches tested via daemon-crash-context; v8 misreports conditional chains @preserve */
|
|
206
|
+
let exitInfo = "n/a";
|
|
207
|
+
if (row.lastExitCode !== null)
|
|
208
|
+
exitInfo = `code=${row.lastExitCode}`;
|
|
209
|
+
if (row.lastSignal !== null)
|
|
210
|
+
exitInfo = row.lastExitCode !== null ? `code=${row.lastExitCode} sig=${row.lastSignal}` : `sig=${row.lastSignal}`;
|
|
211
|
+
/* v8 ignore stop */
|
|
212
|
+
return [
|
|
213
|
+
row.agent,
|
|
214
|
+
row.worker,
|
|
215
|
+
row.status,
|
|
216
|
+
row.pid === null ? "n/a" : String(row.pid),
|
|
217
|
+
String(row.restartCount),
|
|
218
|
+
exitInfo,
|
|
219
|
+
];
|
|
220
|
+
});
|
|
208
221
|
return [
|
|
209
222
|
"Overview",
|
|
210
223
|
formatTable(["Item", "Value"], overviewRows),
|
|
@@ -213,7 +226,7 @@ function formatDaemonStatusOutput(response, fallback) {
|
|
|
213
226
|
formatTable(["Agent", "Sense", "Enabled", "State", "Detail"], senseRows),
|
|
214
227
|
"",
|
|
215
228
|
"Workers",
|
|
216
|
-
formatTable(["Agent", "Worker", "State", "PID", "Restarts"], workerRows),
|
|
229
|
+
formatTable(["Agent", "Worker", "State", "PID", "Restarts", "Last Exit"], workerRows),
|
|
217
230
|
].join("\n");
|
|
218
231
|
}
|
|
219
232
|
async function ensureDaemonRunning(deps) {
|
|
@@ -283,6 +296,9 @@ function usage() {
|
|
|
283
296
|
" ouro chat <agent>",
|
|
284
297
|
" ouro msg --to <agent> [--session <id>] [--task <ref>] <message>",
|
|
285
298
|
" ouro poke <agent> --task <task-id>",
|
|
299
|
+
" ouro poke <agent> --habit <name>",
|
|
300
|
+
" ouro habit list [--agent <name>]",
|
|
301
|
+
" ouro habit create [--agent <name>] <name> [--cadence <interval>]",
|
|
286
302
|
" ouro link <agent> --friend <id> --provider <provider> --external-id <external-id>",
|
|
287
303
|
" ouro task board [<status>] [--agent <name>]",
|
|
288
304
|
" ouro task create <title> [--type <type>] [--agent <name>]",
|
|
@@ -335,15 +351,28 @@ function buildStoppedStatusPayload(socketPath) {
|
|
|
335
351
|
};
|
|
336
352
|
}
|
|
337
353
|
function daemonUnavailableStatusOutput(socketPath) {
|
|
338
|
-
|
|
354
|
+
/* v8 ignore start — tombstone read tested in daemon-status-tombstone.test; branch misreported @preserve */
|
|
355
|
+
const tombstone = (0, daemon_tombstone_1.readDaemonTombstone)();
|
|
356
|
+
const deathLine = tombstone
|
|
357
|
+
? `Last death: ${tombstone.timestamp} -- ${tombstone.reason}: ${tombstone.message}`
|
|
358
|
+
: null;
|
|
359
|
+
/* v8 ignore stop */
|
|
360
|
+
const lines = [
|
|
339
361
|
formatDaemonStatusOutput({
|
|
340
362
|
ok: true,
|
|
341
363
|
summary: "daemon not running",
|
|
342
364
|
data: buildStoppedStatusPayload(socketPath),
|
|
343
365
|
}, "daemon not running"),
|
|
344
366
|
"",
|
|
345
|
-
|
|
346
|
-
|
|
367
|
+
];
|
|
368
|
+
/* v8 ignore start — tombstone presence requires real daemon crash @preserve */
|
|
369
|
+
if (deathLine) {
|
|
370
|
+
lines.push(deathLine);
|
|
371
|
+
lines.push("");
|
|
372
|
+
/* v8 ignore stop */
|
|
373
|
+
}
|
|
374
|
+
lines.push("daemon not running; run `ouro up`");
|
|
375
|
+
return lines.join("\n");
|
|
347
376
|
}
|
|
348
377
|
function isDaemonUnavailableError(error) {
|
|
349
378
|
const code = typeof error === "object" && error !== null && "code" in error
|
|
@@ -392,16 +421,55 @@ function parsePokeCommand(args) {
|
|
|
392
421
|
if (!agent)
|
|
393
422
|
throw new Error(`Usage\n${usage()}`);
|
|
394
423
|
let taskId;
|
|
424
|
+
let habitName;
|
|
395
425
|
for (let i = 1; i < args.length; i += 1) {
|
|
396
426
|
if (args[i] === "--task") {
|
|
397
427
|
taskId = args[i + 1];
|
|
398
428
|
i += 1;
|
|
399
429
|
}
|
|
430
|
+
if (args[i] === "--habit") {
|
|
431
|
+
habitName = args[i + 1];
|
|
432
|
+
i += 1;
|
|
433
|
+
}
|
|
400
434
|
}
|
|
435
|
+
// --habit takes priority over --task
|
|
436
|
+
if (habitName)
|
|
437
|
+
return { kind: "habit.poke", agent, habitName };
|
|
401
438
|
if (!taskId)
|
|
402
439
|
throw new Error(`Usage\n${usage()}`);
|
|
403
440
|
return { kind: "task.poke", agent, taskId };
|
|
404
441
|
}
|
|
442
|
+
function parseHabitCommand(args) {
|
|
443
|
+
const { agent, rest } = extractAgentFlag(args);
|
|
444
|
+
const sub = rest[0];
|
|
445
|
+
if (sub === "list") {
|
|
446
|
+
return { kind: "habit.list", ...(agent ? { agent } : {}) };
|
|
447
|
+
}
|
|
448
|
+
if (sub === "create") {
|
|
449
|
+
const nameArgs = rest.slice(1);
|
|
450
|
+
let name;
|
|
451
|
+
let cadence;
|
|
452
|
+
const positional = [];
|
|
453
|
+
for (let i = 0; i < nameArgs.length; i++) {
|
|
454
|
+
if (nameArgs[i] === "--cadence" && nameArgs[i + 1]) {
|
|
455
|
+
cadence = nameArgs[++i];
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
/* v8 ignore start -- defensive: --agent already extracted by extractAgentFlag; guard prevents regression if parsing flow changes @preserve */
|
|
459
|
+
if (nameArgs[i] === "--agent" && nameArgs[i + 1]) {
|
|
460
|
+
i++; // skip --agent value (already extracted)
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
/* v8 ignore stop */
|
|
464
|
+
positional.push(nameArgs[i]);
|
|
465
|
+
}
|
|
466
|
+
name = positional[0];
|
|
467
|
+
if (!name)
|
|
468
|
+
throw new Error(`Usage\n${usage()}`);
|
|
469
|
+
return { kind: "habit.create", name, ...(agent ? { agent } : {}), ...(cadence ? { cadence } : {}) };
|
|
470
|
+
}
|
|
471
|
+
throw new Error(`Usage\n${usage()}`);
|
|
472
|
+
}
|
|
405
473
|
function parseLinkCommand(args, kind = "friend.link") {
|
|
406
474
|
const agent = args[0];
|
|
407
475
|
if (!agent)
|
|
@@ -1008,6 +1076,8 @@ function parseOuroCommand(args) {
|
|
|
1008
1076
|
return parseTaskCommand(args.slice(1));
|
|
1009
1077
|
if (head === "reminder")
|
|
1010
1078
|
return parseReminderCommand(args.slice(1));
|
|
1079
|
+
if (head === "habit")
|
|
1080
|
+
return parseHabitCommand(args.slice(1));
|
|
1011
1081
|
if (head === "friend")
|
|
1012
1082
|
return parseFriendCommand(args.slice(1));
|
|
1013
1083
|
if (head === "config")
|
|
@@ -1825,7 +1895,7 @@ function executeReminderCommand(command, taskMod) {
|
|
|
1825
1895
|
try {
|
|
1826
1896
|
const created = taskMod.createTask({
|
|
1827
1897
|
title: command.title,
|
|
1828
|
-
type: command.cadence ? "
|
|
1898
|
+
type: command.cadence ? "ongoing" : "one-shot",
|
|
1829
1899
|
category: command.category ?? "reminder",
|
|
1830
1900
|
body: command.body,
|
|
1831
1901
|
scheduledAt: command.scheduledAt,
|
|
@@ -2509,6 +2579,61 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
|
|
|
2509
2579
|
deps.writeStdout(message);
|
|
2510
2580
|
return message;
|
|
2511
2581
|
}
|
|
2582
|
+
// ── habit subcommands (local, no daemon socket needed) ──
|
|
2583
|
+
if (command.kind === "habit.list" || command.kind === "habit.create") {
|
|
2584
|
+
const { parseHabitFile, renderHabitFile } = await Promise.resolve().then(() => __importStar(require("./habit-parser")));
|
|
2585
|
+
/* v8 ignore start -- production default: uses real bundle root @preserve */
|
|
2586
|
+
const agentName = command.agent ?? (0, identity_1.getAgentName)();
|
|
2587
|
+
const bundleRoot = deps.agentBundleRoot ?? path.join((0, identity_1.getAgentBundlesRoot)(), `${agentName}.ouro`);
|
|
2588
|
+
/* v8 ignore stop */
|
|
2589
|
+
const habitsDir = path.join(bundleRoot, "habits");
|
|
2590
|
+
if (command.kind === "habit.list") {
|
|
2591
|
+
let files;
|
|
2592
|
+
try {
|
|
2593
|
+
files = fs.readdirSync(habitsDir).filter((f) => f.endsWith(".md") && f !== "README.md");
|
|
2594
|
+
}
|
|
2595
|
+
catch {
|
|
2596
|
+
const message = "no habits found";
|
|
2597
|
+
deps.writeStdout(message);
|
|
2598
|
+
return message;
|
|
2599
|
+
}
|
|
2600
|
+
if (files.length === 0) {
|
|
2601
|
+
const message = "no habits found";
|
|
2602
|
+
deps.writeStdout(message);
|
|
2603
|
+
return message;
|
|
2604
|
+
}
|
|
2605
|
+
const lines = [];
|
|
2606
|
+
for (const file of files) {
|
|
2607
|
+
const content = fs.readFileSync(path.join(habitsDir, file), "utf-8");
|
|
2608
|
+
const habit = parseHabitFile(content, path.join(habitsDir, file));
|
|
2609
|
+
const lastRunStr = habit.lastRun ?? "never";
|
|
2610
|
+
lines.push(`${habit.name} cadence=${habit.cadence ?? "none"} status=${habit.status} lastRun=${lastRunStr}`);
|
|
2611
|
+
}
|
|
2612
|
+
const message = lines.join("\n");
|
|
2613
|
+
deps.writeStdout(message);
|
|
2614
|
+
return message;
|
|
2615
|
+
}
|
|
2616
|
+
// habit.create
|
|
2617
|
+
const filePath = path.join(habitsDir, `${command.name}.md`);
|
|
2618
|
+
if (fs.existsSync(filePath)) {
|
|
2619
|
+
const message = `error: habit '${command.name}' already exists`;
|
|
2620
|
+
deps.writeStdout(message);
|
|
2621
|
+
return message;
|
|
2622
|
+
}
|
|
2623
|
+
fs.mkdirSync(habitsDir, { recursive: true });
|
|
2624
|
+
const now = new Date().toISOString();
|
|
2625
|
+
const content = renderHabitFile({
|
|
2626
|
+
title: command.name,
|
|
2627
|
+
cadence: command.cadence ?? "null",
|
|
2628
|
+
status: "active",
|
|
2629
|
+
lastRun: now,
|
|
2630
|
+
created: now,
|
|
2631
|
+
}, `Habit: ${command.name}`);
|
|
2632
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
2633
|
+
const message = `created: ${filePath}`;
|
|
2634
|
+
deps.writeStdout(message);
|
|
2635
|
+
return message;
|
|
2636
|
+
}
|
|
2512
2637
|
// ── friend subcommands (local, no daemon socket needed) ──
|
|
2513
2638
|
if (command.kind === "friend.list" || command.kind === "friend.show" || command.kind === "friend.create" ||
|
|
2514
2639
|
command.kind === "friend.update" || command.kind === "friend.link" || command.kind === "friend.unlink") {
|
|
@@ -2860,7 +2985,7 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
|
|
|
2860
2985
|
const agentRoot = (0, identity_1.getAgentRoot)(agentName);
|
|
2861
2986
|
const { buildInnerStatusOutput } = await Promise.resolve().then(() => __importStar(require("./inner-status")));
|
|
2862
2987
|
const { sessionPath: getSessionPath } = await Promise.resolve().then(() => __importStar(require("../config")));
|
|
2863
|
-
const { parseCadenceMs, DEFAULT_CADENCE_MS } = await Promise.resolve().then(() => __importStar(require("./
|
|
2988
|
+
const { parseCadenceToMs: parseCadenceMs, DEFAULT_CADENCE_MS } = await Promise.resolve().then(() => __importStar(require("./cadence")));
|
|
2864
2989
|
const { parseFrontmatter } = await Promise.resolve().then(() => __importStar(require("../../repertoire/tasks/parser")));
|
|
2865
2990
|
const { listActiveObligations } = await Promise.resolve().then(() => __importStar(require("../../mind/obligations")));
|
|
2866
2991
|
// Read runtime state
|
|
@@ -2889,11 +3014,10 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
|
|
|
2889
3014
|
let heartbeat = null;
|
|
2890
3015
|
try {
|
|
2891
3016
|
const habitsDir = path.join(agentRoot, "habits");
|
|
2892
|
-
const
|
|
2893
|
-
const heartbeatFile = habitFiles.find((f) => f.endsWith("-heartbeat.md"));
|
|
3017
|
+
const heartbeatPath = path.join(habitsDir, "heartbeat.md");
|
|
2894
3018
|
let cadenceMs = DEFAULT_CADENCE_MS;
|
|
2895
|
-
if (
|
|
2896
|
-
const content = fs.readFileSync(
|
|
3019
|
+
if (fs.existsSync(heartbeatPath)) {
|
|
3020
|
+
const content = fs.readFileSync(heartbeatPath, "utf-8");
|
|
2897
3021
|
const lines = content.split(/\r?\n/);
|
|
2898
3022
|
if (lines[0]?.trim() === "---") {
|
|
2899
3023
|
const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
@@ -47,7 +47,11 @@ const sense_manager_1 = require("./sense-manager");
|
|
|
47
47
|
const agent_discovery_1 = require("./agent-discovery");
|
|
48
48
|
const identity_1 = require("../identity");
|
|
49
49
|
const runtime_mode_1 = require("./runtime-mode");
|
|
50
|
-
const
|
|
50
|
+
const habit_scheduler_1 = require("./habit-scheduler");
|
|
51
|
+
const habit_migration_1 = require("./habit-migration");
|
|
52
|
+
const os_cron_deps_1 = require("./os-cron-deps");
|
|
53
|
+
const os_cron_1 = require("./os-cron");
|
|
54
|
+
const daemon_tombstone_1 = require("./daemon-tombstone");
|
|
51
55
|
function parseSocketPath(argv) {
|
|
52
56
|
const socketIndex = argv.indexOf("--socket");
|
|
53
57
|
if (socketIndex >= 0) {
|
|
@@ -116,44 +120,90 @@ const daemon = new daemon_1.OuroDaemon({
|
|
|
116
120
|
router,
|
|
117
121
|
mode,
|
|
118
122
|
});
|
|
119
|
-
const
|
|
120
|
-
/* v8 ignore start --
|
|
123
|
+
const habitSchedulers = [];
|
|
124
|
+
/* v8 ignore start -- habit wiring: lambdas delegate to processManager/fs; tested via HabitScheduler unit tests @preserve */
|
|
121
125
|
void daemon.start().then(() => {
|
|
122
126
|
const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
|
|
127
|
+
const ouroPath = (0, os_cron_deps_1.resolveOuroBinaryPath)();
|
|
128
|
+
const osCronDeps = (0, os_cron_deps_1.createRealOsCronDeps)();
|
|
123
129
|
for (const agent of managedAgents) {
|
|
124
130
|
const bundleRoot = path.join(bundlesRoot, `${agent}.ouro`);
|
|
125
|
-
const
|
|
131
|
+
const habitsDir = path.join(bundleRoot, "habits");
|
|
132
|
+
// Migrate old tasks/habits/ to habits/ at bundle root
|
|
133
|
+
(0, habit_migration_1.migrateHabitsFromTaskSystem)(bundleRoot);
|
|
134
|
+
const osCronManager = new os_cron_1.LaunchdCronManager(osCronDeps);
|
|
135
|
+
const scheduler = new habit_scheduler_1.HabitScheduler({
|
|
126
136
|
agent,
|
|
127
|
-
|
|
137
|
+
habitsDir,
|
|
138
|
+
osCronManager,
|
|
139
|
+
onHabitFire: (habitName) => {
|
|
140
|
+
processManager.sendToAgent(agent, { type: "habit", habitName });
|
|
141
|
+
},
|
|
128
142
|
deps: {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
readdir: (dir) => fs.readdirSync(dir),
|
|
144
|
+
readFile: (p, enc) => fs.readFileSync(p, enc),
|
|
145
|
+
writeFile: (p, c, enc) => fs.writeFileSync(p, c, enc),
|
|
146
|
+
existsSync: (p) => fs.existsSync(p),
|
|
147
|
+
now: () => Date.now(),
|
|
148
|
+
ouroPath,
|
|
149
|
+
watch: (dir, cb) => fs.watch(dir, cb),
|
|
133
150
|
},
|
|
134
151
|
});
|
|
135
|
-
|
|
136
|
-
|
|
152
|
+
scheduler.start();
|
|
153
|
+
scheduler.watchForChanges();
|
|
154
|
+
habitSchedulers.push(scheduler);
|
|
137
155
|
}
|
|
138
|
-
}).catch(async () => {
|
|
156
|
+
}).catch(async (err) => {
|
|
157
|
+
/* v8 ignore stop */
|
|
158
|
+
/* v8 ignore start — instanceof branch defensive; catch always receives Error in practice @preserve */
|
|
159
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
139
160
|
/* v8 ignore stop */
|
|
161
|
+
(0, daemon_tombstone_1.writeDaemonTombstone)("startupFailure", error);
|
|
140
162
|
(0, runtime_1.emitNervesEvent)({
|
|
141
163
|
level: "error",
|
|
142
164
|
component: "daemon",
|
|
143
165
|
event: "daemon.entry_error",
|
|
144
166
|
message: "daemon entrypoint failed",
|
|
145
|
-
meta: {},
|
|
167
|
+
meta: { error: error.message },
|
|
146
168
|
});
|
|
147
169
|
await daemon.stop();
|
|
148
170
|
process.exit(1);
|
|
149
171
|
});
|
|
150
172
|
process.on("SIGINT", () => {
|
|
151
|
-
for (const
|
|
152
|
-
|
|
173
|
+
for (const s of habitSchedulers) {
|
|
174
|
+
s.stopWatching();
|
|
175
|
+
s.stop();
|
|
176
|
+
}
|
|
153
177
|
void daemon.stop().then(() => process.exit(0));
|
|
154
178
|
});
|
|
155
179
|
process.on("SIGTERM", () => {
|
|
156
|
-
for (const
|
|
157
|
-
|
|
180
|
+
for (const s of habitSchedulers) {
|
|
181
|
+
s.stopWatching();
|
|
182
|
+
s.stop();
|
|
183
|
+
}
|
|
158
184
|
void daemon.stop().then(() => process.exit(0));
|
|
159
185
|
});
|
|
186
|
+
/* v8 ignore start -- global exception handlers: genuinely untestable in vitest; exercised by real daemon crashes @preserve */
|
|
187
|
+
process.on("uncaughtException", (error) => {
|
|
188
|
+
(0, daemon_tombstone_1.writeDaemonTombstone)("uncaughtException", error);
|
|
189
|
+
(0, runtime_1.emitNervesEvent)({
|
|
190
|
+
level: "error",
|
|
191
|
+
component: "daemon",
|
|
192
|
+
event: "daemon.uncaught_exception",
|
|
193
|
+
message: "uncaught exception in daemon process",
|
|
194
|
+
meta: { error: error.message, stack: error.stack ?? null },
|
|
195
|
+
});
|
|
196
|
+
// Graceful 5-second shutdown window
|
|
197
|
+
setTimeout(() => process.exit(1), 5_000).unref();
|
|
198
|
+
void daemon.stop().then(() => process.exit(1));
|
|
199
|
+
});
|
|
200
|
+
process.on("unhandledRejection", (reason) => {
|
|
201
|
+
(0, runtime_1.emitNervesEvent)({
|
|
202
|
+
level: "warn",
|
|
203
|
+
component: "daemon",
|
|
204
|
+
event: "daemon.unhandled_rejection",
|
|
205
|
+
message: "unhandled promise rejection in daemon process",
|
|
206
|
+
meta: { reason: reason instanceof Error ? reason.message : String(reason) },
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
/* v8 ignore stop */
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.getTombstonePath = getTombstonePath;
|
|
37
|
+
exports.setTombstonePath = setTombstonePath;
|
|
38
|
+
exports.writeDaemonTombstone = writeDaemonTombstone;
|
|
39
|
+
exports.readDaemonTombstone = readDaemonTombstone;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
44
|
+
let _tombstonePath = null;
|
|
45
|
+
function getTombstonePath() {
|
|
46
|
+
if (!_tombstonePath) {
|
|
47
|
+
_tombstonePath = path.join(os.homedir(), ".ouro-cli", "daemon-death.json");
|
|
48
|
+
}
|
|
49
|
+
return _tombstonePath;
|
|
50
|
+
}
|
|
51
|
+
/** Overrides the tombstone path for testing. Pass null to reset. */
|
|
52
|
+
function setTombstonePath(p) {
|
|
53
|
+
_tombstonePath = p;
|
|
54
|
+
}
|
|
55
|
+
function writeDaemonTombstone(reason, error) {
|
|
56
|
+
const tombstone = {
|
|
57
|
+
reason,
|
|
58
|
+
message: error?.message ?? reason,
|
|
59
|
+
stack: error?.stack ?? null,
|
|
60
|
+
timestamp: new Date().toISOString(),
|
|
61
|
+
pid: process.pid,
|
|
62
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
63
|
+
};
|
|
64
|
+
const filePath = getTombstonePath();
|
|
65
|
+
try {
|
|
66
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
67
|
+
fs.writeFileSync(filePath, JSON.stringify(tombstone, null, 2) + "\n", "utf-8");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Best-effort: if we can't write, we're already dying.
|
|
71
|
+
}
|
|
72
|
+
(0, runtime_1.emitNervesEvent)({
|
|
73
|
+
level: "error",
|
|
74
|
+
component: "daemon",
|
|
75
|
+
event: "daemon.tombstone_written",
|
|
76
|
+
message: `daemon tombstone written: ${reason}`,
|
|
77
|
+
meta: { reason, errorMessage: error?.message ?? null, filePath },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function readDaemonTombstone() {
|
|
81
|
+
const filePath = getTombstonePath();
|
|
82
|
+
try {
|
|
83
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
if (typeof parsed.reason !== "string" || typeof parsed.timestamp !== "string") {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
(0, runtime_1.emitNervesEvent)({
|
|
89
|
+
component: "daemon",
|
|
90
|
+
event: "daemon.tombstone_read",
|
|
91
|
+
message: "read daemon tombstone",
|
|
92
|
+
meta: { filePath },
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
reason: parsed.reason,
|
|
96
|
+
message: typeof parsed.message === "string" ? parsed.message : String(parsed.reason),
|
|
97
|
+
stack: typeof parsed.stack === "string" ? parsed.stack : null,
|
|
98
|
+
timestamp: parsed.timestamp,
|
|
99
|
+
pid: typeof parsed.pid === "number" ? parsed.pid : 0,
|
|
100
|
+
uptimeSeconds: typeof parsed.uptimeSeconds === "number" ? parsed.uptimeSeconds : 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|