@ouro.bot/cli 0.1.0-alpha.259 → 0.1.0-alpha.260
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 +9 -0
- package/dist/heart/daemon/daemon.js +74 -3
- package/dist/heart/daemon/socket-client.js +20 -3
- package/dist/heart/daemon/thoughts.js +4 -1
- package/dist/heart/habits/habit-scheduler.js +3 -1
- package/dist/heart/mcp/mcp-server.js +3 -1
- package/dist/heart/providers/minimax-vlm.js +39 -17
- package/dist/heart/versioning/update-checker.js +3 -1
- package/dist/repertoire/mcp-manager.js +3 -1
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
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.260",
|
|
6
|
+
"changes": [
|
|
7
|
+
"fix(daemon): MCP bridge empty-response bug for long-running commands. `sendDaemonCommand` and `checkDaemonSocketAlive` were calling `client.end()` immediately after writing, which half-closed the TCP connection. The daemon server's `net.createServer()` uses the default `allowHalfOpen: false`, so when it saw the client's FIN it auto-closed its own writable side — and any response the server tried to write after processing a long-running command (like `agent.senseTurn`, which runs a full LLM turn) was dropped on the floor. Verified via direct socket repro: with `client.end()`, `agent.senseTurn` returned empty in ~149ms; without it, the same call returned a real response in ~5.8s. This was a silent regression of the fix in #303 (commit `253e4b1f` titled \"socket half-close fix\" actually *added* the half-close back). Fix: drop the `client.end()` calls from both sites AND set `allowHalfOpen: true` on the daemon's `net.createServer(...)` as defense-in-depth so future clients calling `end()` don't silently break again.",
|
|
8
|
+
"fix(daemon): tighten pidfile trust — `killOrphanProcesses` now verifies each pidfile PID is an actual orphan (PPID reparented to init/PID 1) before SIGTERMing it. Previously a polluted pidfile (written by a crashed daemon whose PIDs have since been reused by the OS for unrelated processes) could cause mass-kill of unrelated apps. New exported helper `filterPidfilePidsToActualOrphans` provides direct unit coverage.",
|
|
9
|
+
"fix(daemon, repertoire): rename six `_stop` nerves events to `_end` so they pair correctly with their `_start` counterparts under the nerves audit's start/end pairing rule. Affected: `daemon.thoughts_follow_stop`, `daemon.server_stop`, `daemon.update_checker_stop`, `daemon.mcp_server_stop`, `daemon.habit_scheduler_stop`, `mcp.manager_stop`. The naming was semantically fine (`stop` pairs with `start`), but the audit specifically looks for `_end`/`_error` suffixes. Was producing intermittent audit failures whenever any test run exercised these teardown paths (seen today as flakes on `daemon-socket-errors.test.ts` and `MarkdownStreamer` tests).",
|
|
10
|
+
"fix(providers): drop the harness-imposed MiniMax VLM timeout entirely. Previously the module defaulted to a 60-second AbortSignal; E2E validation of the BB image fix hit a real VLM request that took >60s to return (same bytes returned in 9.5s on the immediate retry). Raising to 120s would have been arbitrary too \u2014 the correct answer, per the same reasoning as PR #322 for LLM providers, is to not impose a harness ceiling at all. When `timeoutMs` isn't provided, `fetch()` now runs without an AbortSignal and undici's own defaults (headersTimeout + bodyTimeout, both 5 minutes) are the ceiling. Callers that want a tighter bound can still pass an explicit `timeoutMs`. The AbortError path is kept for that case, and the error message adapts to say \"underlying stack default\" when there was no harness-set value."
|
|
11
|
+
]
|
|
12
|
+
},
|
|
4
13
|
{
|
|
5
14
|
"version": "0.1.0-alpha.259",
|
|
6
15
|
"changes": [
|
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.OuroDaemon = void 0;
|
|
37
37
|
exports.parseOrphanPidsFromPs = parseOrphanPidsFromPs;
|
|
38
|
+
exports.filterPidfilePidsToActualOrphans = filterPidfilePidsToActualOrphans;
|
|
38
39
|
exports.killOrphanProcesses = killOrphanProcesses;
|
|
39
40
|
exports.writePidfile = writePidfile;
|
|
40
41
|
exports.handleAgentSenseTurn = handleAgentSenseTurn;
|
|
@@ -104,11 +105,65 @@ function parseOrphanPidsFromPs(psOutput, selfPid) {
|
|
|
104
105
|
}
|
|
105
106
|
return orphans;
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Given a list of PIDs from the pidfile, return only those that are actual
|
|
110
|
+
* orphans (PPID reparented to init/PID 1). Protects against a polluted
|
|
111
|
+
* pidfile killing a PID that the OS has reassigned to an unrelated process.
|
|
112
|
+
*
|
|
113
|
+
* Implementation: shells out to `ps -p <csv> -o pid,ppid` for a batch lookup.
|
|
114
|
+
* Returns the empty list if ps fails — safer to skip cleanup than to
|
|
115
|
+
* wildcard-kill on a bad read.
|
|
116
|
+
*
|
|
117
|
+
* Exported for direct unit coverage.
|
|
118
|
+
*/
|
|
119
|
+
function filterPidfilePidsToActualOrphans(candidatePids, psRunner = runPsCheck) {
|
|
120
|
+
if (candidatePids.length === 0)
|
|
121
|
+
return [];
|
|
122
|
+
const psOutput = psRunner(candidatePids);
|
|
123
|
+
if (psOutput === null)
|
|
124
|
+
return [];
|
|
125
|
+
const survivingOrphans = [];
|
|
126
|
+
// `ps -p x,y,z -o pid,ppid` emits a header line then one row per found PID.
|
|
127
|
+
// PIDs not found (already exited) are silently omitted — which is the
|
|
128
|
+
// correct behavior for us: we only want to kill live orphans.
|
|
129
|
+
for (const line of psOutput.split("\n")) {
|
|
130
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)$/);
|
|
131
|
+
if (!match)
|
|
132
|
+
continue;
|
|
133
|
+
const pid = parseInt(match[1], 10);
|
|
134
|
+
const ppid = parseInt(match[2], 10);
|
|
135
|
+
if (ppid !== 1)
|
|
136
|
+
continue;
|
|
137
|
+
if (!candidatePids.includes(pid))
|
|
138
|
+
continue;
|
|
139
|
+
survivingOrphans.push(pid);
|
|
140
|
+
}
|
|
141
|
+
return survivingOrphans;
|
|
142
|
+
}
|
|
143
|
+
/* v8 ignore start -- shells out to ps; covered by filterPidfilePidsToActualOrphans unit tests via injected runner @preserve */
|
|
144
|
+
function runPsCheck(pids) {
|
|
145
|
+
try {
|
|
146
|
+
const csv = pids.join(",");
|
|
147
|
+
return (0, child_process_1.execSync)(`ps -p ${csv} -o pid=,ppid=`, { encoding: "utf-8", timeout: 5000 });
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// ps returns non-zero when none of the requested PIDs exist. Treat as
|
|
151
|
+
// "no survivors" rather than an error.
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/* v8 ignore stop */
|
|
107
156
|
/**
|
|
108
157
|
* Kill all ouro processes from the previous daemon instance using the pidfile.
|
|
109
158
|
* On startup, reads PIDs from ~/.ouro-cli/daemon.pids, kills them all, then
|
|
110
159
|
* deletes the file. The new daemon writes its own PIDs after spawning.
|
|
111
160
|
*
|
|
161
|
+
* Safety: pidfile contents are verified before being killed — each PID must
|
|
162
|
+
* be an actual orphan (PPID reparented to init/PID 1) via
|
|
163
|
+
* `filterPidfilePidsToActualOrphans`. Otherwise a polluted pidfile (written
|
|
164
|
+
* by a test, or a crashed daemon whose PIDs have since been reused by the
|
|
165
|
+
* OS) could SIGTERM unrelated processes.
|
|
166
|
+
*
|
|
112
167
|
* Falls back to ps-based scanning scoped to true orphans (PPID=1) if the
|
|
113
168
|
* pidfile doesn't exist (first run, previous daemon crashed before writing,
|
|
114
169
|
* manual cleanup). The scope is narrow on purpose — see parseOrphanPidsFromPs.
|
|
@@ -120,7 +175,12 @@ function killOrphanProcesses() {
|
|
|
120
175
|
// Primary: read pidfile from previous daemon
|
|
121
176
|
try {
|
|
122
177
|
const raw = fs.readFileSync(PIDFILE_PATH, "utf-8");
|
|
123
|
-
|
|
178
|
+
const candidates = raw.split("\n")
|
|
179
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
180
|
+
.filter((n) => !isNaN(n) && n !== process.pid);
|
|
181
|
+
// Verify each candidate is an actual live orphan before killing. See
|
|
182
|
+
// docstring above for why this matters.
|
|
183
|
+
pidsToKill = filterPidfilePidsToActualOrphans(candidates);
|
|
124
184
|
fs.unlinkSync(PIDFILE_PATH);
|
|
125
185
|
}
|
|
126
186
|
catch {
|
|
@@ -421,7 +481,15 @@ class OuroDaemon {
|
|
|
421
481
|
if (fs.existsSync(this.socketPath)) {
|
|
422
482
|
fs.unlinkSync(this.socketPath);
|
|
423
483
|
}
|
|
424
|
-
|
|
484
|
+
// allowHalfOpen: true lets the server keep its writable side open after
|
|
485
|
+
// the client sends FIN. Without this, when a client calls `client.end()`
|
|
486
|
+
// after writing a command, node closes the server's writable side
|
|
487
|
+
// automatically — so a long-running response (like an agent.senseTurn
|
|
488
|
+
// LLM turn that takes 5+ seconds) never reaches the client. The
|
|
489
|
+
// socket-client fix in #303/#334 also removed client.end() on the
|
|
490
|
+
// sending side, but this option is defense in depth: even if a future
|
|
491
|
+
// caller half-closes, the server still writes its response correctly.
|
|
492
|
+
this.server = net.createServer({ allowHalfOpen: true }, (connection) => {
|
|
425
493
|
let raw = "";
|
|
426
494
|
let responded = false;
|
|
427
495
|
/* v8 ignore start — connection error handler requires real socket error @preserve */
|
|
@@ -620,9 +688,12 @@ class OuroDaemon {
|
|
|
620
688
|
}
|
|
621
689
|
}
|
|
622
690
|
async stop() {
|
|
691
|
+
// Must be named `_end` (not `_stop`) to satisfy the nerves audit's
|
|
692
|
+
// start/end pairing rule — see src/nerves/coverage/audit-rules.ts.
|
|
693
|
+
// This is the counterpart to `daemon.server_start` emitted at line 480.
|
|
623
694
|
(0, runtime_1.emitNervesEvent)({
|
|
624
695
|
component: "daemon",
|
|
625
|
-
event: "daemon.
|
|
696
|
+
event: "daemon.server_end",
|
|
626
697
|
message: "stopping daemon server",
|
|
627
698
|
meta: { socketPath: this.socketPath },
|
|
628
699
|
});
|
|
@@ -96,8 +96,23 @@ function sendDaemonCommand(socketPath, command) {
|
|
|
96
96
|
const client = net.createConnection(socketPath);
|
|
97
97
|
let raw = "";
|
|
98
98
|
client.on("connect", () => {
|
|
99
|
+
// Write the command + newline delimiter. DO NOT call client.end()
|
|
100
|
+
// afterwards — the server closes the connection once it has written
|
|
101
|
+
// its response, which triggers our on("end") handler with the full
|
|
102
|
+
// response in `raw`.
|
|
103
|
+
//
|
|
104
|
+
// Calling client.end() here half-closes the TCP connection. The
|
|
105
|
+
// daemon server uses net.createServer() with the default
|
|
106
|
+
// allowHalfOpen: false, so when the server sees the client's FIN it
|
|
107
|
+
// auto-closes its own writable side — and any response the server
|
|
108
|
+
// tries to write after processing a long-running command (like
|
|
109
|
+
// agent.senseTurn, which runs a full LLM turn) gets dropped on the
|
|
110
|
+
// floor. Verified via repro: with client.end(), agent.senseTurn
|
|
111
|
+
// returns empty in ~149ms; without it, the same call returns the real
|
|
112
|
+
// response in ~5.8s. This is a regression of the fix in #303 that
|
|
113
|
+
// was silently reverted by 253e4b1f (commit titled "socket half-close
|
|
114
|
+
// fix" but actually *added* the half-close back).
|
|
99
115
|
client.write(JSON.stringify(command) + "\n");
|
|
100
|
-
client.end();
|
|
101
116
|
});
|
|
102
117
|
client.on("data", (chunk) => {
|
|
103
118
|
raw += chunk.toString("utf-8");
|
|
@@ -208,8 +223,10 @@ function checkDaemonSocketAlive(socketPath) {
|
|
|
208
223
|
});
|
|
209
224
|
}
|
|
210
225
|
client.on("connect", () => {
|
|
211
|
-
|
|
212
|
-
client.end()
|
|
226
|
+
// Same half-close rationale as sendDaemonCommand: do NOT call
|
|
227
|
+
// client.end() here. The server closes after responding and that
|
|
228
|
+
// triggers the on("end") handler below.
|
|
229
|
+
client.write(JSON.stringify({ kind: "daemon.status" }) + "\n");
|
|
213
230
|
});
|
|
214
231
|
client.on("data", (chunk) => {
|
|
215
232
|
raw += chunk.toString("utf-8");
|
|
@@ -497,9 +497,12 @@ function followThoughts(sessionPath, onNewTurns, pollIntervalMs = 1000) {
|
|
|
497
497
|
});
|
|
498
498
|
return () => {
|
|
499
499
|
fs.unwatchFile(sessionPath);
|
|
500
|
+
// Must be named `_end` (not `_stop`) to satisfy the nerves audit's
|
|
501
|
+
// start/end pairing rule — see src/nerves/coverage/audit-rules.ts which
|
|
502
|
+
// pairs `<prefix>_start` with `<prefix>_end` or `<prefix>_error`.
|
|
500
503
|
(0, runtime_1.emitNervesEvent)({
|
|
501
504
|
component: "daemon",
|
|
502
|
-
event: "daemon.
|
|
505
|
+
event: "daemon.thoughts_follow_end",
|
|
503
506
|
message: "stopped following inner dialog session",
|
|
504
507
|
meta: { sessionPath, totalTurns: displayedCount },
|
|
505
508
|
});
|
|
@@ -124,9 +124,11 @@ class HabitScheduler {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
stop() {
|
|
127
|
+
// `_end` (not `_stop`) to pair with `daemon.habit_scheduler_start`
|
|
128
|
+
// under the nerves audit start/end pairing rule.
|
|
127
129
|
(0, runtime_1.emitNervesEvent)({
|
|
128
130
|
component: "daemon",
|
|
129
|
-
event: "daemon.
|
|
131
|
+
event: "daemon.habit_scheduler_end",
|
|
130
132
|
message: "habit scheduler stopping",
|
|
131
133
|
meta: { agent: this.agent },
|
|
132
134
|
});
|
|
@@ -479,9 +479,11 @@ function createMcpServer(options) {
|
|
|
479
479
|
return;
|
|
480
480
|
running = false;
|
|
481
481
|
stdin.removeListener("data", onData);
|
|
482
|
+
// `_end` (not `_stop`) to satisfy the nerves audit's start/end
|
|
483
|
+
// pairing rule — counterpart to `daemon.mcp_server_start`.
|
|
482
484
|
(0, runtime_1.emitNervesEvent)({
|
|
483
485
|
component: "daemon",
|
|
484
|
-
event: "daemon.
|
|
486
|
+
event: "daemon.mcp_server_end",
|
|
485
487
|
message: "MCP server stopped",
|
|
486
488
|
meta: { agent, friendId },
|
|
487
489
|
});
|
|
@@ -3,7 +3,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.minimaxVlmDescribe = minimaxVlmDescribe;
|
|
4
4
|
const runtime_1 = require("../../nerves/runtime");
|
|
5
5
|
const error_classification_1 = require("./error-classification");
|
|
6
|
-
|
|
6
|
+
// No default timeout — if the caller doesn't pass `timeoutMs`, we don't
|
|
7
|
+
// layer an AbortSignal on top of fetch, which means undici's own defaults
|
|
8
|
+
// (headersTimeout and bodyTimeout, both 5 minutes in the current Node) are
|
|
9
|
+
// the ceiling. That's the right ceiling: long enough to tolerate any
|
|
10
|
+
// legitimate MiniMax VLM slow window, short enough to eventually give up on
|
|
11
|
+
// a dead endpoint, and — unlike a number we picked — it's the stack's
|
|
12
|
+
// actual default rather than a harness-imposed fiction.
|
|
13
|
+
//
|
|
14
|
+
// Tests that want to exercise the timeout branch pass an explicit
|
|
15
|
+
// `timeoutMs`; production callers omit it and get undici's behavior.
|
|
16
|
+
//
|
|
17
|
+
// Same reasoning as PR #322 (dropped the 30s SDK timeout from the LLM
|
|
18
|
+
// providers): the harness shouldn't impose artificially tight request
|
|
19
|
+
// ceilings when the underlying stack already has correct defaults.
|
|
7
20
|
const MODEL_NAME = "minimax-vlm";
|
|
8
21
|
const SUPPORTED_DATA_URL_PATTERN = /^data:image\/(png|jpeg|webp);base64,/i;
|
|
9
22
|
function buildVlmUrl(baseURL) {
|
|
@@ -90,31 +103,40 @@ async function minimaxVlmDescribe(params) {
|
|
|
90
103
|
throw new Error(msg);
|
|
91
104
|
}
|
|
92
105
|
const fetchImpl = params.fetchImpl ?? fetch;
|
|
93
|
-
const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
94
106
|
const url = buildVlmUrl(params.baseURL);
|
|
95
107
|
emitStart(params);
|
|
96
108
|
const startTs = Date.now();
|
|
109
|
+
// Only layer an AbortSignal if the caller explicitly asked for one.
|
|
110
|
+
// Omitting it lets undici's defaults (headersTimeout + bodyTimeout = 5min
|
|
111
|
+
// each in current Node) act as the ceiling — see the long comment on
|
|
112
|
+
// DEFAULT_TIMEOUT_MS above for the rationale.
|
|
113
|
+
const fetchOptions = {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
"Authorization": `Bearer ${params.apiKey}`,
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
"MM-API-Source": "Ouroboros",
|
|
119
|
+
},
|
|
120
|
+
body: JSON.stringify({
|
|
121
|
+
prompt: params.prompt,
|
|
122
|
+
image_url: params.imageDataUrl,
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
if (typeof params.timeoutMs === "number" && params.timeoutMs > 0) {
|
|
126
|
+
fetchOptions.signal = AbortSignal.timeout(params.timeoutMs);
|
|
127
|
+
}
|
|
97
128
|
let response;
|
|
98
129
|
try {
|
|
99
|
-
response = await fetchImpl(url,
|
|
100
|
-
method: "POST",
|
|
101
|
-
headers: {
|
|
102
|
-
"Authorization": `Bearer ${params.apiKey}`,
|
|
103
|
-
"Content-Type": "application/json",
|
|
104
|
-
"MM-API-Source": "Ouroboros",
|
|
105
|
-
},
|
|
106
|
-
body: JSON.stringify({
|
|
107
|
-
prompt: params.prompt,
|
|
108
|
-
image_url: params.imageDataUrl,
|
|
109
|
-
}),
|
|
110
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
111
|
-
});
|
|
130
|
+
response = await fetchImpl(url, fetchOptions);
|
|
112
131
|
}
|
|
113
132
|
catch (error) {
|
|
114
133
|
const err = (error instanceof Error ? error : new Error(String(error)));
|
|
115
134
|
if (err.name === "AbortError" || err.name === "TimeoutError") {
|
|
116
|
-
const seconds = Math.round(timeoutMs / 1000);
|
|
117
|
-
|
|
135
|
+
const seconds = typeof params.timeoutMs === "number" ? Math.round(params.timeoutMs / 1000) : null;
|
|
136
|
+
const hint = seconds !== null
|
|
137
|
+
? `request timed out after ${seconds}s — retry or ask the user to resend the image`
|
|
138
|
+
: `request timed out (underlying stack default) — retry or ask the user to resend the image`;
|
|
139
|
+
throwAndEmit(params, `minimax VLM: ${hint}`);
|
|
118
140
|
}
|
|
119
141
|
if ((0, error_classification_1.isNetworkError)(err)) {
|
|
120
142
|
throwAndEmit(params, `minimax VLM: network error talking to the vlm endpoint (${err.message}) — retry in a moment`);
|
|
@@ -102,9 +102,11 @@ function stopUpdateChecker() {
|
|
|
102
102
|
clearInterval(_intervalId);
|
|
103
103
|
_intervalId = null;
|
|
104
104
|
}
|
|
105
|
+
// `_end` (not `_stop`) to satisfy the nerves audit's start/end pairing
|
|
106
|
+
// rule — counterpart to `daemon.update_checker_start` above.
|
|
105
107
|
(0, runtime_1.emitNervesEvent)({
|
|
106
108
|
component: "daemon",
|
|
107
|
-
event: "daemon.
|
|
109
|
+
event: "daemon.update_checker_end",
|
|
108
110
|
message: "stopping update checker",
|
|
109
111
|
meta: {},
|
|
110
112
|
});
|
|
@@ -91,8 +91,10 @@ class McpManager {
|
|
|
91
91
|
/* v8 ignore stop */
|
|
92
92
|
shutdown() {
|
|
93
93
|
this.shuttingDown = true;
|
|
94
|
+
// `_end` (not `_stop`) to pair with `mcp.manager_start` under the
|
|
95
|
+
// nerves audit start/end pairing rule.
|
|
94
96
|
(0, runtime_1.emitNervesEvent)({
|
|
95
|
-
event: "mcp.
|
|
97
|
+
event: "mcp.manager_end",
|
|
96
98
|
component: "repertoire",
|
|
97
99
|
message: "shutting down MCP manager",
|
|
98
100
|
meta: { serverCount: this.servers.size },
|