@ouro.bot/cli 0.1.0-alpha.319 → 0.1.0-alpha.320
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 +8 -0
- package/dist/heart/daemon/cli-defaults.js +31 -0
- package/dist/heart/daemon/cli-exec.js +190 -53
- package/dist/heart/daemon/daemon-entry.js +87 -23
- package/dist/heart/daemon/daemon-health.js +1 -0
- package/dist/heart/daemon/daemon.js +43 -1
- package/dist/heart/daemon/up-progress.js +9 -0
- package/dist/outlook-ui/assets/index-IuR4F6y6.js +61 -0
- package/dist/outlook-ui/assets/index-LwChZTgL.css +1 -0
- package/dist/outlook-ui/index.html +15 -0
- package/dist/senses/teams.js +2 -2
- package/package.json +5 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
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.320",
|
|
6
|
+
"changes": [
|
|
7
|
+
"fix(daemon): make `ouro up` wait for real startup stability before reporting success. The CLI now stays attached through ordered startup phases, requires sustained socket liveness plus fresh current-boot health evidence, retries once when startup loses the socket, and surfaces recent daemon log context on failure instead of claiming the daemon started.",
|
|
8
|
+
"fix(daemon): harden socket ownership and recoverable bootstrap behavior. An older daemon shutdown no longer unlinks a newer daemon's rebound socket path, and recoverable habit bootstrap failures now degrade with actionable `habit_setup_error` and `bootstrap_degraded` guidance instead of taking the whole daemon down.",
|
|
9
|
+
"fix(testing): declare @testing-library/dom in outlook-ui devDependencies so coverage-gate installs match the package's actual React Testing Library requirements."
|
|
10
|
+
]
|
|
11
|
+
},
|
|
4
12
|
{
|
|
5
13
|
"version": "0.1.0-alpha.319",
|
|
6
14
|
"changes": [
|
|
@@ -64,6 +64,8 @@ const agent_discovery_1 = require("./agent-discovery");
|
|
|
64
64
|
const bundle_manifest_1 = require("../../mind/bundle-manifest");
|
|
65
65
|
const ouro_bot_global_installer_1 = require("../versioning/ouro-bot-global-installer");
|
|
66
66
|
const logs_prune_1 = require("./logs-prune");
|
|
67
|
+
const daemon_health_1 = require("./daemon-health");
|
|
68
|
+
const log_tailer_1 = require("./log-tailer");
|
|
67
69
|
const launchd_1 = require("./launchd");
|
|
68
70
|
const socket_client_1 = require("./socket-client");
|
|
69
71
|
const session_activity_1 = require("../session-activity");
|
|
@@ -121,6 +123,25 @@ function defaultCleanupStaleSocket(socketPath) {
|
|
|
121
123
|
fs.unlinkSync(socketPath);
|
|
122
124
|
}
|
|
123
125
|
}
|
|
126
|
+
function defaultReadHealthUpdatedAt(healthPath) {
|
|
127
|
+
try {
|
|
128
|
+
return fs.statSync(healthPath).mtimeMs;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function defaultReadRecentDaemonLogLines(lines = 10) {
|
|
135
|
+
const files = (0, log_tailer_1.discoverLogFiles)({});
|
|
136
|
+
const recentLines = [];
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
recentLines.push(...(0, log_tailer_1.readLastLines)(file, lines, fs.readFileSync));
|
|
139
|
+
}
|
|
140
|
+
return recentLines.slice(-lines).map((line) => (0, log_tailer_1.formatLogLine)(line));
|
|
141
|
+
}
|
|
142
|
+
function defaultSleep(ms) {
|
|
143
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
144
|
+
}
|
|
124
145
|
function defaultFallbackPendingMessage(command) {
|
|
125
146
|
const inboxDir = path.join((0, identity_1.getAgentBundlesRoot)(), `${command.to}.ouro`, "inbox");
|
|
126
147
|
const pendingPath = path.join(inboxDir, "pending.jsonl");
|
|
@@ -466,6 +487,16 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
|
|
|
466
487
|
checkSocketAlive: socket_client_1.checkDaemonSocketAlive,
|
|
467
488
|
cleanupStaleSocket: defaultCleanupStaleSocket,
|
|
468
489
|
fallbackPendingMessage: defaultFallbackPendingMessage,
|
|
490
|
+
healthFilePath: (0, daemon_health_1.getDefaultHealthPath)(),
|
|
491
|
+
readHealthState: daemon_health_1.readHealth,
|
|
492
|
+
readHealthUpdatedAt: defaultReadHealthUpdatedAt,
|
|
493
|
+
readRecentDaemonLogLines: defaultReadRecentDaemonLogLines,
|
|
494
|
+
sleep: defaultSleep,
|
|
495
|
+
now: () => Date.now(),
|
|
496
|
+
startupPollIntervalMs: 250,
|
|
497
|
+
startupStabilityWindowMs: 1_500,
|
|
498
|
+
startupTimeoutMs: 10_000,
|
|
499
|
+
startupRetryLimit: 1,
|
|
469
500
|
listDiscoveredAgents: defaultListDiscoveredAgents,
|
|
470
501
|
runHatchFlow: hatch_flow_1.runHatchFlow,
|
|
471
502
|
promptInput: defaultPromptInput,
|
|
@@ -77,6 +77,11 @@ const startup_tui_1 = require("./startup-tui");
|
|
|
77
77
|
const stale_bundle_prune_1 = require("./stale-bundle-prune");
|
|
78
78
|
const up_progress_1 = require("./up-progress");
|
|
79
79
|
// ── ensureDaemonRunning ──
|
|
80
|
+
const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 10_000;
|
|
81
|
+
const DEFAULT_DAEMON_STARTUP_POLL_INTERVAL_MS = 500;
|
|
82
|
+
const DEFAULT_DAEMON_STARTUP_STABILITY_WINDOW_MS = 1_500;
|
|
83
|
+
const DEFAULT_DAEMON_STARTUP_RETRY_LIMIT = 1;
|
|
84
|
+
const DEFAULT_DAEMON_STARTUP_LOG_LINES = 10;
|
|
80
85
|
async function ensureDaemonRunning(deps) {
|
|
81
86
|
const alive = await deps.checkSocketAlive(deps.socketPath);
|
|
82
87
|
if (alive) {
|
|
@@ -111,66 +116,192 @@ async function ensureDaemonRunning(deps) {
|
|
|
111
116
|
checkSocketAlive: deps.checkSocketAlive,
|
|
112
117
|
});
|
|
113
118
|
}
|
|
114
|
-
deps.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (stat.size === 0)
|
|
145
|
-
continue;
|
|
146
|
-
// Only read logs from the last 30 seconds (daemon just started)
|
|
147
|
-
const mtime = stat.mtimeMs;
|
|
148
|
-
if (Date.now() - mtime > 30_000)
|
|
149
|
-
continue;
|
|
150
|
-
const buf = Buffer.alloc(4096);
|
|
151
|
-
const fd = fs.openSync(logPath, "r");
|
|
119
|
+
const retryLimit = deps.startupRetryLimit ?? DEFAULT_DAEMON_STARTUP_RETRY_LIMIT;
|
|
120
|
+
let lastFailure = {
|
|
121
|
+
reason: "daemon failed before the startup monitor recorded a failure",
|
|
122
|
+
retryable: false,
|
|
123
|
+
};
|
|
124
|
+
let lastPid = null;
|
|
125
|
+
const readLatestDaemonStartupEvent = () => {
|
|
126
|
+
try {
|
|
127
|
+
// The daemon writes structured events to daemon.ndjson in the first
|
|
128
|
+
// agent bundle's state/daemon/logs/ directory. Read the last line to
|
|
129
|
+
// surface what it's currently doing (e.g., "starting auto-start agents").
|
|
130
|
+
const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
|
|
131
|
+
if (!fs.existsSync(bundlesRoot))
|
|
132
|
+
return null;
|
|
133
|
+
const agents = fs.readdirSync(bundlesRoot).filter((d) => d.endsWith(".ouro"));
|
|
134
|
+
for (const agent of agents) {
|
|
135
|
+
const logPath = path.join(bundlesRoot, agent, "state", "daemon", "logs", "daemon.ndjson");
|
|
136
|
+
if (!fs.existsSync(logPath))
|
|
137
|
+
continue;
|
|
138
|
+
const stat = fs.statSync(logPath);
|
|
139
|
+
if (stat.size === 0)
|
|
140
|
+
continue;
|
|
141
|
+
// Only read logs from the last 30 seconds (daemon just started)
|
|
142
|
+
const mtime = stat.mtimeMs;
|
|
143
|
+
if (Date.now() - mtime > 30_000)
|
|
144
|
+
continue;
|
|
145
|
+
const buf = Buffer.alloc(4096);
|
|
146
|
+
const fd = fs.openSync(logPath, "r");
|
|
147
|
+
let bytesRead = 0;
|
|
148
|
+
try {
|
|
152
149
|
const readFrom = Math.max(0, stat.size - 4096);
|
|
153
|
-
fs.readSync(fd, buf, 0, 4096, readFrom);
|
|
150
|
+
bytesRead = fs.readSync(fd, buf, 0, 4096, readFrom);
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
154
153
|
fs.closeSync(fd);
|
|
155
|
-
const lines = buf.toString("utf-8").trim().split("\n").filter(Boolean);
|
|
156
|
-
const last = lines[lines.length - 1];
|
|
157
|
-
if (!last)
|
|
158
|
-
continue;
|
|
159
|
-
const parsed = JSON.parse(last);
|
|
160
|
-
return parsed.message ?? null;
|
|
161
154
|
}
|
|
155
|
+
const lines = buf.subarray(0, bytesRead).toString("utf-8").trim().split("\n").filter(Boolean);
|
|
156
|
+
const last = lines[lines.length - 1];
|
|
157
|
+
if (!last)
|
|
158
|
+
continue;
|
|
159
|
+
const parsed = JSON.parse(last);
|
|
160
|
+
return parsed.message ?? null;
|
|
162
161
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Best effort only.
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
};
|
|
168
|
+
for (let attempt = 0; attempt <= retryLimit; attempt += 1) {
|
|
169
|
+
deps.reportDaemonStartupPhase?.("starting daemon...");
|
|
170
|
+
deps.reportDaemonStartupPhase?.("waiting for daemon socket...");
|
|
171
|
+
deps.cleanupStaleSocket(deps.socketPath);
|
|
172
|
+
const bootStartedAtMs = (deps.now ?? Date.now)();
|
|
173
|
+
const started = await deps.startDaemonProcess(deps.socketPath);
|
|
174
|
+
lastPid = started.pid ?? null;
|
|
175
|
+
const startupFailure = await waitForDaemonStartup(deps, {
|
|
176
|
+
bootStartedAtMs,
|
|
177
|
+
pid: lastPid,
|
|
178
|
+
});
|
|
179
|
+
if (!startupFailure) {
|
|
180
|
+
const stability = await (0, startup_tui_1.pollDaemonStartup)({
|
|
181
|
+
sendCommand: deps.sendCommand,
|
|
182
|
+
socketPath: deps.socketPath,
|
|
183
|
+
daemonPid: lastPid,
|
|
184
|
+
/* v8 ignore next -- thin wrapper: raw process.stdout.write for ANSI cursor control @preserve */
|
|
185
|
+
writeRaw: (text) => process.stdout.write(text),
|
|
186
|
+
/* v8 ignore next -- thin wrapper: real Date.now() injected for testability @preserve */
|
|
187
|
+
now: () => Date.now(),
|
|
188
|
+
/* v8 ignore next -- thin wrapper: real setTimeout injected for testability @preserve */
|
|
189
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
190
|
+
/* v8 ignore start -- daemon log tail + pid check: reads real filesystem, tested via deployment @preserve */
|
|
191
|
+
readLatestDaemonEvent: readLatestDaemonStartupEvent,
|
|
192
|
+
/* v8 ignore stop */
|
|
193
|
+
});
|
|
194
|
+
return {
|
|
195
|
+
alreadyRunning: false,
|
|
196
|
+
message: `daemon started (pid ${lastPid ?? "unknown"})`,
|
|
197
|
+
stability,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
lastFailure = startupFailure;
|
|
201
|
+
if (!startupFailure.retryable || attempt >= retryLimit) {
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
deps.reportDaemonStartupPhase?.("daemon startup lost stability; cleaning up and retrying once...");
|
|
205
|
+
}
|
|
168
206
|
return {
|
|
169
207
|
alreadyRunning: false,
|
|
170
|
-
message:
|
|
171
|
-
stability,
|
|
208
|
+
message: formatDaemonStartupFailureMessage(lastPid, lastFailure, deps),
|
|
172
209
|
};
|
|
173
210
|
}
|
|
211
|
+
function hasStartupHealthMonitor(deps) {
|
|
212
|
+
return !!deps.healthFilePath && !!deps.readHealthState && !!deps.readHealthUpdatedAt;
|
|
213
|
+
}
|
|
214
|
+
function hasFreshCurrentBootHealthSignal(deps, bootStartedAtMs, pid) {
|
|
215
|
+
const healthState = deps.readHealthState(deps.healthFilePath);
|
|
216
|
+
if (!healthState)
|
|
217
|
+
return false;
|
|
218
|
+
const healthUpdatedAt = deps.readHealthUpdatedAt(deps.healthFilePath);
|
|
219
|
+
if (healthUpdatedAt === null || healthUpdatedAt < bootStartedAtMs) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
const healthStartedAtMs = Date.parse(healthState.startedAt);
|
|
223
|
+
if (!Number.isFinite(healthStartedAtMs) || healthStartedAtMs < bootStartedAtMs) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
if (pid !== null && healthState.pid !== pid) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
function formatDaemonStartupFailureMessage(pid, failure, deps) {
|
|
232
|
+
const lines = [
|
|
233
|
+
`daemon spawned (pid ${pid ?? "unknown"}) but failed to stabilize: ${failure.reason}`,
|
|
234
|
+
];
|
|
235
|
+
const recentLogLines = deps.readRecentDaemonLogLines?.(DEFAULT_DAEMON_STARTUP_LOG_LINES) ?? [];
|
|
236
|
+
if (recentLogLines.length > 0) {
|
|
237
|
+
lines.push("recent daemon logs:");
|
|
238
|
+
lines.push(...recentLogLines.map((line) => ` ${line}`));
|
|
239
|
+
}
|
|
240
|
+
lines.push("fix hint for daemon: check daemon logs or run `ouro doctor`");
|
|
241
|
+
return lines.join("\n");
|
|
242
|
+
}
|
|
243
|
+
async function waitForDaemonStartup(deps, options) {
|
|
244
|
+
const now = deps.now ?? Date.now;
|
|
245
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
246
|
+
const timeoutMs = deps.startupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS;
|
|
247
|
+
const pollIntervalMs = deps.startupPollIntervalMs ?? DEFAULT_DAEMON_STARTUP_POLL_INTERVAL_MS;
|
|
248
|
+
const stabilityWindowMs = deps.startupStabilityWindowMs ?? DEFAULT_DAEMON_STARTUP_STABILITY_WINDOW_MS;
|
|
249
|
+
const deadline = options.bootStartedAtMs + timeoutMs;
|
|
250
|
+
const useHealthMonitor = hasStartupHealthMonitor(deps);
|
|
251
|
+
let stableSinceMs = null;
|
|
252
|
+
let sawSocket = false;
|
|
253
|
+
if (!useHealthMonitor) {
|
|
254
|
+
const verified = await verifyDaemonAlive(deps.checkSocketAlive, deps.socketPath, timeoutMs, pollIntervalMs, sleep, now);
|
|
255
|
+
return verified
|
|
256
|
+
? null
|
|
257
|
+
: {
|
|
258
|
+
reason: `daemon failed to respond within ${Math.ceil(timeoutMs / 1000)}s`,
|
|
259
|
+
retryable: false,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
while (now() < deadline) {
|
|
263
|
+
await sleep(pollIntervalMs);
|
|
264
|
+
const aliveNow = await deps.checkSocketAlive(deps.socketPath);
|
|
265
|
+
if (!aliveNow) {
|
|
266
|
+
if (sawSocket) {
|
|
267
|
+
return {
|
|
268
|
+
reason: "daemon socket disappeared during startup",
|
|
269
|
+
retryable: true,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (!sawSocket) {
|
|
275
|
+
sawSocket = true;
|
|
276
|
+
stableSinceMs = now();
|
|
277
|
+
deps.reportDaemonStartupPhase?.("verifying daemon health...");
|
|
278
|
+
}
|
|
279
|
+
if (!hasFreshCurrentBootHealthSignal(deps, options.bootStartedAtMs, options.pid)) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (stableSinceMs !== null && now() - stableSinceMs >= stabilityWindowMs) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
reason: sawSocket
|
|
288
|
+
? "daemon did not publish fresh health for the current boot attempt"
|
|
289
|
+
: `daemon failed to respond within ${Math.ceil(timeoutMs / 1000)}s`,
|
|
290
|
+
retryable: sawSocket,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
async function verifyDaemonAlive(checkSocketAlive, socketPath, maxWaitMs = 10_000, pollIntervalMs = 500, sleep = defaultSleep, now = Date.now) {
|
|
294
|
+
const deadline = now() + maxWaitMs;
|
|
295
|
+
while (now() < deadline) {
|
|
296
|
+
await sleep(pollIntervalMs);
|
|
297
|
+
if (await checkSocketAlive(socketPath))
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
function defaultSleep(ms) {
|
|
303
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
304
|
+
}
|
|
174
305
|
// ── GitHub Copilot model helpers ──
|
|
175
306
|
async function listGithubCopilotModels(baseUrl, token, fetchImpl = fetch) {
|
|
176
307
|
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
|
|
@@ -933,7 +1064,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
933
1064
|
progress.completePhase("bundle cleanup", `pruned ${prunedBundles.length} stale bundle${prunedBundles.length === 1 ? "" : "s"}`);
|
|
934
1065
|
}
|
|
935
1066
|
progress.startPhase("starting daemon");
|
|
936
|
-
const daemonResult = await ensureDaemonRunning(
|
|
1067
|
+
const daemonResult = await ensureDaemonRunning({
|
|
1068
|
+
...deps,
|
|
1069
|
+
reportDaemonStartupPhase: (label) => {
|
|
1070
|
+
;
|
|
1071
|
+
progress.announceStep?.(label);
|
|
1072
|
+
},
|
|
1073
|
+
});
|
|
937
1074
|
progress.end();
|
|
938
1075
|
deps.writeStdout(daemonResult.message);
|
|
939
1076
|
// Interactive repair for degraded agents (Unit 5) — skipped by --no-repair (Unit 6)
|
|
@@ -169,19 +169,62 @@ const daemon = new daemon_1.OuroDaemon({
|
|
|
169
169
|
router,
|
|
170
170
|
mode,
|
|
171
171
|
});
|
|
172
|
+
const daemonStartedAt = new Date().toISOString();
|
|
173
|
+
const degradedComponents = [];
|
|
174
|
+
function buildDaemonHealthState() {
|
|
175
|
+
return {
|
|
176
|
+
status: degradedComponents.length > 0 ? "degraded" : "ok",
|
|
177
|
+
mode,
|
|
178
|
+
pid: process.pid,
|
|
179
|
+
startedAt: daemonStartedAt,
|
|
180
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
181
|
+
safeMode: null,
|
|
182
|
+
degraded: degradedComponents.map((entry) => ({ ...entry })),
|
|
183
|
+
agents: {},
|
|
184
|
+
habits: {},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function recordRecoverableBootstrapFailure(options) {
|
|
188
|
+
const errorMessage = options.error instanceof Error ? options.error.message : String(options.error);
|
|
189
|
+
const existing = degradedComponents.find((entry) => entry.component === options.component);
|
|
190
|
+
const reason = `${errorMessage}. ${options.guidance}`;
|
|
191
|
+
if (existing) {
|
|
192
|
+
existing.reason = reason;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
degradedComponents.push({
|
|
196
|
+
component: options.component,
|
|
197
|
+
reason,
|
|
198
|
+
since: new Date().toISOString(),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
(0, runtime_1.emitNervesEvent)({
|
|
202
|
+
level: "warn",
|
|
203
|
+
component: "daemon",
|
|
204
|
+
event: "daemon.bootstrap_degraded",
|
|
205
|
+
message: "recoverable daemon bootstrap failure; daemon remains available in degraded mode",
|
|
206
|
+
meta: {
|
|
207
|
+
agent: options.agent,
|
|
208
|
+
component: options.component,
|
|
209
|
+
habitsDir: options.habitsDir,
|
|
210
|
+
error: errorMessage,
|
|
211
|
+
guidance: options.guidance,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function emitHabitSetupError(agent, error) {
|
|
216
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
217
|
+
(0, runtime_1.emitNervesEvent)({
|
|
218
|
+
level: "error",
|
|
219
|
+
component: "daemon",
|
|
220
|
+
event: "daemon.habit_setup_error",
|
|
221
|
+
message: `habit setup failed for agent ${agent}`,
|
|
222
|
+
meta: { agent, error: normalized.message },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
172
225
|
/* v8 ignore start — daemon health writer wiring, tested via daemon-health.test.ts @preserve */
|
|
173
226
|
const healthWriter = new daemon_health_1.DaemonHealthWriter((0, daemon_health_1.getDefaultHealthPath)());
|
|
174
|
-
const healthSink = (0, daemon_health_1.createHealthNervesSink)(healthWriter,
|
|
175
|
-
status: "ok",
|
|
176
|
-
mode,
|
|
177
|
-
pid: process.pid,
|
|
178
|
-
startedAt: new Date().toISOString(),
|
|
179
|
-
uptimeSeconds: Math.floor(process.uptime()),
|
|
180
|
-
safeMode: null,
|
|
181
|
-
degraded: [],
|
|
182
|
-
agents: {},
|
|
183
|
-
habits: {},
|
|
184
|
-
}));
|
|
227
|
+
const healthSink = (0, daemon_health_1.createHealthNervesSink)(healthWriter, buildDaemonHealthState);
|
|
185
228
|
(0, index_1.registerGlobalLogSink)(healthSink);
|
|
186
229
|
/* v8 ignore stop */
|
|
187
230
|
const habitSchedulers = [];
|
|
@@ -191,9 +234,10 @@ void daemon.start().then(() => {
|
|
|
191
234
|
const ouroPath = (0, os_cron_deps_1.resolveOuroBinaryPath)();
|
|
192
235
|
const osCronDeps = (0, os_cron_deps_1.createRealOsCronDeps)();
|
|
193
236
|
for (const agent of managedAgents) {
|
|
237
|
+
const bundleRoot = path.join(bundlesRoot, `${agent}.ouro`);
|
|
238
|
+
const habitsDir = path.join(bundleRoot, "habits");
|
|
239
|
+
const degradedComponent = `habits:${agent}`;
|
|
194
240
|
try {
|
|
195
|
-
const bundleRoot = path.join(bundlesRoot, `${agent}.ouro`);
|
|
196
|
-
const habitsDir = path.join(bundleRoot, "habits");
|
|
197
241
|
// Migrate old tasks/habits/ to habits/ at bundle root
|
|
198
242
|
(0, habit_migration_1.migrateHabitsFromTaskSystem)(bundleRoot);
|
|
199
243
|
const osCronManager = new os_cron_1.LaunchdCronManager(osCronDeps);
|
|
@@ -214,19 +258,39 @@ void daemon.start().then(() => {
|
|
|
214
258
|
watch: (dir, cb) => fs.watch(dir, cb),
|
|
215
259
|
},
|
|
216
260
|
});
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
261
|
+
try {
|
|
262
|
+
scheduler.start();
|
|
263
|
+
scheduler.startPeriodicReconciliation();
|
|
264
|
+
scheduler.watchForChanges();
|
|
265
|
+
habitSchedulers.push(scheduler);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
try {
|
|
269
|
+
scheduler.stopWatching();
|
|
270
|
+
scheduler.stop();
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// Cleanup is best-effort for partially initialized schedulers.
|
|
274
|
+
}
|
|
275
|
+
emitHabitSetupError(agent, error);
|
|
276
|
+
recordRecoverableBootstrapFailure({
|
|
277
|
+
agent,
|
|
278
|
+
component: degradedComponent,
|
|
279
|
+
habitsDir,
|
|
280
|
+
error,
|
|
281
|
+
guidance: `fix ${agent} habits or cron setup and rerun ouro up to restore habit automation`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
221
284
|
}
|
|
222
285
|
catch (err) {
|
|
223
286
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
224
|
-
(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
287
|
+
emitHabitSetupError(agent, error);
|
|
288
|
+
recordRecoverableBootstrapFailure({
|
|
289
|
+
agent,
|
|
290
|
+
component: degradedComponent,
|
|
291
|
+
habitsDir,
|
|
292
|
+
error,
|
|
293
|
+
guidance: `fix ${agent} habits or cron setup and rerun ouro up to restore habit automation`,
|
|
230
294
|
});
|
|
231
295
|
}
|
|
232
296
|
}
|
|
@@ -271,6 +271,24 @@ function writePidfile(extraPids = []) {
|
|
|
271
271
|
}
|
|
272
272
|
catch { /* best effort */ }
|
|
273
273
|
}
|
|
274
|
+
function readSocketIdentity(socketPath) {
|
|
275
|
+
try {
|
|
276
|
+
const stats = fs.lstatSync(socketPath);
|
|
277
|
+
return {
|
|
278
|
+
dev: stats.dev,
|
|
279
|
+
ino: stats.ino,
|
|
280
|
+
ctimeMs: stats.ctimeMs,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function sameSocketIdentity(left, right) {
|
|
288
|
+
if (!left || !right)
|
|
289
|
+
return false;
|
|
290
|
+
return left.dev === right.dev && left.ino === right.ino && left.ctimeMs === right.ctimeMs;
|
|
291
|
+
}
|
|
274
292
|
function buildWorkerRows(snapshots) {
|
|
275
293
|
return snapshots.map((snapshot) => ({
|
|
276
294
|
agent: snapshot.name,
|
|
@@ -355,6 +373,7 @@ class OuroDaemon {
|
|
|
355
373
|
mode;
|
|
356
374
|
server = null;
|
|
357
375
|
outlookServer = null;
|
|
376
|
+
socketIdentity = null;
|
|
358
377
|
outlookServerFactory;
|
|
359
378
|
constructor(options) {
|
|
360
379
|
this.socketPath = options.socketPath;
|
|
@@ -625,6 +644,7 @@ class OuroDaemon {
|
|
|
625
644
|
server.listen(this.socketPath, () => {
|
|
626
645
|
// Replace the one-time error listener with a persistent one after successful listen
|
|
627
646
|
server.removeAllListeners("error");
|
|
647
|
+
this.socketIdentity = readSocketIdentity(this.socketPath);
|
|
628
648
|
/* v8 ignore start — server error after listen requires real socket race condition @preserve */
|
|
629
649
|
server.on("error", (err) => {
|
|
630
650
|
(0, runtime_1.emitNervesEvent)({
|
|
@@ -827,9 +847,31 @@ class OuroDaemon {
|
|
|
827
847
|
await this.outlookServer.stop();
|
|
828
848
|
this.outlookServer = null;
|
|
829
849
|
}
|
|
830
|
-
|
|
850
|
+
const socketPathExists = fs.existsSync(this.socketPath);
|
|
851
|
+
const currentSocketIdentity = socketPathExists ? readSocketIdentity(this.socketPath) : null;
|
|
852
|
+
if (sameSocketIdentity(this.socketIdentity, currentSocketIdentity)) {
|
|
831
853
|
fs.unlinkSync(this.socketPath);
|
|
832
854
|
}
|
|
855
|
+
else if (socketPathExists) {
|
|
856
|
+
const expectedSocketIdentity = { dev: null, ino: null, ctimeMs: null, ...this.socketIdentity };
|
|
857
|
+
const actualSocketIdentity = { dev: null, ino: null, ctimeMs: null, ...currentSocketIdentity };
|
|
858
|
+
(0, runtime_1.emitNervesEvent)({
|
|
859
|
+
level: "warn",
|
|
860
|
+
component: "daemon",
|
|
861
|
+
event: "daemon.socket_cleanup_skipped",
|
|
862
|
+
message: "skipped daemon socket cleanup because the socket path no longer belongs to this daemon",
|
|
863
|
+
meta: {
|
|
864
|
+
socketPath: this.socketPath,
|
|
865
|
+
expectedDev: expectedSocketIdentity.dev,
|
|
866
|
+
expectedIno: expectedSocketIdentity.ino,
|
|
867
|
+
expectedCtimeMs: expectedSocketIdentity.ctimeMs,
|
|
868
|
+
actualDev: actualSocketIdentity.dev,
|
|
869
|
+
actualIno: actualSocketIdentity.ino,
|
|
870
|
+
actualCtimeMs: actualSocketIdentity.ctimeMs,
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
this.socketIdentity = null;
|
|
833
875
|
}
|
|
834
876
|
async handleRawPayload(raw) {
|
|
835
877
|
try {
|
|
@@ -43,6 +43,15 @@ class UpProgress {
|
|
|
43
43
|
}
|
|
44
44
|
this.currentPhase = { label, startedAt: Date.now() };
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Emit a one-line status breadcrumb in non-TTY mode without affecting the
|
|
48
|
+
* accumulated checklist state. Used for daemon startup sub-steps.
|
|
49
|
+
*/
|
|
50
|
+
announceStep(label) {
|
|
51
|
+
if (this.isTTY)
|
|
52
|
+
return;
|
|
53
|
+
this.write(label);
|
|
54
|
+
}
|
|
46
55
|
/**
|
|
47
56
|
* Mark the current phase as done. In non-TTY mode, immediately writes
|
|
48
57
|
* a static line. Emits a nerves event for observability.
|