moflo 4.12.2 → 4.12.3
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/bin/hooks.mjs +5 -2
- package/bin/index-all.mjs +38 -0
- package/bin/lib/moflo-paths.mjs +37 -1
- package/bin/lib/process-manager.mjs +16 -1
- package/bin/lib/registry-cleanup.cjs +9 -1
- package/bin/session-start-launcher.mjs +5 -2
- package/dist/src/cli/commands/agent.js +7 -2
- package/dist/src/cli/commands/benchmark.js +5 -2
- package/dist/src/cli/commands/daemon.js +81 -9
- package/dist/src/cli/commands/doctor-checks-config.js +55 -19
- package/dist/src/cli/commands/doctor-checks-memory.js +3 -2
- package/dist/src/cli/commands/doctor-checks-writers-audit.js +5 -1
- package/dist/src/cli/commands/doctor-embedding-hygiene.js +2 -1
- package/dist/src/cli/commands/doctor-fixes.js +20 -5
- package/dist/src/cli/commands/doctor-zombies.js +4 -3
- package/dist/src/cli/commands/embeddings.js +10 -5
- package/dist/src/cli/commands/hooks.js +11 -8
- package/dist/src/cli/commands/swarm.js +2 -1
- package/dist/src/cli/index.js +18 -6
- package/dist/src/cli/memory/bridge-core.js +8 -2
- package/dist/src/cli/memory/daemon-write-client.js +5 -5
- package/dist/src/cli/memory/database-provider.js +14 -5
- package/dist/src/cli/memory/entries-read.js +5 -4
- package/dist/src/cli/memory/entries-write.js +3 -2
- package/dist/src/cli/memory/hnsw-singleton.js +2 -1
- package/dist/src/cli/memory/init.js +14 -8
- package/dist/src/cli/memory/learnings-overview.js +2 -1
- package/dist/src/cli/runtime/headless.js +8 -3
- package/dist/src/cli/services/daemon-dashboard.js +4 -4
- package/dist/src/cli/services/embeddings-migration.js +2 -1
- package/dist/src/cli/services/ephemeral-namespace-purge.js +3 -2
- package/dist/src/cli/services/project-root.js +108 -1
- package/dist/src/cli/services/soft-delete-purge.js +2 -1
- package/dist/src/cli/services/worker-daemon.js +67 -1
- package/dist/src/cli/version.js +1 -1
- package/package.json +8 -7
package/bin/hooks.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { fileURLToPath, pathToFileURL } from 'url';
|
|
|
26
26
|
import { createProcessManager } from './lib/process-manager.mjs';
|
|
27
27
|
import { shouldDaemonAutoStart } from './lib/daemon-config.mjs';
|
|
28
28
|
import { resolveMofloBin } from './lib/resolve-bin.mjs';
|
|
29
|
-
import {
|
|
29
|
+
import { resolveStateRoot } from './lib/moflo-paths.mjs';
|
|
30
30
|
|
|
31
31
|
const __filename = fileURLToPath(import.meta.url);
|
|
32
32
|
const __dirname = dirname(__filename);
|
|
@@ -35,7 +35,10 @@ const __dirname = dirname(__filename);
|
|
|
35
35
|
// in bin/ during development but gets synced to .claude/scripts/ in consumer
|
|
36
36
|
// projects, so __dirname-relative paths break. findProjectRoot() works
|
|
37
37
|
// everywhere and resolves identically to the TS bridge (see lib/moflo-paths.mjs).
|
|
38
|
-
|
|
38
|
+
// #1315 — resolveStateRoot, NOT findProjectRoot: this layer mkdirs
|
|
39
|
+
// `.moflo/` state, and findProjectRoot honors CLAUDE_PROJECT_DIR verbatim,
|
|
40
|
+
// so a sub-workspace-rooted session minted an island here every start.
|
|
41
|
+
const projectRoot = resolveStateRoot();
|
|
39
42
|
const logFile = resolve(projectRoot, '.moflo', 'logs', 'hooks.log');
|
|
40
43
|
try { mkdirSync(dirname(logFile), { recursive: true }); } catch { /* best effort */ }
|
|
41
44
|
const pm = createProcessManager(projectRoot);
|
package/bin/index-all.mjs
CHANGED
|
@@ -111,6 +111,41 @@ function killProcessTree(child) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// The step currently in flight, so a termination signal can reap it (#1317).
|
|
115
|
+
let currentChild = null;
|
|
116
|
+
let shuttingDown = false;
|
|
117
|
+
|
|
118
|
+
// Reap on SIGTERM/SIGINT (#1317). hooks.mjs session-end calls pm.killAll(),
|
|
119
|
+
// which SIGTERMs THIS process. Without a handler Node's default action kills us
|
|
120
|
+
// instantly and the in-flight step is orphaned: it lives in its OWN process
|
|
121
|
+
// group (detached:true in runStep, below), so the signal aimed at us never
|
|
122
|
+
// reaches it, and no group-kill of our group can either. It reparents to init
|
|
123
|
+
// and keeps burning CPU and writing .moflo/moflo.db after the session is gone —
|
|
124
|
+
// build-embeddings most often, the same ~2 GB fastembed process as #744.
|
|
125
|
+
// killProcessTree is the teardown primitive #744 already built; it was simply
|
|
126
|
+
// never wired to the signal path.
|
|
127
|
+
function handleTerminationSignal(signal) {
|
|
128
|
+
if (shuttingDown) return; // second signal while tearing down — ignore
|
|
129
|
+
shuttingDown = true;
|
|
130
|
+
log(`SIGNAL ${signal} — reaping in-flight step before exit`);
|
|
131
|
+
// Synchronous on both platforms: process.kill delivers before returning,
|
|
132
|
+
// spawnSync('taskkill') blocks. Safe to process.exit() immediately after.
|
|
133
|
+
killProcessTree(currentChild);
|
|
134
|
+
currentChild = null;
|
|
135
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 'SIGTERM' is never delivered on Windows — Node permits the listener but it
|
|
139
|
+
// cannot fire, and Windows is already correct via taskkill /T. Registering it
|
|
140
|
+
// unconditionally is therefore harmless, not a portability assumption.
|
|
141
|
+
// 'SIGBREAK' is the Windows-only console-termination signal.
|
|
142
|
+
for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
143
|
+
try { process.on(sig, () => handleTerminationSignal(sig)); } catch { /* signal unsupported here */ }
|
|
144
|
+
}
|
|
145
|
+
if (platform() === 'win32') {
|
|
146
|
+
try { process.on('SIGBREAK', () => handleTerminationSignal('SIGBREAK')); } catch { /* ignore */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
114
149
|
function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
|
|
115
150
|
return new Promise((resolveStep) => {
|
|
116
151
|
const start = Date.now();
|
|
@@ -122,6 +157,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
|
|
|
122
157
|
detached: platform() !== 'win32', // POSIX: own process group for tree-kill
|
|
123
158
|
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
|
124
159
|
});
|
|
160
|
+
currentChild = child;
|
|
125
161
|
let timedOut = false;
|
|
126
162
|
const timer = setTimeout(() => {
|
|
127
163
|
timedOut = true;
|
|
@@ -129,6 +165,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
|
|
|
129
165
|
}, timeoutMs);
|
|
130
166
|
child.once('exit', (code, signal) => {
|
|
131
167
|
clearTimeout(timer);
|
|
168
|
+
if (currentChild === child) currentChild = null;
|
|
132
169
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
133
170
|
if (timedOut) {
|
|
134
171
|
log(`FAIL ${label} (${elapsed}s): timed out after ${timeoutMs}ms, child killed`);
|
|
@@ -143,6 +180,7 @@ function runStep(label, cmd, args, timeoutMs = 120_000, extraEnv = null) {
|
|
|
143
180
|
});
|
|
144
181
|
child.once('error', (err) => {
|
|
145
182
|
clearTimeout(timer);
|
|
183
|
+
if (currentChild === child) currentChild = null;
|
|
146
184
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
147
185
|
log(`FAIL ${label} (${elapsed}s): ${err.message?.split('\n')[0] || 'unknown'}`);
|
|
148
186
|
resolveStep(false);
|
package/bin/lib/moflo-paths.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* helpers no longer ship: the version-bump-gated cherry-pick lives entirely
|
|
13
13
|
* in the launcher and the TS service `cli/services/cherry-pick-learnings.ts`.
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync } from 'node:fs';
|
|
15
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
16
16
|
import { basename, dirname, join, parse, resolve } from 'node:path';
|
|
17
17
|
|
|
18
18
|
export const MOFLO_DIR = '.moflo';
|
|
@@ -127,6 +127,42 @@ export function findAncestorMofloRoot(dir) {
|
|
|
127
127
|
* @param {{ cwd?: string; honorEnv?: boolean }} [opts]
|
|
128
128
|
* @returns {string} absolute project root
|
|
129
129
|
*/
|
|
130
|
+
/**
|
|
131
|
+
* Pure-JS twin of `src/cli/services/project-root.ts:resolveStateRoot()` (#1315).
|
|
132
|
+
*
|
|
133
|
+
* Use this — never `findProjectRoot()` — anywhere the bin/hook layer is about
|
|
134
|
+
* to CREATE `.moflo/` state. `findProjectRoot` returns `CLAUDE_PROJECT_DIR`
|
|
135
|
+
* verbatim, and Claude Code sets that to the SESSION directory; in a monorepo
|
|
136
|
+
* session rooted at a sub-workspace the launcher therefore mkdir'd a `.moflo/`
|
|
137
|
+
* island there every session. That made the healer's nested-island fix a
|
|
138
|
+
* perpetual loop: archive it, next session re-mints it.
|
|
139
|
+
*
|
|
140
|
+
* Semantics (kept in lockstep with the TS side): an EXISTING
|
|
141
|
+
* `CLAUDE_PROJECT_DIR` wins outright and is never climbed above — the marker
|
|
142
|
+
* walk has no upper bound, so climbing past an explicit anchor would let a
|
|
143
|
+
* stray ancestor `.moflo/moflo.db` capture every project beneath it. Otherwise
|
|
144
|
+
* walk up from cwd and take the topmost marker, which is what stops a
|
|
145
|
+
* sub-directory invocation from minting an island where it stands. A value
|
|
146
|
+
* naming a directory that does not exist is ignored rather than materialized.
|
|
147
|
+
*
|
|
148
|
+
* @param {{ cwd?: string }} [opts]
|
|
149
|
+
* @returns {string} absolute project root
|
|
150
|
+
*/
|
|
151
|
+
export function resolveStateRoot(opts) {
|
|
152
|
+
const envDir = process.env.CLAUDE_PROJECT_DIR;
|
|
153
|
+
const envAbs = envDir ? resolve(envDir) : undefined;
|
|
154
|
+
const resolved = envAbs && existsSync(envAbs)
|
|
155
|
+
? envAbs
|
|
156
|
+
: findProjectRoot({ honorEnv: false, cwd: opts?.cwd });
|
|
157
|
+
// Canonicalize so this compares equal to the realpath'd root the TS side
|
|
158
|
+
// derives (macOS /var → /private/var). Falls back when the path is absent.
|
|
159
|
+
try {
|
|
160
|
+
return realpathSync(resolve(resolved));
|
|
161
|
+
} catch {
|
|
162
|
+
return resolve(resolved);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
130
166
|
export function findProjectRoot(opts) {
|
|
131
167
|
const honorEnv = opts?.honorEnv !== false;
|
|
132
168
|
if (honorEnv && process.env.CLAUDE_PROJECT_DIR) {
|
|
@@ -219,7 +219,22 @@ export function createProcessManager(root) {
|
|
|
219
219
|
if (process.platform === 'win32') {
|
|
220
220
|
execFileSync('taskkill', ['/T', '/F', '/PID', String(entry.pid)], { windowsHide: true, timeout: 10000 });
|
|
221
221
|
} else {
|
|
222
|
-
|
|
222
|
+
// POSIX tree-kill (#1317). Windows walks the tree via taskkill /T;
|
|
223
|
+
// a bare SIGTERM here only hit the one PID, so anything the tracked
|
|
224
|
+
// process left in its group survived session-end. Everything spawn()
|
|
225
|
+
// launches is detached (setsid), so the tracked PID leads its own
|
|
226
|
+
// group and the negative PID reaches those children.
|
|
227
|
+
//
|
|
228
|
+
// NOTE this does NOT reach grandchildren that called setsid again —
|
|
229
|
+
// index-all's steps do exactly that, which is why index-all reaps
|
|
230
|
+
// them itself on SIGTERM. This is defence in depth, not the whole fix.
|
|
231
|
+
try {
|
|
232
|
+
process.kill(-entry.pid, 'SIGTERM');
|
|
233
|
+
} catch {
|
|
234
|
+
// No such process group — e.g. an entry registered by
|
|
235
|
+
// registerBackgroundPid() whose process doesn't lead one.
|
|
236
|
+
process.kill(entry.pid, 'SIGTERM');
|
|
237
|
+
}
|
|
223
238
|
}
|
|
224
239
|
killed++;
|
|
225
240
|
} catch {
|
|
@@ -32,7 +32,15 @@ function killTrackedSync(projectDir) {
|
|
|
32
32
|
if (process.platform === 'win32') {
|
|
33
33
|
childProcess.execFileSync('taskkill', ['/T', '/F', '/PID', String(entries[i].pid)], { windowsHide: true, timeout: 5000 });
|
|
34
34
|
} else {
|
|
35
|
-
process.
|
|
35
|
+
// POSIX tree-kill — mirrors process-manager.mjs killAll (#1317).
|
|
36
|
+
// This is the second reaper over the same registry (session-end via
|
|
37
|
+
// hook-handler.cjs), so it must not be left behind on the bare-PID
|
|
38
|
+
// kill or session-end orphans depend on which path ran.
|
|
39
|
+
try {
|
|
40
|
+
process.kill(-entries[i].pid, 'SIGTERM');
|
|
41
|
+
} catch (groupErr) {
|
|
42
|
+
process.kill(entries[i].pid, 'SIGTERM');
|
|
43
|
+
}
|
|
36
44
|
}
|
|
37
45
|
killed++;
|
|
38
46
|
} catch (e) { /* ok */ }
|
|
@@ -11,7 +11,7 @@ import { spawn, execFileSync } from 'child_process';
|
|
|
11
11
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, mkdirSync, statSync } from 'fs';
|
|
12
12
|
import { resolve, dirname, join } from 'path';
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
14
|
-
import { mofloDir,
|
|
14
|
+
import { mofloDir, resolveStateRoot, findAncestorMofloRoot, COMMON_WALK_SKIP_NAMES } from './lib/moflo-paths.mjs';
|
|
15
15
|
import { repairMemoryDbIfCorrupt } from './lib/db-repair.mjs';
|
|
16
16
|
import { resolveMofloBin } from './lib/resolve-bin.mjs';
|
|
17
17
|
import { applyRetiredPrune, writeRetainedRecord, reconcileRetainedRecord, RETAINED_RECORD_REL } from './lib/retired-files.mjs';
|
|
@@ -62,7 +62,10 @@ function sessionStartMirrorHeader(file) {
|
|
|
62
62
|
// first, then walk up for .moflo/moflo.db / .swarm/memory.db / CLAUDE.md+pkg /
|
|
63
63
|
// package.json / .git. Inline walks here have caused N writers to land on
|
|
64
64
|
// different DBs than the bridge reads from — never reintroduce one.
|
|
65
|
-
|
|
65
|
+
// #1315 — resolveStateRoot, NOT findProjectRoot: this layer mkdirs
|
|
66
|
+
// `.moflo/` state, and findProjectRoot honors CLAUDE_PROJECT_DIR verbatim,
|
|
67
|
+
// so a sub-workspace-rooted session minted an island here every start.
|
|
68
|
+
const projectRoot = resolveStateRoot();
|
|
66
69
|
|
|
67
70
|
// Monorepo nested-.moflo guard (#1174). After the resolver fix, findProjectRoot
|
|
68
71
|
// returns the topmost ancestor with .moflo/moflo.db, so an ancestor still
|
|
@@ -8,6 +8,7 @@ import { select, confirm, input } from '../prompt.js';
|
|
|
8
8
|
import { callMCPTool, MCPClientError } from '../mcp-client.js';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
+
import { resolveStateRoot } from '../services/project-root.js';
|
|
11
12
|
/**
|
|
12
13
|
* Update swarm-activity.json metrics with absolute agent count.
|
|
13
14
|
* The statusline reads this file to display the swarm agent count.
|
|
@@ -17,7 +18,10 @@ import * as path from 'path';
|
|
|
17
18
|
*/
|
|
18
19
|
function syncSwarmAgentCount(absoluteCount) {
|
|
19
20
|
try {
|
|
20
|
-
|
|
21
|
+
// #1315 — resolved root, not cwd. This mkdirs, so a subdirectory
|
|
22
|
+
// invocation minted a `.moflo/metrics/` island; and the statusline reads
|
|
23
|
+
// this file from the ROOT, so a cwd-anchored write was invisible to it.
|
|
24
|
+
const metricsDir = path.join(resolveStateRoot(), '.moflo', 'metrics');
|
|
21
25
|
const activityPath = path.join(metricsDir, 'swarm-activity.json');
|
|
22
26
|
let data = {
|
|
23
27
|
timestamp: new Date().toISOString(),
|
|
@@ -48,7 +52,8 @@ function syncSwarmAgentCount(absoluteCount) {
|
|
|
48
52
|
*/
|
|
49
53
|
function readSwarmAgentCount() {
|
|
50
54
|
try {
|
|
51
|
-
|
|
55
|
+
// #1315 — must read from the same root `syncSwarmAgentCount` writes to.
|
|
56
|
+
const activityPath = path.join(resolveStateRoot(), '.moflo', 'metrics', 'swarm-activity.json');
|
|
52
57
|
if (!fs.existsSync(activityPath))
|
|
53
58
|
return 0;
|
|
54
59
|
const data = JSON.parse(fs.readFileSync(activityPath, 'utf-8'));
|
|
@@ -8,6 +8,9 @@ import { output } from '../output.js';
|
|
|
8
8
|
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
11
|
+
// #1315 — benchmark results are `.moflo/` state; anchor them at the resolved
|
|
12
|
+
// root so a subdirectory run doesn't mint an island to save into.
|
|
13
|
+
import { resolveStateRoot } from '../services/project-root.js';
|
|
11
14
|
// ============================================================================
|
|
12
15
|
// Pretrain Benchmark Subcommand
|
|
13
16
|
// ============================================================================
|
|
@@ -46,7 +49,7 @@ const pretrainCommand = {
|
|
|
46
49
|
}
|
|
47
50
|
// Save to file if requested
|
|
48
51
|
if (saveFile) {
|
|
49
|
-
const resultsDir = join(
|
|
52
|
+
const resultsDir = join(resolveStateRoot(), '.moflo', 'benchmarks');
|
|
50
53
|
if (!existsSync(resultsDir)) {
|
|
51
54
|
mkdirSync(resultsDir, { recursive: true });
|
|
52
55
|
}
|
|
@@ -397,7 +400,7 @@ const allCommand = {
|
|
|
397
400
|
// Save if requested
|
|
398
401
|
const saveFile = ctx.flags.save;
|
|
399
402
|
if (saveFile) {
|
|
400
|
-
const resultsDir = join(
|
|
403
|
+
const resultsDir = join(resolveStateRoot(), '.moflo', 'benchmarks');
|
|
401
404
|
if (!existsSync(resultsDir)) {
|
|
402
405
|
mkdirSync(resultsDir, { recursive: true });
|
|
403
406
|
}
|
|
@@ -11,10 +11,11 @@ import { startDashboard, createDashboardMemoryAccessor, DEFAULT_DASHBOARD_PORT }
|
|
|
11
11
|
import { bootstrapDaemonScheduler } from '../services/daemon-scheduler-bootstrap.js';
|
|
12
12
|
import { loadMofloConfig } from '../config/moflo-config.js';
|
|
13
13
|
import { spawn, execFileSync } from 'child_process';
|
|
14
|
-
import { join, resolve } from 'path';
|
|
14
|
+
import { join, resolve, isAbsolute } from 'path';
|
|
15
15
|
import * as fs from 'fs';
|
|
16
16
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
17
17
|
import { readMofloEnv } from '../services/env-compat.js';
|
|
18
|
+
import { resolveStateRoot } from '../services/project-root.js';
|
|
18
19
|
/**
|
|
19
20
|
* Resolve the dashboard port from CLI flag and env, in that precedence order.
|
|
20
21
|
*
|
|
@@ -68,7 +69,13 @@ const startCommand = {
|
|
|
68
69
|
const foreground = ctx.flags.foreground;
|
|
69
70
|
const noDashboard = ctx.flags.noDashboard;
|
|
70
71
|
const rawDashboardPort = ctx.flags.dashboardPort;
|
|
71
|
-
|
|
72
|
+
// #1315 — the shared chokepoint. Every daemon-start path lands here:
|
|
73
|
+
// `maybeAutoStartDaemon`, the session-start launcher, bin/hooks.mjs, the
|
|
74
|
+
// daemon recycler, the scheduler, and the OS service definitions. Each one
|
|
75
|
+
// spawns with a cwd it inherited from the caller, so anchoring on
|
|
76
|
+
// `process.cwd()` let ANY of them mint a `.moflo/` island in a
|
|
77
|
+
// sub-workspace while resolving its own binary from the root install.
|
|
78
|
+
const projectRoot = resolveStateRoot();
|
|
72
79
|
const isDaemonProcess = readMofloEnv('DAEMON') === '1';
|
|
73
80
|
// Resolve dashboard port; see `resolveDashboardPort` for precedence.
|
|
74
81
|
const portResult = resolveDashboardPort(rawDashboardPort, process.env.MOFLO_DAEMON_PORT);
|
|
@@ -314,13 +321,35 @@ function validatePath(path, label) {
|
|
|
314
321
|
}
|
|
315
322
|
}
|
|
316
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Validate the resolved project root (#1315).
|
|
326
|
+
*
|
|
327
|
+
* Separate from `validatePath` because containment is meaningless for the root
|
|
328
|
+
* itself — it IS the boundary every other path is checked against. Passing it
|
|
329
|
+
* to `validatePath` would either compare it against `process.cwd()` (which now
|
|
330
|
+
* fails legitimately, since the root is an ANCESTOR of cwd for any
|
|
331
|
+
* sub-workspace invocation) or against itself (a tautology that silently
|
|
332
|
+
* retires the check). Assert the properties that actually matter instead:
|
|
333
|
+
* injection-free and absolute.
|
|
334
|
+
*/
|
|
335
|
+
function validateProjectRoot(root) {
|
|
336
|
+
if (root.includes('\0')) {
|
|
337
|
+
throw new Error('Project root contains null bytes');
|
|
338
|
+
}
|
|
339
|
+
if (/[;&|`$<>]/.test(root)) {
|
|
340
|
+
throw new Error('Project root contains shell metacharacters');
|
|
341
|
+
}
|
|
342
|
+
if (!isAbsolute(root)) {
|
|
343
|
+
throw new Error(`Project root is not absolute: ${root}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
317
346
|
/**
|
|
318
347
|
* Start daemon as a detached background process
|
|
319
348
|
*/
|
|
320
349
|
async function startBackgroundDaemon(projectRoot, quiet, maxCpuLoad, minFreeMemory, dashboardPort, noDashboard) {
|
|
321
350
|
// Validate and resolve project root
|
|
322
351
|
const resolvedRoot = resolve(projectRoot);
|
|
323
|
-
|
|
352
|
+
validateProjectRoot(resolvedRoot);
|
|
324
353
|
const stateDir = join(resolvedRoot, '.moflo');
|
|
325
354
|
const logFile = join(stateDir, 'daemon.log');
|
|
326
355
|
// Validate all paths
|
|
@@ -374,6 +403,13 @@ async function startBackgroundDaemon(projectRoot, quiet, maxCpuLoad, minFreeMemo
|
|
|
374
403
|
}
|
|
375
404
|
const daemonEnv = {
|
|
376
405
|
...process.env,
|
|
406
|
+
// #1315 — PIN the child's project dir to the root we just resolved.
|
|
407
|
+
// Without this the daemon inherited the SESSION's `CLAUDE_PROJECT_DIR`
|
|
408
|
+
// (a sub-workspace), and so did everything it spawns — headless Claude,
|
|
409
|
+
// hooks, MCP — all of which resolve through `findProjectRoot()` and would
|
|
410
|
+
// anchor back on the sub-workspace, re-minting the island this fix
|
|
411
|
+
// removes. Pinning it closes the whole inherited-env class in one place.
|
|
412
|
+
CLAUDE_PROJECT_DIR: resolvedRoot,
|
|
377
413
|
MOFLO_DAEMON: '1',
|
|
378
414
|
// #981 — daemon process must skip its own write-routing client.
|
|
379
415
|
MOFLO_IS_DAEMON: '1',
|
|
@@ -454,7 +490,10 @@ const stopCommand = {
|
|
|
454
490
|
],
|
|
455
491
|
action: async (ctx) => {
|
|
456
492
|
const quiet = ctx.flags.quiet;
|
|
457
|
-
|
|
493
|
+
// #1315 — must match `start`'s anchor. Anchoring on cwd here would look for
|
|
494
|
+
// the lock in a sub-workspace, miss the root daemon, and report "not
|
|
495
|
+
// running" while it kept going.
|
|
496
|
+
const projectRoot = resolveStateRoot();
|
|
458
497
|
try {
|
|
459
498
|
if (!quiet) {
|
|
460
499
|
const spinner = output.createSpinner({ text: 'Stopping worker daemon...', spinner: 'dots' });
|
|
@@ -584,7 +623,12 @@ const statusCommand = {
|
|
|
584
623
|
action: async (ctx) => {
|
|
585
624
|
const verbose = ctx.flags.verbose;
|
|
586
625
|
const showModes = ctx.flags.showModes;
|
|
587
|
-
|
|
626
|
+
// #1315 — `getDaemon` constructs a WorkerDaemon, and that constructor
|
|
627
|
+
// unconditionally mkdirs `<root>/.moflo` and `<root>/.moflo/logs`
|
|
628
|
+
// (`worker-daemon.ts`). Anchoring on cwd meant a read-only-looking
|
|
629
|
+
// `flo daemon status` MINTED a state-dir island in whatever directory it
|
|
630
|
+
// was run from — a creation path entirely separate from `start`.
|
|
631
|
+
const projectRoot = resolveStateRoot();
|
|
588
632
|
try {
|
|
589
633
|
const daemon = getDaemon(projectRoot);
|
|
590
634
|
const status = daemon.getStatus();
|
|
@@ -721,7 +765,9 @@ const triggerCommand = {
|
|
|
721
765
|
return { success: false, exitCode: 1 };
|
|
722
766
|
}
|
|
723
767
|
try {
|
|
724
|
-
|
|
768
|
+
// #1315 — resolved root, not cwd: the WorkerDaemon constructor creates
|
|
769
|
+
// the state dir it is pointed at.
|
|
770
|
+
const daemon = getDaemon(resolveStateRoot());
|
|
725
771
|
const spinner = output.createSpinner({ text: `Running ${workerType} worker...`, spinner: 'dots' });
|
|
726
772
|
spinner.start();
|
|
727
773
|
const result = await daemon.triggerWorker(workerType);
|
|
@@ -764,7 +810,9 @@ const enableCommand = {
|
|
|
764
810
|
return { success: false, exitCode: 1 };
|
|
765
811
|
}
|
|
766
812
|
try {
|
|
767
|
-
|
|
813
|
+
// #1315 — resolved root, not cwd (see `trigger`). Enabling a worker on a
|
|
814
|
+
// cwd-anchored daemon also wrote its state to the wrong `.moflo`.
|
|
815
|
+
const daemon = getDaemon(resolveStateRoot());
|
|
768
816
|
daemon.setWorkerEnabled(workerType, !disable);
|
|
769
817
|
output.printSuccess(`Worker ${workerType} ${disable ? 'disabled' : 'enabled'}`);
|
|
770
818
|
return { success: true };
|
|
@@ -787,7 +835,11 @@ const installCommand = {
|
|
|
787
835
|
],
|
|
788
836
|
action: async (ctx) => {
|
|
789
837
|
const quiet = ctx.flags.quiet;
|
|
790
|
-
|
|
838
|
+
// #1315 — the highest-stakes anchor of the family. `installDaemonService`
|
|
839
|
+
// BAKES this path into a launchd plist / systemd unit / schtasks entry
|
|
840
|
+
// (`daemon-service.ts`), so a cwd-anchored install persisted an island
|
|
841
|
+
// across reboots — the one variant that outlives `flo doctor --fix`.
|
|
842
|
+
const projectRoot = resolveStateRoot();
|
|
791
843
|
try {
|
|
792
844
|
const result = installDaemonService(projectRoot);
|
|
793
845
|
if (!quiet) {
|
|
@@ -821,9 +873,29 @@ const uninstallCommand = {
|
|
|
821
873
|
],
|
|
822
874
|
action: async (ctx) => {
|
|
823
875
|
const quiet = ctx.flags.quiet;
|
|
824
|
-
|
|
876
|
+
// #1315 — must match `install`'s anchor, or uninstall looks for a service
|
|
877
|
+
// registered under a different root and silently leaves it in place.
|
|
878
|
+
const projectRoot = resolveStateRoot();
|
|
825
879
|
try {
|
|
826
880
|
const result = uninstallDaemonService(projectRoot);
|
|
881
|
+
// #1315 — also sweep the LEGACY cwd-anchored registration. The service
|
|
882
|
+
// name is `sha256(resolvedRoot)`, so a consumer who ran `flo daemon
|
|
883
|
+
// install` from a sub-directory before this fix has a plist/unit/task
|
|
884
|
+
// named for THAT path. Resolving correctly now makes it unreachable:
|
|
885
|
+
// uninstall would report success while the old service stayed enabled
|
|
886
|
+
// and re-spawned a sub-root daemon at every login, with no supported way
|
|
887
|
+
// to remove it. Best-effort and silent — on the overwhelmingly common
|
|
888
|
+
// path cwd IS the root and this is a no-op.
|
|
889
|
+
const legacyRoot = resolve(process.cwd());
|
|
890
|
+
if (legacyRoot !== projectRoot) {
|
|
891
|
+
try {
|
|
892
|
+
const legacy = uninstallDaemonService(legacyRoot);
|
|
893
|
+
if (legacy.success && !quiet) {
|
|
894
|
+
output.printSuccess(`Also removed a legacy service registered at ${legacyRoot}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
catch { /* nothing registered there — expected */ }
|
|
898
|
+
}
|
|
827
899
|
if (!quiet) {
|
|
828
900
|
if (result.success) {
|
|
829
901
|
output.printSuccess(result.message);
|
|
@@ -10,7 +10,7 @@ import { findProjectDaemonPids, getDaemonLockHolder, getDaemonLockPayload, } fro
|
|
|
10
10
|
import { resolveClientPort, LEGACY_DEFAULT_PORT, probeDaemonHealthWithRetry as probeDaemonHealthIdentity, normalizeProjectRoot, } from '../services/daemon-port.js';
|
|
11
11
|
import { COMMON_WALK_SKIP_NAMES, LEGACY_SWARM_DIR, memoryDbCandidatePaths, memoryDbPath, } from '../services/moflo-paths.js';
|
|
12
12
|
import { probeDbIntegrity } from '../services/memory-db-integrity-repair.js';
|
|
13
|
-
import { findProjectRoot } from '../services/project-root.js';
|
|
13
|
+
import { findProjectRoot, resolveStateRoot } from '../services/project-root.js';
|
|
14
14
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
15
15
|
export async function checkConfigFile() {
|
|
16
16
|
// JSON configs (parse-validated). LEGACY-CONFIG: `.claude-flow.json` and
|
|
@@ -382,11 +382,23 @@ export async function checkSwarmResidue() {
|
|
|
382
382
|
* something more targeted.
|
|
383
383
|
*/
|
|
384
384
|
const NESTED_BFS_MAX_FOUND = 50;
|
|
385
|
-
|
|
386
|
-
|
|
385
|
+
/**
|
|
386
|
+
* Depth bound for the nested-`.moflo/` walk.
|
|
387
|
+
*
|
|
388
|
+
* #1315 raised this from 5 to 8. The waxstak report found a stray seven levels
|
|
389
|
+
* deep (`back-office/ui/src/layout/Dashboard/Header/HeaderContent/.moflo`) —
|
|
390
|
+
* inside a React component folder, because ANY cwd could mint one. At depth 5
|
|
391
|
+
* the healer silently walked past it and reported a clean tree. The walk skips
|
|
392
|
+
* `node_modules`/`.git`/build output via `COMMON_WALK_SKIP_NAMES`, so the extra
|
|
393
|
+
* three levels cost little on a real repo.
|
|
394
|
+
*/
|
|
395
|
+
const NESTED_SCAN_MAX_DEPTH = 8;
|
|
396
|
+
function scanNestedMofloDirs(root, maxDepth = NESTED_SCAN_MAX_DEPTH) {
|
|
397
|
+
const islands = [];
|
|
398
|
+
const husks = [];
|
|
387
399
|
let truncated = false;
|
|
388
400
|
function walk(dir, depth) {
|
|
389
|
-
if (
|
|
401
|
+
if (islands.length + husks.length >= NESTED_BFS_MAX_FOUND) {
|
|
390
402
|
truncated = true;
|
|
391
403
|
return;
|
|
392
404
|
}
|
|
@@ -411,9 +423,17 @@ function scanNestedMofloDirs(root, maxDepth = 5) {
|
|
|
411
423
|
if (entry.name.startsWith('.') && depth > 0)
|
|
412
424
|
continue;
|
|
413
425
|
const childDir = join(dir, entry.name);
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
426
|
+
// #1315 — key on the `.moflo/` DIRECTORY, then classify by whether it
|
|
427
|
+
// holds `moflo.db`. Keying on `moflo.db` alone (the pre-#1315 predicate)
|
|
428
|
+
// made every daemon-minted husk invisible.
|
|
429
|
+
if (existsSync(join(childDir, '.moflo'))) {
|
|
430
|
+
if (existsSync(join(childDir, '.moflo', 'moflo.db'))) {
|
|
431
|
+
islands.push(childDir);
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
husks.push(childDir);
|
|
435
|
+
}
|
|
436
|
+
if (islands.length + husks.length >= NESTED_BFS_MAX_FOUND) {
|
|
417
437
|
truncated = true;
|
|
418
438
|
return;
|
|
419
439
|
}
|
|
@@ -426,15 +446,12 @@ function scanNestedMofloDirs(root, maxDepth = 5) {
|
|
|
426
446
|
continue;
|
|
427
447
|
}
|
|
428
448
|
walk(childDir, depth + 1);
|
|
429
|
-
if (
|
|
449
|
+
if (islands.length + husks.length >= NESTED_BFS_MAX_FOUND)
|
|
430
450
|
return;
|
|
431
451
|
}
|
|
432
452
|
}
|
|
433
453
|
walk(root, 0);
|
|
434
|
-
return { islands
|
|
435
|
-
}
|
|
436
|
-
function findNestedMofloDirs(root, maxDepth = 5) {
|
|
437
|
-
return scanNestedMofloDirs(root, maxDepth).islands;
|
|
454
|
+
return { islands, husks, truncated };
|
|
438
455
|
}
|
|
439
456
|
/**
|
|
440
457
|
* Public wrapper for the BFS used by `checkNestedMofloIslands`. Exposed so
|
|
@@ -443,7 +460,11 @@ function findNestedMofloDirs(root, maxDepth = 5) {
|
|
|
443
460
|
* (the consumer joins `.moflo` itself).
|
|
444
461
|
*/
|
|
445
462
|
export function findNestedMofloDirsForFix(root) {
|
|
446
|
-
|
|
463
|
+
// #1315 — husks are archived alongside stateful islands. Archiving is a
|
|
464
|
+
// rename to `.moflo-archived-<ISO>/`, so it is non-destructive either way,
|
|
465
|
+
// and leaving husks in place lets the daemons bound to them keep running.
|
|
466
|
+
const { islands, husks } = scanNestedMofloDirs(root);
|
|
467
|
+
return [...islands, ...husks];
|
|
447
468
|
}
|
|
448
469
|
/**
|
|
449
470
|
* Surface nested `.moflo/moflo.db` directories — every one of them is a daemon
|
|
@@ -463,7 +484,9 @@ export function findNestedMofloDirsForFix(root) {
|
|
|
463
484
|
* first.
|
|
464
485
|
*/
|
|
465
486
|
export async function checkNestedMofloIslands(cwd) {
|
|
466
|
-
|
|
487
|
+
// #1315 — must match the anchor `fixNestedMofloIslands` uses, or the check
|
|
488
|
+
// and its own auto-fix disagree about which tree they are talking about.
|
|
489
|
+
const root = cwd ?? resolveStateRoot();
|
|
467
490
|
let scan;
|
|
468
491
|
try {
|
|
469
492
|
scan = scanNestedMofloDirs(root);
|
|
@@ -475,20 +498,33 @@ export async function checkNestedMofloIslands(cwd) {
|
|
|
475
498
|
message: `Walk failed: ${errorDetail(e, { firstLineOnly: true })}`,
|
|
476
499
|
};
|
|
477
500
|
}
|
|
478
|
-
const { islands, truncated } = scan;
|
|
479
|
-
if (islands.length === 0) {
|
|
501
|
+
const { islands, husks, truncated } = scan;
|
|
502
|
+
if (islands.length === 0 && husks.length === 0) {
|
|
480
503
|
return {
|
|
481
504
|
name: 'Nested .moflo/ Islands',
|
|
482
505
|
status: 'pass',
|
|
483
506
|
message: 'No nested .moflo/ directories detected',
|
|
484
507
|
};
|
|
485
508
|
}
|
|
486
|
-
const
|
|
487
|
-
const
|
|
509
|
+
const rel = (p) => relative(root, p) || '.';
|
|
510
|
+
const parts = [];
|
|
511
|
+
if (islands.length) {
|
|
512
|
+
// Stateful: created by `flo init` in a sub-workspace (#1174).
|
|
513
|
+
parts.push(`${islands.length} with state (#1174): ${islands.map(rel).join(', ')}`);
|
|
514
|
+
}
|
|
515
|
+
if (husks.length) {
|
|
516
|
+
// Stateless: minted by the daemon or a cwd-anchored command (#1315). Worth
|
|
517
|
+
// naming separately because the remedy differs — these mean something is
|
|
518
|
+
// still resolving its root from cwd, not that someone ran `flo init` wrong.
|
|
519
|
+
parts.push(`${husks.length} daemon-minted, no moflo.db (#1315): ${husks.map(rel).join(', ')}`);
|
|
520
|
+
}
|
|
521
|
+
const truncNote = truncated
|
|
522
|
+
? ` (walk truncated at depth ${NESTED_SCAN_MAX_DEPTH} or ${NESTED_BFS_MAX_FOUND} results — more may exist)`
|
|
523
|
+
: '';
|
|
488
524
|
return {
|
|
489
525
|
name: 'Nested .moflo/ Islands',
|
|
490
526
|
status: 'warn',
|
|
491
|
-
message: `${
|
|
527
|
+
message: `${parts.join('; ')}${truncNote}`,
|
|
492
528
|
fix: 'flo healer --fix -c nested-moflo',
|
|
493
529
|
};
|
|
494
530
|
}
|
|
@@ -9,6 +9,7 @@ import { join } from 'path';
|
|
|
9
9
|
import { memoryDbCandidatePaths } from '../services/moflo-paths.js';
|
|
10
10
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
11
11
|
import { openDaemonDatabase } from '../memory/daemon-backend.js';
|
|
12
|
+
import { resolveStateRoot } from '../services/project-root.js';
|
|
12
13
|
/** Skew (cached / live count delta) above which the cache is treated as stale. */
|
|
13
14
|
const VECTOR_STATS_SKEW_WARN_THRESHOLD = 0.2;
|
|
14
15
|
/**
|
|
@@ -35,9 +36,9 @@ async function countEmbeddedRowsFromDb(dbPath) {
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
export async function checkEmbeddings() {
|
|
38
|
-
const liveDbPath = memoryDbCandidatePaths(
|
|
39
|
+
const liveDbPath = memoryDbCandidatePaths(resolveStateRoot()).find((p) => existsSync(p));
|
|
39
40
|
// 1. Fast path: read cached vector-stats.json if available
|
|
40
|
-
const statsPath = join(
|
|
41
|
+
const statsPath = join(resolveStateRoot(), '.moflo', 'vector-stats.json');
|
|
41
42
|
try {
|
|
42
43
|
if (existsSync(statsPath)) {
|
|
43
44
|
const stats = JSON.parse(readFileSync(statsPath, 'utf8'));
|
|
@@ -24,6 +24,7 @@ import { existsSync, readFileSync } from 'fs';
|
|
|
24
24
|
import { join } from 'path';
|
|
25
25
|
import { getDaemonLockHolder } from '../services/daemon-lock.js';
|
|
26
26
|
import { errorDetail } from '../shared/utils/error-detail.js';
|
|
27
|
+
import { resolveStateRoot } from '../services/project-root.js';
|
|
27
28
|
const SCAN_TIMEOUT_MS_WIN = 10_000;
|
|
28
29
|
const SCAN_TIMEOUT_MS_POSIX = 5_000;
|
|
29
30
|
const CMDLINE_CAPTURE_LEN = 300;
|
|
@@ -125,7 +126,10 @@ export function findForeignWriters(procs, daemonPid, trackedPids, selfPid) {
|
|
|
125
126
|
* running a known cross-process writer. Lists every offender so the user can
|
|
126
127
|
* SIGKILL them by PID.
|
|
127
128
|
*/
|
|
128
|
-
|
|
129
|
+
// #1315 — the default anchor is the RESOLVED root, not cwd. Running the healer
|
|
130
|
+
// from a sub-workspace previously audited whatever `.moflo` happened to sit
|
|
131
|
+
// under that directory (often a stray) while reporting on "the project".
|
|
132
|
+
export async function checkWritersAudit(cwd = resolveStateRoot()) {
|
|
129
133
|
const name = 'Writers Audit';
|
|
130
134
|
try {
|
|
131
135
|
const daemonPid = getDaemonLockHolder(cwd);
|
|
@@ -34,6 +34,7 @@ import { CANONICAL_EMBEDDING_MODEL } from '../embeddings/migration/types.js';
|
|
|
34
34
|
import { memoryDbCandidatePaths } from '../services/moflo-paths.js';
|
|
35
35
|
import { openDaemonDatabase } from '../memory/daemon-backend.js';
|
|
36
36
|
import { EPHEMERAL_NAMESPACES, EPHEMERAL_NAMESPACE_PREFIXES, } from '../memory/bridge-embedder.js';
|
|
37
|
+
import { resolveStateRoot } from '../services/project-root.js';
|
|
37
38
|
/**
|
|
38
39
|
* Known neural-model labels that all share the all-MiniLM-L6-v2 384-dim
|
|
39
40
|
* vector space. The Story-2 migration retags any of these to the
|
|
@@ -132,7 +133,7 @@ export async function checkEmbeddingHygiene() {
|
|
|
132
133
|
};
|
|
133
134
|
}
|
|
134
135
|
function resolveMemoryDb() {
|
|
135
|
-
return memoryDbCandidatePaths(
|
|
136
|
+
return memoryDbCandidatePaths(resolveStateRoot()).find((p) => existsSync(p)) ?? null;
|
|
136
137
|
}
|
|
137
138
|
async function loadModelGroups(dbPath) {
|
|
138
139
|
let db;
|