moflo 4.8.87-rc.4 → 4.8.87

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 CHANGED
@@ -24,6 +24,7 @@ import { existsSync, appendFileSync, readFileSync, writeFileSync, mkdirSync, sta
24
24
  import { resolve, dirname } from 'path';
25
25
  import { fileURLToPath } from 'url';
26
26
  import { createProcessManager } from './lib/process-manager.mjs';
27
+ import { shouldDaemonAutoStart } from './lib/daemon-config.mjs';
27
28
 
28
29
  const __filename = fileURLToPath(import.meta.url);
29
30
  const __dirname = dirname(__filename);
@@ -560,13 +561,19 @@ function touchSpawnStamp() {
560
561
 
561
562
  // Run daemon start in background (non-blocking) — skip if already running
562
563
  function runDaemonStartBackground() {
563
- // 1. Check if a live daemon already holds the lock
564
+ // 1. Honor user opt-out via .claude/settings.json claudeFlow.daemon.autoStart
565
+ if (!shouldDaemonAutoStart(projectRoot)) {
566
+ log('info', 'Daemon autoStart disabled in settings, skipping');
567
+ return;
568
+ }
569
+
570
+ // 2. Check if a live daemon already holds the lock
564
571
  if (isDaemonLockHeld()) {
565
572
  log('info', 'Daemon already running (lock held), skipping start');
566
573
  return;
567
574
  }
568
575
 
569
- // 2. Debounce: skip if we spawned recently (prevents thundering herd)
576
+ // 3. Debounce: skip if we spawned recently (prevents thundering herd)
570
577
  if (isDaemonSpawnRecent()) {
571
578
  log('info', 'Daemon spawn debounced (recent attempt), skipping');
572
579
  return;
@@ -578,7 +585,7 @@ function runDaemonStartBackground() {
578
585
  return;
579
586
  }
580
587
 
581
- // 3. Write stamp BEFORE spawning so concurrent callers see it immediately
588
+ // 4. Write stamp BEFORE spawning so concurrent callers see it immediately
582
589
  touchSpawnStamp();
583
590
 
584
591
  spawnWindowless('node', [localCli, 'daemon', 'start', '--quiet'], 'daemon');
@@ -0,0 +1,19 @@
1
+ // Daemon spawn-gate for bin/ scripts. Reads .claude/settings.json — the
2
+ // Claude-Code-facing surface. moflo.yaml's daemon.auto_start is a separate
3
+ // parallel gate honored by src/cli/index.ts maybeAutoStartDaemon.
4
+
5
+ import { existsSync, readFileSync } from 'fs';
6
+ import { resolve } from 'path';
7
+
8
+ // Default-true on missing file, missing key, or malformed JSON so a broken
9
+ // config can't silently disable the daemon.
10
+ export function shouldDaemonAutoStart(projectRoot) {
11
+ try {
12
+ const settingsPath = resolve(projectRoot, '.claude', 'settings.json');
13
+ if (!existsSync(settingsPath)) return true;
14
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
15
+ return settings?.claudeFlow?.daemon?.autoStart !== false;
16
+ } catch {
17
+ return true;
18
+ }
19
+ }
@@ -56,7 +56,8 @@ function lockPath(root) {
56
56
  return resolve(root, '.claude-flow', 'spawn.lock');
57
57
  }
58
58
 
59
- function readRegistry(root) {
59
+ /** Raw read — returns whatever's on disk without filtering or rewriting. */
60
+ function readRegistryRaw(root) {
60
61
  const p = registryPath(root);
61
62
  if (!existsSync(p)) return [];
62
63
  try {
@@ -67,6 +68,22 @@ function readRegistry(root) {
67
68
  }
68
69
  }
69
70
 
71
+ /**
72
+ * Default read — auto-prunes dead PIDs and rewrites the file when stale
73
+ * entries are detected. Catches the "abnormal session termination" case
74
+ * (Claude killed via task manager, OS reboot, hard crash) where session-end
75
+ * never ran and the registry would otherwise grow unboundedly. Rewrite is
76
+ * skipped when nothing changed so steady-state reads stay cheap.
77
+ */
78
+ function readRegistry(root) {
79
+ const raw = readRegistryRaw(root);
80
+ const live = raw.filter(e => e && typeof e.pid === 'number' && isAlive(e.pid));
81
+ if (live.length !== raw.length) {
82
+ try { writeRegistry(root, live); } catch { /* non-fatal */ }
83
+ }
84
+ return live;
85
+ }
86
+
70
87
  /** Atomic write: write to tmp file then rename to avoid torn reads. */
71
88
  function writeRegistry(root, entries) {
72
89
  const p = registryPath(root);
@@ -191,7 +208,9 @@ export function createProcessManager(root) {
191
208
  * @returns {{ killed: number, total: number }}
192
209
  */
193
210
  killAll() {
194
- const entries = readRegistry(projectRoot);
211
+ // Use raw read so `total` reflects every on-disk entry — including
212
+ // dead ones we'd otherwise skip silently. Matches the prior contract.
213
+ const entries = readRegistryRaw(projectRoot);
195
214
  let killed = 0;
196
215
 
197
216
  for (const entry of entries) {
@@ -227,7 +246,9 @@ export function createProcessManager(root) {
227
246
  * @returns {{ pruned: number, remaining: number }}
228
247
  */
229
248
  prune() {
230
- const entries = readRegistry(projectRoot);
249
+ // Use raw read so the pruned count reflects what was on disk, not
250
+ // what the auto-pruning readRegistry would have already cleaned up.
251
+ const entries = readRegistryRaw(projectRoot);
231
252
  const alive = entries.filter(e => isAlive(e.pid));
232
253
  writeRegistry(projectRoot, alive);
233
254
  return { pruned: entries.length - alive.length, remaining: alive.length };
@@ -222,6 +222,42 @@ try {
222
222
  }
223
223
  }
224
224
 
225
+ // Recycle the running daemon — its in-process module cache holds the
226
+ // previous moflo image. After an upgrade that cache is stale, which
227
+ // shows up as warnings from removed code paths (e.g. the
228
+ // `[neural-tools] @moflo/embeddings not resolvable` spam from #639,
229
+ // emitted by pre-#592 collapse code that no longer exists in source)
230
+ // and means freshly-disabled workers keep running.
231
+ //
232
+ // Recycle = stop old + start new. We kill the lock-recorded PID,
233
+ // remove the lock, then fire a fresh daemon so the user keeps the
234
+ // functionality they had. `daemon.autoStart` only governs the
235
+ // cold-start case (no daemon existed); here a daemon was actually
236
+ // running, so replacing it with a current-code copy is the desired
237
+ // behaviour regardless of that flag.
238
+ try {
239
+ const lockFile = resolve(projectRoot, '.claude-flow', 'daemon.lock');
240
+ if (existsSync(lockFile)) {
241
+ let stalePid = null;
242
+ try {
243
+ const lock = JSON.parse(readFileSync(lockFile, 'utf-8'));
244
+ if (typeof lock?.pid === 'number' && lock.pid > 0) stalePid = lock.pid;
245
+ } catch { /* malformed lock — fall through to unlink */ }
246
+ if (stalePid !== null) {
247
+ try { process.kill(stalePid, 'SIGTERM'); } catch { /* already dead */ }
248
+ }
249
+ try { unlinkSync(lockFile); } catch { /* non-fatal */ }
250
+ // Respawn only if a live daemon was actually recorded — no point
251
+ // starting one when there wasn't one before.
252
+ if (stalePid !== null) {
253
+ const localCliPath = resolve(binDir, 'cli.js');
254
+ if (existsSync(localCliPath)) {
255
+ fireAndForget('node', [localCliPath, 'daemon', 'start', '--quiet'], 'daemon-recycle');
256
+ }
257
+ }
258
+ }
259
+ } catch { /* non-fatal — daemon recycle is best-effort */ }
260
+
225
261
  // Write updated manifest + version stamp
226
262
  try {
227
263
  const cfDir = resolve(projectRoot, '.claude-flow');
@@ -104,9 +104,12 @@ export function generateSettings(options) {
104
104
  },
105
105
  daemon: {
106
106
  autoStart: true,
107
+ // Note: this list is documentation for the user — the daemon's actual
108
+ // worker registry lives in src/cli/services/worker-daemon.ts DEFAULT_WORKERS.
109
+ // 'audit' is intentionally absent here because it's default-disabled
110
+ // pending the perf fix in #631.
107
111
  workers: [
108
112
  'map', // Codebase mapping
109
- 'audit', // Security auditing (critical priority)
110
113
  'optimize', // Performance optimization (high priority)
111
114
  'consolidate', // Memory consolidation
112
115
  'testgaps', // Test coverage gaps
@@ -17,16 +17,17 @@ import { createEmbeddingService } from '../embeddings/index.js';
17
17
  import { HnswLite } from './hnsw-lite.js';
18
18
  /**
19
19
  * Write vector-stats.json cache for the statusline (no subprocess needed).
20
- * Called after memory store/delete to keep the cache fresh.
20
+ * Called after memory store in the raw-sql.js fallback path. The bridge path
21
+ * goes through refreshVectorStatsCache() in bridge-core.ts instead.
21
22
  * @param dbPath - path to the SQLite database file
22
- * @param stats - optional exact counts from a db query already in progress
23
+ * @param stats - exact counts from a db query already in progress (required —
24
+ * making this optional caused issue #639 by silently writing 0)
23
25
  */
24
26
  function writeVectorStatsCache(dbPath, stats) {
25
27
  try {
26
28
  const fileStat = fs.statSync(dbPath);
27
29
  const dbSizeKB = Math.floor(fileStat.size / 1024);
28
- const vectorCount = stats?.vectorCount ?? 0;
29
- const namespaces = stats?.namespaces ?? 0;
30
+ const { vectorCount, namespaces } = stats;
30
31
  // Check HNSW index presence
31
32
  const dbDir = path.dirname(dbPath);
32
33
  const projectDir = path.dirname(dbDir); // .swarm -> project root
@@ -1568,17 +1569,15 @@ export async function verifyMemoryInit(dbPath, options) {
1568
1569
  * This bypasses MCP and writes directly to the database
1569
1570
  */
1570
1571
  export async function storeEntry(options) {
1571
- // ADR-053: Try AgentDB v3 bridge first
1572
+ // ADR-053: Try AgentDB v3 bridge first. The bridge calls
1573
+ // refreshVectorStatsCache() itself (bridge-entries.ts:191) — a second
1574
+ // write here was redundant and previously clobbered the correct count
1575
+ // with 0 (#639).
1572
1576
  const bridge = await getBridge();
1573
1577
  if (bridge) {
1574
1578
  const bridgeResult = await bridge.bridgeStoreEntry(options);
1575
- if (bridgeResult) {
1576
- // Update statusline cache after successful bridge store
1577
- const swarmDir = path.join(process.cwd(), '.swarm');
1578
- const dbFile = options.dbPath || path.join(swarmDir, 'memory.db');
1579
- writeVectorStatsCache(dbFile);
1579
+ if (bridgeResult)
1580
1580
  return bridgeResult;
1581
- }
1582
1581
  }
1583
1582
  // Fallback: raw sql.js
1584
1583
  const { key, value, namespace = 'default', generateEmbeddingFlag = true, tags = [], ttl, dbPath: customPath, upsert = false } = options;
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import * as fs from 'fs';
19
19
  import * as path from 'path';
20
+ import { atomicWriteFileSync } from './atomic-file-write.js';
20
21
  import { loadMofloConfig } from '../config/moflo-config.js';
21
22
  // ============================================================================
22
23
  // Constants
@@ -108,10 +109,11 @@ export class GateService {
108
109
  writeState(state) {
109
110
  try {
110
111
  fs.mkdirSync(path.dirname(this.stateFilePath), { recursive: true });
111
- fs.writeFileSync(this.stateFilePath, JSON.stringify(state, null, 2));
112
+ // Atomic write so concurrent gate-hook processes never produce torn JSON.
113
+ atomicWriteFileSync(this.stateFilePath, JSON.stringify(state, null, 2));
112
114
  }
113
115
  catch {
114
- // Non-fatal
116
+ // Non-fatal — last-writer-wins; updates may be lost, never corrupted.
115
117
  }
116
118
  }
117
119
  // --------------------------------------------------------------------------
@@ -11,13 +11,17 @@
11
11
  */
12
12
  import { EventEmitter } from 'events';
13
13
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
14
+ import { atomicWriteFileSync } from './atomic-file-write.js';
14
15
  import { cpus } from 'os';
15
16
  import { join } from 'path';
16
17
  import { HeadlessWorkerExecutor, isHeadlessWorker, } from './headless-worker-executor.js';
17
18
  // Default worker configurations with improved intervals (P0 fix: map 5min -> 15min)
18
19
  const DEFAULT_WORKERS = [
19
20
  { type: 'map', intervalMs: 15 * 60 * 1000, offsetMs: 0, priority: 'normal', description: 'Codebase mapping', enabled: true },
20
- { type: 'audit', intervalMs: 10 * 60 * 1000, offsetMs: 2 * 60 * 1000, priority: 'critical', description: 'Security analysis', enabled: true },
21
+ // Default-disabled until the perf regression in #631 is remediated. The
22
+ // worker averages 238 s/run on real installs, saturating cores back-to-back
23
+ // when scheduled at the 10-minute interval. Re-enable here when #631 ships.
24
+ { type: 'audit', intervalMs: 10 * 60 * 1000, offsetMs: 2 * 60 * 1000, priority: 'critical', description: 'Security analysis', enabled: false },
21
25
  { type: 'optimize', intervalMs: 15 * 60 * 1000, offsetMs: 4 * 60 * 1000, priority: 'high', description: 'Performance optimization', enabled: true },
22
26
  { type: 'consolidate', intervalMs: 30 * 60 * 1000, offsetMs: 6 * 60 * 1000, priority: 'low', description: 'Memory consolidation', enabled: true },
23
27
  { type: 'testgaps', intervalMs: 20 * 60 * 1000, offsetMs: 8 * 60 * 1000, priority: 'normal', description: 'Test coverage analysis', enabled: true },
@@ -336,10 +340,17 @@ export class WorkerDaemon extends EventEmitter {
336
340
  this.startedAt = new Date();
337
341
  this.emit('started', { pid: process.pid, startedAt: this.startedAt });
338
342
  // Schedule all enabled workers
343
+ const skipped = [];
339
344
  for (const workerConfig of this.config.workers) {
340
345
  if (workerConfig.enabled) {
341
346
  this.scheduleWorker(workerConfig);
342
347
  }
348
+ else {
349
+ skipped.push(workerConfig.type);
350
+ }
351
+ }
352
+ if (skipped.length > 0) {
353
+ this.log('info', `Skipping disabled workers: ${skipped.join(', ')}`);
343
354
  }
344
355
  if (this.scheduler && !this.scheduler.isRunning) {
345
356
  this.scheduler.start();
@@ -881,7 +892,8 @@ export class WorkerDaemon extends EventEmitter {
881
892
  savedAt: new Date().toISOString(),
882
893
  };
883
894
  try {
884
- writeFileSync(this.config.stateFile, JSON.stringify(state, null, 2));
895
+ // Atomic write so a force-kill mid-write can't leave partial JSON behind.
896
+ atomicWriteFileSync(this.config.stateFile, JSON.stringify(state, null, 2));
885
897
  }
886
898
  catch (error) {
887
899
  this.log('error', `Failed to save state: ${error}`);
@@ -1,12 +1,19 @@
1
1
  /**
2
2
  * Atomic filesystem writes for files that must not be left corrupted if the
3
- * process is interrupted mid-write (SIGINT, power loss, ENOSPC).
3
+ * process is interrupted mid-write (SIGINT, power loss, ENOSPC) or if multiple
4
+ * processes write to the same target concurrently.
4
5
  *
5
- * Pattern: write to `<target>.tmp`, then rename onto `target`.
6
+ * Pattern: write to a process-unique temp path `<target>.tmp.<pid>.<rand>`,
7
+ * then rename onto `target`.
6
8
  * - `fs.renameSync` is atomic on POSIX.
7
9
  * - On Windows, Node maps it to `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`,
8
10
  * which replaces the destination near-atomically — concurrent readers
9
11
  * always observe either the old file or the new, never a truncated one.
12
+ * - The unique temp path means concurrent writers can't clobber each other's
13
+ * in-flight bytes (#635). Last-writer-wins semantics: each rename is fully
14
+ * atomic, so the destination always reflects exactly one writer's data.
15
+ * Updates from earlier writers may be lost — that's a separate concern
16
+ * requiring read-modify-write under a file lock.
10
17
  *
11
18
  * On any failure, the temp file is best-effort removed and the original
12
19
  * `target` stays intact. The underlying error is always re-thrown.
@@ -1,12 +1,19 @@
1
1
  /**
2
2
  * Atomic filesystem writes for files that must not be left corrupted if the
3
- * process is interrupted mid-write (SIGINT, power loss, ENOSPC).
3
+ * process is interrupted mid-write (SIGINT, power loss, ENOSPC) or if multiple
4
+ * processes write to the same target concurrently.
4
5
  *
5
- * Pattern: write to `<target>.tmp`, then rename onto `target`.
6
+ * Pattern: write to a process-unique temp path `<target>.tmp.<pid>.<rand>`,
7
+ * then rename onto `target`.
6
8
  * - `fs.renameSync` is atomic on POSIX.
7
9
  * - On Windows, Node maps it to `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`,
8
10
  * which replaces the destination near-atomically — concurrent readers
9
11
  * always observe either the old file or the new, never a truncated one.
12
+ * - The unique temp path means concurrent writers can't clobber each other's
13
+ * in-flight bytes (#635). Last-writer-wins semantics: each rename is fully
14
+ * atomic, so the destination always reflects exactly one writer's data.
15
+ * Updates from earlier writers may be lost — that's a separate concern
16
+ * requiring read-modify-write under a file lock.
10
17
  *
11
18
  * On any failure, the temp file is best-effort removed and the original
12
19
  * `target` stays intact. The underlying error is always re-thrown.
@@ -18,7 +25,7 @@
18
25
  */
19
26
  import * as realFs from 'node:fs';
20
27
  export function atomicWriteFileSync(targetPath, data, fs = realFs) {
21
- const tmpPath = `${targetPath}.tmp`;
28
+ const tmpPath = `${targetPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
22
29
  try {
23
30
  fs.writeFileSync(tmpPath, data);
24
31
  fs.renameSync(tmpPath, targetPath);
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export declare const VERSION = "4.8.87-rc.4";
5
+ export declare const VERSION = "4.8.87";
6
6
  //# sourceMappingURL=version.d.ts.map
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.8.87-rc.4';
5
+ export const VERSION = '4.8.87';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.8.87-rc.4",
3
+ "version": "4.8.87",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. Forked from ruflo/claude-flow with patches applied to source, plus feature-level orchestration.",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "type": "module",
@@ -45,7 +45,7 @@
45
45
  "scripts": {
46
46
  "dev": "tsx watch src/cli/index.ts",
47
47
  "prebuild": "node scripts/sync-version.mjs && node scripts/clean-dist.mjs",
48
- "build": "tsc -b && node -e \"const{cpSync}=require('fs');cpSync('src/cli/epic/spells','dist/src/cli/epic/spells',{recursive:true})\"",
48
+ "build": "tsc && node -e \"const{cpSync}=require('fs');cpSync('src/cli/epic/spells','dist/src/cli/epic/spells',{recursive:true})\"",
49
49
  "prepublishOnly": "npm run build",
50
50
  "postinstall": "node scripts/prune-native-binaries.mjs",
51
51
  "test": "node scripts/test-runner.mjs",
@@ -79,7 +79,7 @@
79
79
  "@typescript-eslint/eslint-plugin": "^7.18.0",
80
80
  "@typescript-eslint/parser": "^7.18.0",
81
81
  "eslint": "^8.0.0",
82
- "moflo": "^4.8.87-rc.3",
82
+ "moflo": "^4.8.87-rc.4",
83
83
  "tsx": "^4.21.0",
84
84
  "typescript": "^5.9.3",
85
85
  "vitest": "^4.0.0"