moflo 4.10.7 → 4.10.8
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/.claude/guidance/shipped/moflo-cli-reference.md +1 -1
- package/.claude/guidance/shipped/moflo-memory-strategy.md +1 -1
- package/.claude/guidance/shipped/moflo-yaml-reference.md +4 -4
- package/.claude/skills/memory-optimization/SKILL.md +1 -1
- package/.claude/skills/memory-patterns/SKILL.md +3 -3
- package/.claude/skills/vector-search/SKILL.md +2 -2
- package/README.md +5 -5
- package/bin/lib/daemon-port.mjs +66 -0
- package/dist/src/cli/commands/daemon.js +31 -10
- package/dist/src/cli/commands/doctor-checks-config.js +139 -1
- package/dist/src/cli/commands/doctor-fixes.js +75 -2
- package/dist/src/cli/commands/doctor-registry.js +10 -1
- package/dist/src/cli/commands/memory.js +8 -8
- package/dist/src/cli/commands/neural.js +8 -6
- package/dist/src/cli/config/moflo-config.js +68 -3
- package/dist/src/cli/index.js +18 -19
- package/dist/src/cli/init/moflo-yaml-template.js +1 -1
- package/dist/src/cli/mcp-server.js +59 -10
- package/dist/src/cli/mcp-tools/memory-tools.js +46 -27
- package/dist/src/cli/memory/auto-memory-bridge.js +1 -1
- package/dist/src/cli/memory/controllers/attestation-log.js +1 -1
- package/dist/src/cli/memory/controllers/causal-graph.js +1 -1
- package/dist/src/cli/memory/daemon-write-client.js +178 -49
- package/dist/src/cli/memory/database-provider.js +58 -3
- package/dist/src/cli/memory/intelligence.js +54 -26
- package/dist/src/cli/memory/memory-initializer.js +21 -11
- package/dist/src/cli/services/daemon-dashboard.js +94 -25
- package/dist/src/cli/services/daemon-lock.js +390 -3
- package/dist/src/cli/services/daemon-port.js +217 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- package/dist/src/cli/config-adapter.js +0 -182
|
@@ -16,7 +16,18 @@ import { createServer } from 'node:http';
|
|
|
16
16
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
17
17
|
import { handleMemoryStore, handleMemoryDelete, handleMemoryBatch, handleMemoryGet, handleMemorySearch, handleMemoryList, matchMemoryRpcRoute, } from './daemon-memory-rpc.js';
|
|
18
18
|
import { aggregateClaudeStats, emptyClaudeStatsShape } from './claude-stats.js';
|
|
19
|
-
|
|
19
|
+
import { serverPortCandidates, LEGACY_DEFAULT_PORT } from './daemon-port.js';
|
|
20
|
+
import { writeLockPort } from './daemon-lock.js';
|
|
21
|
+
import { findProjectRoot } from './project-root.js';
|
|
22
|
+
import { readOwnMofloVersion } from './daemon-lock.js';
|
|
23
|
+
/**
|
|
24
|
+
* Legacy default port retained as a re-export of {@link LEGACY_DEFAULT_PORT}
|
|
25
|
+
* for backward compat with existing importers (`commands/daemon.ts`,
|
|
26
|
+
* `__tests__/daemon-dashboard.test.ts`). The actual port a daemon binds is
|
|
27
|
+
* now resolved deterministically per project via `serverPortCandidates()` —
|
|
28
|
+
* see `daemon-port.ts` and `docs/internal/1145-daemon-port-collision-analysis.md`.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_DASHBOARD_PORT = LEGACY_DEFAULT_PORT;
|
|
20
31
|
/**
|
|
21
32
|
* Process-wide promise for the shared MemoryAccessor. Memoized as a *promise*
|
|
22
33
|
* (not the resolved value) so concurrent first-callers share a single init
|
|
@@ -129,6 +140,27 @@ function tryParseSafe(s) {
|
|
|
129
140
|
return s;
|
|
130
141
|
}
|
|
131
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Build the `/api/health` response (#1145).
|
|
145
|
+
*
|
|
146
|
+
* Identity payload — clients compare `projectRoot` against their own
|
|
147
|
+
* `findProjectRoot()` and refuse to route to this daemon on mismatch.
|
|
148
|
+
* Also surfaces `pid`, `version`, and `uptimeMs` for healer-class
|
|
149
|
+
* diagnostics and orphan-daemon detection.
|
|
150
|
+
*
|
|
151
|
+
* Read-only, no-auth, localhost-only (the dashboard binds 127.0.0.1).
|
|
152
|
+
*/
|
|
153
|
+
function handleHealth(daemon, opts) {
|
|
154
|
+
const status = daemon.getStatus();
|
|
155
|
+
const startedAt = status.startedAt instanceof Date ? status.startedAt : null;
|
|
156
|
+
return {
|
|
157
|
+
status: 'ok',
|
|
158
|
+
projectRoot: opts.projectRoot ?? findProjectRoot(),
|
|
159
|
+
pid: status.pid ?? process.pid,
|
|
160
|
+
version: readOwnMofloVersion() ?? null,
|
|
161
|
+
uptimeMs: startedAt ? Date.now() - startedAt.getTime() : 0,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
132
164
|
function handleStatus(daemon) {
|
|
133
165
|
const status = daemon.getStatus();
|
|
134
166
|
// Index config rows by worker type so the row renderer can show a
|
|
@@ -244,15 +276,18 @@ function tryParse(s) {
|
|
|
244
276
|
}
|
|
245
277
|
}
|
|
246
278
|
async function handleMemoryStats() {
|
|
247
|
-
// Single GROUP BY query — no hardcoded namespace list, no row fetching
|
|
248
|
-
try
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
279
|
+
// Single GROUP BY query — no hardcoded namespace list, no row fetching.
|
|
280
|
+
// Errors propagate to the request handler's outer try/catch → 500, so
|
|
281
|
+
// MCP clients see a real failure instead of a silent `totalEntries: 0`.
|
|
282
|
+
const { getNamespaceCounts } = await import('../memory/memory-initializer.js');
|
|
283
|
+
const { namespaces, total, withEmbeddings } = await getNamespaceCounts();
|
|
284
|
+
return {
|
|
285
|
+
ok: true,
|
|
286
|
+
namespaces,
|
|
287
|
+
totalEntries: total,
|
|
288
|
+
withEmbeddings,
|
|
289
|
+
available: total > 0 || Object.keys(namespaces).length > 0,
|
|
290
|
+
};
|
|
256
291
|
}
|
|
257
292
|
/**
|
|
258
293
|
* Build the `/api/claude-stats` response (#1044).
|
|
@@ -433,6 +468,11 @@ async function handleRequest(req, res, daemon, opts) {
|
|
|
433
468
|
if (url === '/') {
|
|
434
469
|
sendHtml(res, DASHBOARD_HTML);
|
|
435
470
|
}
|
|
471
|
+
else if (url === '/api/health') {
|
|
472
|
+
// #1145 — identity probe. Clients use this to confirm they're talking
|
|
473
|
+
// to the daemon for their OWN project before routing memory ops here.
|
|
474
|
+
sendJson(res, 200, handleHealth(daemon, opts));
|
|
475
|
+
}
|
|
436
476
|
else if (url === '/api/status') {
|
|
437
477
|
sendJson(res, 200, handleStatus(daemon));
|
|
438
478
|
}
|
|
@@ -588,33 +628,62 @@ const MAX_PORT_ATTEMPTS = 10;
|
|
|
588
628
|
/**
|
|
589
629
|
* Start the dashboard HTTP server.
|
|
590
630
|
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
631
|
+
* Port selection (#1145):
|
|
632
|
+
* 1. `opts.port`, if explicitly set (CLI `--dashboard-port` flag).
|
|
633
|
+
* 2. Otherwise `serverPortCandidates(projectRoot)` — deterministic per-
|
|
634
|
+
* project port + collision-fallback range.
|
|
635
|
+
* Both honor `MOFLO_DAEMON_PORT` (collapses the candidate list to one).
|
|
636
|
+
*
|
|
637
|
+
* On successful bind the bound port is stamped into `.moflo/daemon.lock`
|
|
638
|
+
* via `writeLockPort()` so clients can discover it without guessing.
|
|
594
639
|
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
640
|
+
* On bind exhaustion (every candidate in use) the server throws — the
|
|
641
|
+
* caller is expected to surface the failure rather than stay half-alive
|
|
642
|
+
* (the silent-trap pattern that produced #1145).
|
|
643
|
+
*
|
|
644
|
+
* @returns handle whose `.port` field reflects the actually bound port
|
|
598
645
|
*/
|
|
599
646
|
export async function startDashboard(daemon, opts) {
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
647
|
+
const projectRoot = opts.projectRoot ?? findProjectRoot();
|
|
648
|
+
const candidates = buildBindCandidates(opts.port, projectRoot, MAX_PORT_ATTEMPTS);
|
|
649
|
+
let lastErr = null;
|
|
650
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
651
|
+
const port = candidates[i];
|
|
603
652
|
try {
|
|
604
|
-
const handle = await tryListenOnPort(daemon, opts, port);
|
|
653
|
+
const handle = await tryListenOnPort(daemon, { ...opts, projectRoot }, port);
|
|
654
|
+
// Stamp the bound port into the lock so clients discover us reliably.
|
|
655
|
+
// Best-effort: a missing/locked-by-another-pid lock means stamping
|
|
656
|
+
// is a no-op — the deterministic fallback still works.
|
|
657
|
+
try {
|
|
658
|
+
writeLockPort(projectRoot, handle.port);
|
|
659
|
+
}
|
|
660
|
+
catch { /* ignore */ }
|
|
605
661
|
return handle;
|
|
606
662
|
}
|
|
607
663
|
catch (err) {
|
|
664
|
+
lastErr = err;
|
|
608
665
|
const code = err && typeof err === 'object' && 'code' in err ? err.code : '';
|
|
609
|
-
if (code === 'EADDRINUSE' &&
|
|
610
|
-
// Port taken — try the next one
|
|
666
|
+
if (code === 'EADDRINUSE' && i < candidates.length - 1)
|
|
611
667
|
continue;
|
|
612
|
-
}
|
|
613
668
|
throw err;
|
|
614
669
|
}
|
|
615
670
|
}
|
|
616
|
-
//
|
|
617
|
-
throw new Error(`All dashboard ports ${
|
|
671
|
+
// Bind exhaustion — surface so the daemon can hard-fail (#1145 §9.4).
|
|
672
|
+
throw lastErr ?? new Error(`All dashboard ports (${candidates[0]}…${candidates[candidates.length - 1]}) are in use`);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Build the ordered list of ports to try.
|
|
676
|
+
*
|
|
677
|
+
* When the caller pinned a port (CLI flag), respect it without any
|
|
678
|
+
* fallback — the consumer pinned it on purpose. When they didn't, use
|
|
679
|
+
* the deterministic per-project candidates so two projects never collide
|
|
680
|
+
* silently on a fixed default.
|
|
681
|
+
*/
|
|
682
|
+
function buildBindCandidates(explicitPort, projectRoot, maxAttempts) {
|
|
683
|
+
if (typeof explicitPort === 'number' && explicitPort > 0 && explicitPort < 65536) {
|
|
684
|
+
return [explicitPort];
|
|
685
|
+
}
|
|
686
|
+
return serverPortCandidates(projectRoot, maxAttempts);
|
|
618
687
|
}
|
|
619
688
|
/**
|
|
620
689
|
* Attempt to bind the dashboard server to a specific port.
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
* and verifying the process command line before trusting a "live" PID.
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from 'fs';
|
|
12
|
-
import { dirname, join } from 'path';
|
|
12
|
+
import { dirname, join, sep } from 'path';
|
|
13
13
|
import { fileURLToPath } from 'url';
|
|
14
|
-
import { execSync } from 'child_process';
|
|
14
|
+
import { execFileSync, execSync } from 'child_process';
|
|
15
|
+
import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
|
|
16
|
+
import { normalizeProjectRoot } from './daemon-port.js';
|
|
15
17
|
const LOCK_FILENAME = 'daemon.lock';
|
|
16
18
|
const LOCK_LABEL = 'moflo-daemon';
|
|
17
19
|
/** Resolve the lock file path for a project root. */
|
|
@@ -48,6 +50,18 @@ export function readOwnMofloVersion() {
|
|
|
48
50
|
/**
|
|
49
51
|
* Try to acquire the daemon lock atomically.
|
|
50
52
|
*
|
|
53
|
+
* Before the EEXIST atomic write, runs a same-project orphan scan (#1150):
|
|
54
|
+
* enumerates moflo daemon processes whose command line is rooted at THIS
|
|
55
|
+
* project's CLI binary. If any are found that the lock doesn't account for,
|
|
56
|
+
* they get SIGTERM'd (3s graceful → SIGKILL) before we try to acquire.
|
|
57
|
+
* Catches the failure mode where the lock was unlinked (e.g. by an old
|
|
58
|
+
* doctor-fix or a crashed shutdown handler) but the daemon process is still
|
|
59
|
+
* alive — without this scan, a fresh daemon would spawn alongside it.
|
|
60
|
+
*
|
|
61
|
+
* Tests can opt out of the scan via `MOFLO_TEST_SKIP_ORPHAN_SCAN=1` (the same
|
|
62
|
+
* env-var also disables the post-spawn fallback in #1086 so vitest workers
|
|
63
|
+
* don't pay the 8s Windows introspection cost on every acquire).
|
|
64
|
+
*
|
|
51
65
|
* @returns `{ acquired: true }` on success,
|
|
52
66
|
* `{ acquired: false, holder: pid }` if another daemon owns the lock.
|
|
53
67
|
*/
|
|
@@ -58,6 +72,14 @@ export function acquireDaemonLock(projectRoot, pid = process.pid) {
|
|
|
58
72
|
if (!fs.existsSync(stateDir)) {
|
|
59
73
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
60
74
|
}
|
|
75
|
+
// #1150 — same-project orphan scan. Runs BEFORE the atomic write because
|
|
76
|
+
// (a) it lets us reclaim the lock after a crash that left the daemon
|
|
77
|
+
// running but the lock unlinked, and
|
|
78
|
+
// (b) the second-spawn case (lock absent, prior daemon alive) is exactly
|
|
79
|
+
// the failure mode that produced two-daemons-per-project in #1145's
|
|
80
|
+
// waxstack audit.
|
|
81
|
+
const lockHolderPid = readLockPayload(lock)?.pid;
|
|
82
|
+
reapSameProjectOrphans(projectRoot, pid, lockHolderPid);
|
|
61
83
|
const payload = {
|
|
62
84
|
pid,
|
|
63
85
|
startedAt: Date.now(),
|
|
@@ -108,6 +130,48 @@ export function releaseDaemonLock(projectRoot, pid = process.pid, force = false)
|
|
|
108
130
|
safeUnlink(lock);
|
|
109
131
|
}
|
|
110
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Stamp the daemon's bound HTTP port into the lock file (#1145).
|
|
135
|
+
*
|
|
136
|
+
* Called by `daemon-dashboard.startDashboard()` after a successful bind so
|
|
137
|
+
* clients can read the actual port (vs. guessing the fixed default and
|
|
138
|
+
* silently hitting another project's daemon).
|
|
139
|
+
*
|
|
140
|
+
* Best-effort by design:
|
|
141
|
+
* - Missing lock → no-op (the daemon didn't acquire the lock; this is
|
|
142
|
+
* a test or unusual startup path).
|
|
143
|
+
* - Lock owned by a different PID → no-op (we don't overwrite locks we
|
|
144
|
+
* don't own).
|
|
145
|
+
* - Write failure → swallowed (the daemon still serves; clients fall
|
|
146
|
+
* back to the deterministic port resolution).
|
|
147
|
+
*
|
|
148
|
+
* Returns `true` on a successful stamp, `false` otherwise. The boolean is
|
|
149
|
+
* informational — production callers don't branch on it.
|
|
150
|
+
*/
|
|
151
|
+
export function writeLockPort(projectRoot, port, pid = process.pid) {
|
|
152
|
+
if (!Number.isFinite(port) || port < 1 || port > 65535)
|
|
153
|
+
return false;
|
|
154
|
+
const lock = lockPath(projectRoot);
|
|
155
|
+
const existing = readLockPayload(lock);
|
|
156
|
+
if (!existing)
|
|
157
|
+
return false;
|
|
158
|
+
if (existing.pid !== pid)
|
|
159
|
+
return false;
|
|
160
|
+
if (existing.port === port)
|
|
161
|
+
return true;
|
|
162
|
+
const updated = { ...existing, port };
|
|
163
|
+
try {
|
|
164
|
+
// Atomic write-then-rename: a client reading mid-write never sees a
|
|
165
|
+
// truncated JSON. The vulnerable window is precisely a re-stamp after
|
|
166
|
+
// a daemon recycle on a different port, when clients are likeliest
|
|
167
|
+
// to be probing the lock for the new port.
|
|
168
|
+
atomicWriteFileSync(lock, JSON.stringify(updated));
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
111
175
|
/**
|
|
112
176
|
* Atomically transfer the daemon lock to a new PID (e.g. parent → child).
|
|
113
177
|
*
|
|
@@ -125,6 +189,9 @@ export function transferDaemonLock(projectRoot, newPid, fromPid = process.pid) {
|
|
|
125
189
|
startedAt: Date.now(),
|
|
126
190
|
label: LOCK_LABEL,
|
|
127
191
|
version: existing.version ?? readOwnMofloVersion(),
|
|
192
|
+
// Preserve the port field across PID transfers (#1145) — the child
|
|
193
|
+
// process inherits the parent's binding, so the port is still valid.
|
|
194
|
+
...(existing.port != null ? { port: existing.port } : {}),
|
|
128
195
|
};
|
|
129
196
|
try {
|
|
130
197
|
// Atomic overwrite — no unlink/recreate gap
|
|
@@ -214,11 +281,33 @@ function safeUnlink(path) {
|
|
|
214
281
|
function isProcessAlive(pid) {
|
|
215
282
|
try {
|
|
216
283
|
process.kill(pid, 0);
|
|
217
|
-
return true;
|
|
218
284
|
}
|
|
219
285
|
catch {
|
|
220
286
|
return false;
|
|
221
287
|
}
|
|
288
|
+
// Linux zombie handling: `kill(pid, 0)` succeeds for zombie processes
|
|
289
|
+
// (exited but not yet reaped). A zombie can't write to the DB or hold
|
|
290
|
+
// a lock, so treating it as alive exhausts the kill window polling a
|
|
291
|
+
// corpse. Read /proc/<pid>/stat and treat 'Z' as dead — same logic as
|
|
292
|
+
// bin/lib/daemon-recycler.mjs:51-69. The case surfaces in tests AND
|
|
293
|
+
// in any production path where the daemon and our process share a
|
|
294
|
+
// parent (foreground mode, vitest worker that spawned a child); on
|
|
295
|
+
// standard detached-daemon production paths init reaps so this is a
|
|
296
|
+
// no-op there.
|
|
297
|
+
if (process.platform === 'linux') {
|
|
298
|
+
try {
|
|
299
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8');
|
|
300
|
+
const lastParen = stat.lastIndexOf(')');
|
|
301
|
+
if (lastParen !== -1 && stat.charAt(lastParen + 2) === 'Z')
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
if (err && err.code === 'ENOENT')
|
|
306
|
+
return false;
|
|
307
|
+
// /proc unavailable — fall through with the kill(0) verdict.
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return true;
|
|
222
311
|
}
|
|
223
312
|
/**
|
|
224
313
|
* Cross-platform check: is this PID actually a moflo/claude-flow daemon?
|
|
@@ -293,4 +382,302 @@ function isDaemonProcessUnix(pid) {
|
|
|
293
382
|
return true; // fallback
|
|
294
383
|
}
|
|
295
384
|
}
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Same-project orphan detection (#1150)
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
/**
|
|
389
|
+
* Enumerate moflo daemon node processes whose command line is rooted at THIS
|
|
390
|
+
* project's CLI binary (consumer install OR dogfood-source).
|
|
391
|
+
*
|
|
392
|
+
* Returns PIDs. Used by `acquireDaemonLock` (pre-acquire reap) and the
|
|
393
|
+
* `daemon-orphan` doctor check/fix.
|
|
394
|
+
*
|
|
395
|
+
* Matching strategy: cmdline must contain BOTH a moflo daemon marker
|
|
396
|
+
* (`daemon ... start` + `moflo`/`claude-flow`) AND one of the two
|
|
397
|
+
* project-rooted cli.js paths. This keeps daemons for OTHER projects out of
|
|
398
|
+
* scope — they have their own project root and (post-#1145) their own port.
|
|
399
|
+
*
|
|
400
|
+
* Cross-platform:
|
|
401
|
+
* - Windows: `Get-CimInstance Win32_Process` via PowerShell (single shell
|
|
402
|
+
* invocation that returns all node processes with command lines).
|
|
403
|
+
* - Linux: `/proc/<pid>/cmdline` walk.
|
|
404
|
+
* - macOS: `ps -axo pid,command` (no `/proc`).
|
|
405
|
+
*
|
|
406
|
+
* Falls back to `[]` if the platform probe fails — better to spawn an extra
|
|
407
|
+
* daemon than to wrongly kill a foreign-project one.
|
|
408
|
+
*/
|
|
409
|
+
export function findProjectDaemonPids(projectRoot, opts = {}) {
|
|
410
|
+
if (process.env.MOFLO_TEST_SKIP_ORPHAN_SCAN === '1')
|
|
411
|
+
return [];
|
|
412
|
+
const candidates = projectCliCandidates(projectRoot);
|
|
413
|
+
if (candidates.length === 0)
|
|
414
|
+
return [];
|
|
415
|
+
let processes;
|
|
416
|
+
if (opts.pidsHint) {
|
|
417
|
+
processes = opts.pidsHint;
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
try {
|
|
421
|
+
if (process.platform === 'win32')
|
|
422
|
+
processes = listMofloDaemonsWindows();
|
|
423
|
+
else if (process.platform === 'linux')
|
|
424
|
+
processes = listMofloDaemonsLinux();
|
|
425
|
+
else
|
|
426
|
+
processes = listMofloDaemonsUnix();
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return processes.filter(p => cmdlineMatchesProject(p.cmdline, candidates)).map(p => p.pid);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Candidate absolute paths for THIS project's daemon CLI binary.
|
|
436
|
+
*
|
|
437
|
+
* Returns the two layouts moflo ships with — consumer install
|
|
438
|
+
* (`<root>/node_modules/moflo/bin/cli.js`) and dogfood-source
|
|
439
|
+
* (`<root>/bin/cli.js`) — normalised for case-insensitive substring match
|
|
440
|
+
* via the shared `normalizeProjectRoot` helper (which realpaths + lowercases
|
|
441
|
+
* on Windows, matching the #1145 daemon-identity surface so the two checks
|
|
442
|
+
* agree about which root a process belongs to).
|
|
443
|
+
*
|
|
444
|
+
* Never includes the bare `projectRoot` prefix as a match candidate: an
|
|
445
|
+
* unrelated process (editor, npm script) whose cmdline happens to mention
|
|
446
|
+
* the project path would otherwise false-positive once the daemon-marker
|
|
447
|
+
* regex also incidentally matched.
|
|
448
|
+
*/
|
|
449
|
+
function projectCliCandidates(projectRoot) {
|
|
450
|
+
const cliRelatives = [
|
|
451
|
+
join('node_modules', 'moflo', 'bin', 'cli.js'),
|
|
452
|
+
join('bin', 'cli.js'),
|
|
453
|
+
];
|
|
454
|
+
// realpath both the input AND each candidate path — on macOS the
|
|
455
|
+
// command-line records the realpath'd form (`/private/var/folders/...`)
|
|
456
|
+
// while the cwd-rooted candidate stays under `/var/folders/...`.
|
|
457
|
+
const normRoot = normalizeProjectRoot(projectRoot);
|
|
458
|
+
const out = new Set();
|
|
459
|
+
for (const rel of cliRelatives) {
|
|
460
|
+
// Apply normalizeForMatch ON TOP of normalizeProjectRoot so the
|
|
461
|
+
// substring match also tolerates mixed separators in the spawn-recorded
|
|
462
|
+
// cmdline ("\\" vs "/"). `normalizeProjectRoot` realpaths + lowercases
|
|
463
|
+
// on Windows; `normalizeForMatch` collapses slashes.
|
|
464
|
+
out.add(normalizeForMatch(normalizeProjectRoot(join(projectRoot, rel))));
|
|
465
|
+
out.add(normalizeForMatch(normalizeProjectRoot(join(normRoot, rel))));
|
|
466
|
+
}
|
|
467
|
+
return Array.from(out).filter(s => s.length > 0);
|
|
468
|
+
}
|
|
469
|
+
function cmdlineMatchesProject(cmdline, candidates) {
|
|
470
|
+
// Daemon marker — must look like a moflo daemon to even consider matching.
|
|
471
|
+
if (!/daemon[\s\S]{0,40}start/i.test(cmdline))
|
|
472
|
+
return false;
|
|
473
|
+
if (!/moflo|claude-flow/i.test(cmdline))
|
|
474
|
+
return false;
|
|
475
|
+
// Substring match against case-folded, slash-normalised forms.
|
|
476
|
+
const norm = normalizeForMatch(cmdline);
|
|
477
|
+
return candidates.some(c => c.length > 0 && norm.includes(c));
|
|
478
|
+
}
|
|
479
|
+
function normalizeForMatch(p) {
|
|
480
|
+
// Collapse mixed slashes to the OS separator so the substring check works
|
|
481
|
+
// regardless of how spawn quoted the path. Case-fold on Windows.
|
|
482
|
+
const collapsed = p.replace(/[\\/]+/g, sep);
|
|
483
|
+
return process.platform === 'win32' ? collapsed.toLowerCase() : collapsed;
|
|
484
|
+
}
|
|
485
|
+
function listMofloDaemonsWindows() {
|
|
486
|
+
// Use execFileSync so the PS command is passed as a single argument vector
|
|
487
|
+
// (no cmd.exe quote-mangling). The `@($res)` array-cast handles the
|
|
488
|
+
// single-result case (`ConvertTo-Json` emits a bare object otherwise, and
|
|
489
|
+
// `-AsArray` is PS 6+ only). The `if ($res)` guard avoids emitting an
|
|
490
|
+
// empty string that JSON.parse can't read.
|
|
491
|
+
const script = "$res = Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" " +
|
|
492
|
+
"| Select-Object ProcessId, CommandLine; " +
|
|
493
|
+
"if ($res) { @($res) | ConvertTo-Json -Compress -Depth 3 }";
|
|
494
|
+
let raw;
|
|
495
|
+
try {
|
|
496
|
+
raw = execFileSync('powershell', ['-NoProfile', '-Command', script], {
|
|
497
|
+
encoding: 'utf-8',
|
|
498
|
+
timeout: 10000,
|
|
499
|
+
windowsHide: true,
|
|
500
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return [];
|
|
505
|
+
}
|
|
506
|
+
if (!raw.trim())
|
|
507
|
+
return [];
|
|
508
|
+
let parsed;
|
|
509
|
+
try {
|
|
510
|
+
parsed = JSON.parse(raw);
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return [];
|
|
514
|
+
}
|
|
515
|
+
if (!Array.isArray(parsed))
|
|
516
|
+
parsed = [parsed];
|
|
517
|
+
return parsed
|
|
518
|
+
.filter((p) => p && typeof p.CommandLine === 'string' && p.CommandLine.length > 0)
|
|
519
|
+
.map((p) => ({ pid: Number(p.ProcessId), cmdline: String(p.CommandLine) }))
|
|
520
|
+
.filter((p) => Number.isFinite(p.pid) && p.pid > 0);
|
|
521
|
+
}
|
|
522
|
+
function listMofloDaemonsLinux() {
|
|
523
|
+
const out = [];
|
|
524
|
+
let entries;
|
|
525
|
+
try {
|
|
526
|
+
entries = fs.readdirSync('/proc');
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
return [];
|
|
530
|
+
}
|
|
531
|
+
for (const entry of entries) {
|
|
532
|
+
if (!/^\d+$/.test(entry))
|
|
533
|
+
continue;
|
|
534
|
+
const pid = parseInt(entry, 10);
|
|
535
|
+
try {
|
|
536
|
+
// cmdline is NUL-separated argv. Replace NULs with spaces for matching.
|
|
537
|
+
const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8');
|
|
538
|
+
if (!raw)
|
|
539
|
+
continue;
|
|
540
|
+
const cmdline = raw.replace(/\0+$/, '').replace(/\0/g, ' ');
|
|
541
|
+
if (!/\bnode\b/i.test(cmdline) && !/\.js\b/.test(cmdline))
|
|
542
|
+
continue;
|
|
543
|
+
out.push({ pid, cmdline });
|
|
544
|
+
}
|
|
545
|
+
catch { /* process exited mid-scan / no perms — skip */ }
|
|
546
|
+
}
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
function listMofloDaemonsUnix() {
|
|
550
|
+
let raw;
|
|
551
|
+
try {
|
|
552
|
+
// -axww = all processes including session leaders (BSD form portable to
|
|
553
|
+
// macOS/Linux), unlimited line width so long cmdlines don't truncate.
|
|
554
|
+
// execFileSync (no shell) keeps quoting consistent with the rest of the
|
|
555
|
+
// codebase.
|
|
556
|
+
raw = execFileSync('ps', ['-axww', '-o', 'pid=,command='], {
|
|
557
|
+
encoding: 'utf-8',
|
|
558
|
+
timeout: 5000,
|
|
559
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return [];
|
|
564
|
+
}
|
|
565
|
+
const out = [];
|
|
566
|
+
for (const line of raw.split('\n')) {
|
|
567
|
+
const trimmed = line.trim();
|
|
568
|
+
if (!trimmed)
|
|
569
|
+
continue;
|
|
570
|
+
const sepIdx = trimmed.indexOf(' ');
|
|
571
|
+
if (sepIdx === -1)
|
|
572
|
+
continue;
|
|
573
|
+
const pid = parseInt(trimmed.slice(0, sepIdx), 10);
|
|
574
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
575
|
+
continue;
|
|
576
|
+
const cmdline = trimmed.slice(sepIdx + 1).trim();
|
|
577
|
+
if (!/\bnode\b/i.test(cmdline) && !/\.js\b/.test(cmdline))
|
|
578
|
+
continue;
|
|
579
|
+
out.push({ pid, cmdline });
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Same-project orphan reap. Called from `acquireDaemonLock` BEFORE the atomic
|
|
585
|
+
* write. PIDs that match the lock-holder OR our own process are skipped.
|
|
586
|
+
*
|
|
587
|
+
* Best-effort: failures during kill are swallowed because the next step
|
|
588
|
+
* (atomic exclusive write of the lock) is the source of truth — if the
|
|
589
|
+
* orphan survives, the lock-acquire still fails cleanly and the caller
|
|
590
|
+
* reports a stale lock-holder rather than spawning a duplicate.
|
|
591
|
+
*
|
|
592
|
+
* Exported for the `Daemon Orphan` healer fix which reuses the same logic.
|
|
593
|
+
*/
|
|
594
|
+
export function reapSameProjectOrphans(projectRoot, ownPid = process.pid, lockHolderPid,
|
|
595
|
+
/**
|
|
596
|
+
* Pre-computed project-daemon PIDs. Skips re-running the OS process scan
|
|
597
|
+
* when the caller already has them — the `Daemon Orphan` doctor-fix
|
|
598
|
+
* computes them once via `findProjectDaemonPids` and then reuses the
|
|
599
|
+
* same list here.
|
|
600
|
+
*/
|
|
601
|
+
pidsHint) {
|
|
602
|
+
const reaped = [];
|
|
603
|
+
const survived = [];
|
|
604
|
+
const allPids = pidsHint ?? findProjectDaemonPids(projectRoot);
|
|
605
|
+
const foreignPids = allPids.filter(p => {
|
|
606
|
+
if (p === ownPid)
|
|
607
|
+
return false;
|
|
608
|
+
if (lockHolderPid != null && p === lockHolderPid)
|
|
609
|
+
return false;
|
|
610
|
+
return true;
|
|
611
|
+
});
|
|
612
|
+
if (foreignPids.length === 0)
|
|
613
|
+
return { reaped, survived };
|
|
614
|
+
for (const pid of foreignPids) {
|
|
615
|
+
if (terminateOrphan(pid))
|
|
616
|
+
reaped.push(pid);
|
|
617
|
+
else
|
|
618
|
+
survived.push(pid);
|
|
619
|
+
}
|
|
620
|
+
return { reaped, survived };
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Terminate a same-project daemon orphan: SIGTERM → 3s graceful poll →
|
|
624
|
+
* SIGKILL (POSIX) / `taskkill /F /T` (Windows). Returns true once the PID
|
|
625
|
+
* is no longer alive.
|
|
626
|
+
*/
|
|
627
|
+
function terminateOrphan(pid) {
|
|
628
|
+
if (!isProcessAlive(pid))
|
|
629
|
+
return true;
|
|
630
|
+
try {
|
|
631
|
+
if (process.platform === 'win32') {
|
|
632
|
+
// No SIGTERM equivalent for our detached Node daemon on Windows — go
|
|
633
|
+
// straight to /F /T (same shape as bin/lib/daemon-recycler.mjs and
|
|
634
|
+
// killBackgroundDaemon). execFileSync keeps args un-shell-quoted.
|
|
635
|
+
try {
|
|
636
|
+
execFileSync('taskkill', ['/F', '/T', '/PID', String(pid)], {
|
|
637
|
+
windowsHide: true,
|
|
638
|
+
timeout: 3000,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
catch { /* already exiting */ }
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
try {
|
|
645
|
+
process.kill(pid, 'SIGTERM');
|
|
646
|
+
}
|
|
647
|
+
catch { /* already dead */ }
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
catch { /* fall through to liveness poll */ }
|
|
651
|
+
// Graceful window
|
|
652
|
+
const gracefulDeadline = Date.now() + 3000;
|
|
653
|
+
while (Date.now() < gracefulDeadline && isProcessAlive(pid)) {
|
|
654
|
+
sleepSyncMs(100);
|
|
655
|
+
}
|
|
656
|
+
if (!isProcessAlive(pid))
|
|
657
|
+
return true;
|
|
658
|
+
// Escalate to SIGKILL on POSIX (Windows already used /F)
|
|
659
|
+
if (process.platform !== 'win32') {
|
|
660
|
+
try {
|
|
661
|
+
process.kill(pid, 'SIGKILL');
|
|
662
|
+
}
|
|
663
|
+
catch { /* already dead */ }
|
|
664
|
+
}
|
|
665
|
+
const killDeadline = Date.now() + 1000;
|
|
666
|
+
while (Date.now() < killDeadline && isProcessAlive(pid)) {
|
|
667
|
+
sleepSyncMs(100);
|
|
668
|
+
}
|
|
669
|
+
return !isProcessAlive(pid);
|
|
670
|
+
}
|
|
671
|
+
function sleepSyncMs(ms) {
|
|
672
|
+
try {
|
|
673
|
+
const buf = new Int32Array(new SharedArrayBuffer(4));
|
|
674
|
+
Atomics.wait(buf, 0, 0, ms);
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
// SharedArrayBuffer disabled (rare — exotic Node flags); fall back to a
|
|
678
|
+
// tight loop. Caller's wait windows are bounded so this is safe.
|
|
679
|
+
const deadline = Date.now() + ms;
|
|
680
|
+
while (Date.now() < deadline) { /* spin */ }
|
|
681
|
+
}
|
|
682
|
+
}
|
|
296
683
|
//# sourceMappingURL=daemon-lock.js.map
|