@ouro.bot/cli 0.1.0-alpha.252 → 0.1.0-alpha.254

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 CHANGED
@@ -1,6 +1,19 @@
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.254",
6
+ "changes": [
7
+ "fix(daemon): orphan-cleanup fallback no longer kills processes from sibling harness instances. On startup, `killOrphanProcesses` scans `ps` for harness entry points (`agent-entry.js`, `daemon-entry.js`, `bluebubbles/entry.js`, `teams-entry.js`) and SIGTERMs them when the pidfile is missing. Previously any matching process was fair game, so a vitest-driven harness run from a sibling worktree (or a parallel Claude Code session running the coverage gate) would terminate the production daemon's children, triggering cascading graceful shutdowns and making production slugger unavailable for seconds-to-minutes at a time. Now the fallback only flags true orphans \u2014 processes whose PPID has been reparented to init (PID 1). Sibling daemons' live-parented children are left alone. New exported helper `parseOrphanPidsFromPs` isolates the filter for direct unit coverage. Complements the test-isolation guard shipped in alpha.253 (#333): that PR stopped tests from SENDING `inner.wake testagent` commands into the production socket; this PR stops the production daemon from SIGTERMing test-spawned child processes during its own startup."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.253",
12
+ "changes": [
13
+ "fix(daemon): test-isolation guard \u2014 stop tests from leaking real `inner.wake` commands into the running daemon. A pattern in 36+ test files mocks `getAgentName` to the literal `\"testagent\"` but does NOT mock `socket-client`, so any code path through pondering / rest / coding feedback fires a real socket connection at /tmp/ouroboros-daemon.sock with `inner.wake testagent`. The daemon errored on every command (`Unknown managed agent 'testagent'`) and at flood volumes that contributed to a real outage on the developer's machine. The fix is in `socket-client.ts` itself: detect vitest via `process.argv` (no env vars) and convert all socket operations into safe no-ops. Tests that legitimately exercise the real transport (socket-client.test.ts, daemon-cli-defaults.test.ts) opt out of the guard via a new `__bypassVitestGuardForTests()` setter that lives on globalThis to survive `vi.resetModules()`. New nerves events: `daemon.socket_command_test_blocked`, `daemon.inner_wake_test_blocked`.",
14
+ "test(contract): new test-isolation contract test ratchets two anti-patterns: (1) test files using `name: \"testagent\"` without mocking socket-client, and (2) test files constructing write paths under the real `~/AgentBundles` via `os.homedir()`. Existing offenders are grandfathered in two allowlists; new offenders fail the build. Follow-up PRs shrink the allowlists toward zero."
15
+ ]
16
+ },
4
17
  {
5
18
  "version": "0.1.0-alpha.252",
6
19
  "changes": [
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.OuroDaemon = void 0;
37
+ exports.parseOrphanPidsFromPs = parseOrphanPidsFromPs;
37
38
  exports.killOrphanProcesses = killOrphanProcesses;
38
39
  exports.writePidfile = writePidfile;
39
40
  exports.handleAgentSenseTurn = handleAgentSenseTurn;
@@ -62,13 +63,55 @@ const outlook_types_1 = require("../outlook/outlook-types");
62
63
  const outlook_read_1 = require("../outlook/outlook-read");
63
64
  const outlook_view_1 = require("../outlook/outlook-view");
64
65
  const PIDFILE_PATH = path.join(os.homedir(), ".ouro-cli", "daemon.pids");
66
+ /**
67
+ * Scan `ps -eo pid,ppid,command` output for daemon-owned entry points whose
68
+ * parent has died (PPID reparented to init/PID 1). Returns the list of PIDs
69
+ * that are safe to SIGTERM — true orphans, not children of live sibling
70
+ * daemons running from worktrees, test suites, or other users of the harness.
71
+ *
72
+ * Exported so unit tests can exercise the filter without shelling out.
73
+ */
74
+ function parseOrphanPidsFromPs(psOutput, selfPid) {
75
+ const orphans = [];
76
+ for (const line of psOutput.split("\n")) {
77
+ // Explicitly exclude MCP server processes — they share a harness entry
78
+ // point but are not daemon children and must never be killed.
79
+ if (line.includes("mcp-serve") || line.includes("mcp serve"))
80
+ continue;
81
+ // Match only daemon-owned JS entry points.
82
+ if (!line.includes("agent-entry.js")
83
+ && !line.includes("daemon-entry.js")
84
+ && !line.includes("bluebubbles/entry.js")
85
+ && !line.includes("teams-entry.js"))
86
+ continue;
87
+ // Parse `<pid> <ppid> <command...>`. ps pads these with leading spaces.
88
+ // Regex guarantees both groups are \d+ so parseInt can't produce NaN.
89
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s/);
90
+ if (!match)
91
+ continue;
92
+ const pid = parseInt(match[1], 10);
93
+ const ppid = parseInt(match[2], 10);
94
+ if (pid === selfPid)
95
+ continue;
96
+ // CRITICAL: only kill processes whose parent is init (PID 1). A live
97
+ // PPID means the process belongs to another daemon instance (parallel
98
+ // test run, sibling worktree, another user of /tmp/ouroboros-daemon.sock).
99
+ // Killing those will crash unrelated harnesses — we saw this in B6
100
+ // when a vitest worker's daemon killed slugger's production children.
101
+ if (ppid !== 1)
102
+ continue;
103
+ orphans.push(pid);
104
+ }
105
+ return orphans;
106
+ }
65
107
  /**
66
108
  * Kill all ouro processes from the previous daemon instance using the pidfile.
67
109
  * On startup, reads PIDs from ~/.ouro-cli/daemon.pids, kills them all, then
68
110
  * deletes the file. The new daemon writes its own PIDs after spawning.
69
111
  *
70
- * Falls back to ps-based scanning if the pidfile doesn't exist (first run
71
- * or manual cleanup).
112
+ * Falls back to ps-based scanning scoped to true orphans (PPID=1) if the
113
+ * pidfile doesn't exist (first run, previous daemon crashed before writing,
114
+ * manual cleanup). The scope is narrow on purpose — see parseOrphanPidsFromPs.
72
115
  */
73
116
  /* v8 ignore start -- process lifecycle: uses kill/ps, tested via deployment @preserve */
74
117
  function killOrphanProcesses() {
@@ -81,22 +124,12 @@ function killOrphanProcesses() {
81
124
  fs.unlinkSync(PIDFILE_PATH);
82
125
  }
83
126
  catch {
84
- // No pidfile — fall back to ps scan
127
+ // No pidfile — fall back to ps scan (scoped to orphans with PPID=1).
85
128
  }
86
- // Fallback: scan ps for daemon-owned processes only (not MCP servers or external tools)
87
129
  if (pidsToKill.length === 0) {
88
130
  try {
89
- const result = (0, child_process_1.execSync)("ps -eo pid,command", { encoding: "utf-8", timeout: 5000 });
90
- for (const line of result.split("\n")) {
91
- // Only match daemon-owned entry points, NOT mcp-serve or other external processes
92
- if (line.includes("mcp-serve") || line.includes("mcp serve"))
93
- continue;
94
- if (!line.includes("agent-entry.js") && !line.includes("daemon-entry.js") && !line.includes("bluebubbles/entry.js") && !line.includes("teams-entry.js"))
95
- continue;
96
- const pid = parseInt(line.trim(), 10);
97
- if (!isNaN(pid) && pid !== process.pid)
98
- pidsToKill.push(pid);
99
- }
131
+ const result = (0, child_process_1.execSync)("ps -eo pid,ppid,command", { encoding: "utf-8", timeout: 5000 });
132
+ pidsToKill = parseOrphanPidsFromPs(result, process.pid);
100
133
  }
101
134
  catch { /* ps failed — best effort */ }
102
135
  }
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DEFAULT_DAEMON_SOCKET_PATH = void 0;
37
+ exports.__bypassVitestGuardForTests = __bypassVitestGuardForTests;
37
38
  exports.sendDaemonCommand = sendDaemonCommand;
38
39
  exports.checkDaemonSocketAlive = checkDaemonSocketAlive;
39
40
  exports.requestInnerWake = requestInnerWake;
@@ -41,7 +42,50 @@ const fs = __importStar(require("fs"));
41
42
  const net = __importStar(require("net"));
42
43
  const runtime_1 = require("../../nerves/runtime");
43
44
  exports.DEFAULT_DAEMON_SOCKET_PATH = "/tmp/ouroboros-daemon.sock";
45
+ /**
46
+ * Defense-in-depth: detect if we're running under vitest. Tests that forget
47
+ * to `vi.mock("../../heart/daemon/socket-client")` would otherwise leak real
48
+ * `inner.wake` commands (with whatever literal agent name the test uses,
49
+ * commonly "testagent") into whichever real daemon happens to be running on
50
+ * the developer's machine. That has caused real outages when test loops
51
+ * flooded the production-on-localhost daemon.
52
+ *
53
+ * We detect vitest via `process.argv` (not env vars — the project bans those)
54
+ * and convert all socket operations into safe no-ops. Individual tests are
55
+ * still expected to vi.mock this module so they can assert on call counts,
56
+ * but this guard guarantees that "I forgot to mock" can never again take
57
+ * down the daemon.
58
+ *
59
+ * The socket-client's OWN tests (which exercise the real functions against a
60
+ * test socket) call `__bypassVitestGuardForTests(true)` to opt out of the
61
+ * guard. We persist that flag on globalThis so it survives `vi.resetModules`
62
+ * (which clears the module cache between tests).
63
+ */
64
+ const BYPASS_KEY = "__ouro_socket_client_bypass_vitest_guard__";
65
+ /** ONLY for socket-client.test.ts. Do not call from any other test or production code. */
66
+ function __bypassVitestGuardForTests(bypass) {
67
+ ;
68
+ globalThis[BYPASS_KEY] = bypass;
69
+ }
70
+ function isUnderVitest() {
71
+ if (globalThis[BYPASS_KEY] === true)
72
+ return false;
73
+ /* v8 ignore next -- defensive: process and process.argv always exist in node @preserve */
74
+ if (typeof process === "undefined" || !Array.isArray(process.argv))
75
+ return false;
76
+ return process.argv.some((arg) => typeof arg === "string" && arg.includes("vitest"));
77
+ }
44
78
  function sendDaemonCommand(socketPath, command) {
79
+ if (isUnderVitest()) {
80
+ (0, runtime_1.emitNervesEvent)({
81
+ level: "warn",
82
+ component: "daemon",
83
+ event: "daemon.socket_command_test_blocked",
84
+ message: "blocked socket command from leaking into real daemon under vitest",
85
+ meta: { socketPath, kind: command.kind },
86
+ });
87
+ return Promise.resolve({ ok: true, message: "test mode: socket call suppressed" });
88
+ }
45
89
  (0, runtime_1.emitNervesEvent)({
46
90
  component: "daemon",
47
91
  event: "daemon.socket_command_start",
@@ -132,6 +176,9 @@ function sendDaemonCommand(socketPath, command) {
132
176
  });
133
177
  }
134
178
  function checkDaemonSocketAlive(socketPath) {
179
+ if (isUnderVitest()) {
180
+ return Promise.resolve(false);
181
+ }
135
182
  (0, runtime_1.emitNervesEvent)({
136
183
  component: "daemon",
137
184
  event: "daemon.socket_alive_check_start",
@@ -184,6 +231,16 @@ function checkDaemonSocketAlive(socketPath) {
184
231
  });
185
232
  }
186
233
  async function requestInnerWake(agent, socketPath = exports.DEFAULT_DAEMON_SOCKET_PATH) {
234
+ if (isUnderVitest()) {
235
+ (0, runtime_1.emitNervesEvent)({
236
+ level: "warn",
237
+ component: "daemon",
238
+ event: "daemon.inner_wake_test_blocked",
239
+ message: "blocked inner wake from leaking into real daemon under vitest",
240
+ meta: { agent, socketPath },
241
+ });
242
+ return null;
243
+ }
187
244
  const socketAvailable = fs.existsSync(socketPath);
188
245
  (0, runtime_1.emitNervesEvent)({
189
246
  component: "daemon",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.252",
3
+ "version": "0.1.0-alpha.254",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",