moflo 4.9.1 → 4.9.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.
@@ -43,6 +43,31 @@
43
43
 
44
44
  ---
45
45
 
46
+ ## Transient Errors Must Use Retry + Circuit Breaker
47
+
48
+ **Wrap every transient-failure-capable operation in a retry helper with exponential backoff and a circuit breaker.** One-shot try-and-log on a transient class strands users in partial-state loops (#854: Windows file-lock + AV scan race left stale `.claude/helpers/gate.cjs` across 8+ moflo bumps).
49
+
50
+ | Element | Default |
51
+ |---------|---------|
52
+ | Retryable codes | `EBUSY`, `EPERM`, `EACCES`, `EAGAIN`, `ETIMEDOUT`, `ECONNRESET`; HTTP 5xx/429/408 |
53
+ | Hard codes (no retry) | `ENOENT`, `EISDIR`, `EEXIST`, validation errors |
54
+ | Backoff (filesystem) | `[50, 200, 800]ms` — 3 retries |
55
+ | Circuit breaker | Open after 5 distinct failures; tail runs with `maxAttempts=1` |
56
+ | Exhaustion handling | Log error AND name the healer (e.g. `run 'flo doctor --fix' to repair`) |
57
+
58
+ **Reference implementation:** `syncWithRetry` in `bin/session-start-launcher.mjs`. Use it; do not invent ad-hoc retries.
59
+
60
+ ```js
61
+ // FORBIDDEN
62
+ try { copyFileSync(src, dest); } catch (err) { console.warn(err.message); }
63
+
64
+ // CORRECT
65
+ const result = await syncWithRetry(() => copyFileSync(src, dest));
66
+ if (!result.ok) syncFailures.push({ key, message: `${errMessage(result.err)} (retried after ${result.code})` });
67
+ ```
68
+
69
+ ---
70
+
46
71
  ## See Also
47
72
 
48
73
  - `.claude/guidance/shipped/moflo-source-hygiene.md` — General source code standards
@@ -68,6 +68,22 @@ Compiled output is generated by `npm run build` (single `tsc` run from repo root
68
68
 
69
69
  ---
70
70
 
71
+ ## Async-Eligible Operations Must Be Async By Default
72
+
73
+ **Use the async API for any operation that has one, unless a documented reason forces sync.** Sync IO and busy-waits block the event loop — on a hot path that pins a CPU core, stalls timers, and turns a "best-effort" failure into a hang. Issue #854 burned 5s of CPU to a `while (Date.now() < end)` busy-wait inside an already-async launcher.
74
+
75
+ | Operation | Use | Avoid |
76
+ |-----------|-----|-------|
77
+ | File IO inside an `async` function | `fs/promises` (`readFile`, `copyFile`, `mkdir`) | `*Sync` variants |
78
+ | Delays | `await new Promise(r => setTimeout(r, ms))` | `while (Date.now() < end)` busy-wait |
79
+ | Spawning long-running children | `await new Promise` around `spawn`/`execFile` | `execFileSync`/`spawnSync` (Windows timeouts orphan children — #744) |
80
+
81
+ **Sync is OK only when:** the function cannot be `async` (top-level config reader before any await; certain CommonJS hooks); the IO is microscopic on a known-fast path (one `existsSync` probe, one tiny `readFileSync`); or conversion churn outweighs the benefit. Document the reason in a one-line comment.
82
+
83
+ **Rule of thumb:** if anything in your call stack already `await`s, every IO/delay below it must be async too — mixing sync IO inside an async function is a smell.
84
+
85
+ ---
86
+
71
87
  ## When Creating New Files
72
88
 
73
89
  Before creating any new source file, verify:
@@ -203,6 +203,29 @@ function writeVectorStatsCache(stats, nsStats) {
203
203
  // Main
204
204
  // ============================================================================
205
205
 
206
+ // Build (or skip) the HNSW sidecar — shared between the all-embedded fast path
207
+ // and the post-embedding finalize path. Issue #854: the early-return path used
208
+ // to skip this entirely, leaving consumers with `hasHnsw: false` permanently
209
+ // after the embeddings migration deleted the stale sidecar without rebuilding.
210
+ // `stats` is passed in by both call sites so we don't redundantly run the
211
+ // COUNT aggregate the caller already executed. `alwaysRebuild` is set by the
212
+ // post-embedding path because newly-embedded rows always invalidate the
213
+ // existing sidecar.
214
+ async function ensureHnswSidecar(stats, { alwaysRebuild = false } = {}) {
215
+ if (stats.withEmbeddings <= 0) return false;
216
+ const sidecarExists = existsSync(hnswIndexPath(projectRoot));
217
+ if (sidecarExists && !force && !alwaysRebuild) return false;
218
+ log(sidecarExists ? 'Rebuilding HNSW sidecar...' : 'Building HNSW sidecar...');
219
+ const result = await buildAndWriteHnswSidecar(DB_PATH, projectRoot, {
220
+ dimensions: EMBEDDING_DIMS,
221
+ });
222
+ log(`HNSW sidecar: ${result.vectorCount} vectors → ${result.sidecarPath} (${(result.bytes / 1024).toFixed(1)} KB)`);
223
+ if (!existsSync(result.sidecarPath)) {
224
+ throw new Error(`HNSW sidecar missing after write: ${result.sidecarPath}`);
225
+ }
226
+ return true;
227
+ }
228
+
206
229
  async function main() {
207
230
  console.log('');
208
231
  log('═══════════════════════════════════════════════════════════');
@@ -217,6 +240,8 @@ async function main() {
217
240
  log('All entries already have embeddings');
218
241
  const stats = getEmbeddingStats(db);
219
242
  log(`Total: ${stats.withEmbeddings}/${stats.total} entries embedded`);
243
+ // HNSW first so vector-stats reports the post-rebuild `hasHnsw` (#854).
244
+ await ensureHnswSidecar(stats);
220
245
  writeVectorStatsCache(stats, getNamespaceStats(db));
221
246
  db.close();
222
247
  return;
@@ -312,22 +337,14 @@ async function main() {
312
337
  }
313
338
  log('═══════════════════════════════════════════════════════════');
314
339
 
340
+ // Rebuild the HNSW sidecar before vector-stats so `hasHnsw` reflects the
341
+ // post-rebuild state in the same session (#854). Newly-embedded rows
342
+ // invalidate the existing sidecar, so we force the rebuild here even
343
+ // when the sidecar already exists.
344
+ await ensureHnswSidecar(stats, { alwaysRebuild: true });
345
+
315
346
  writeVectorStatsCache(stats, nsStats);
316
347
  db.close();
317
-
318
- // Rebuild + persist the HNSW sidecar so cold-start memory searches skip
319
- // the SQL→graph rebuild. Failure here exits non-zero — index-all.mjs and
320
- // any caller of this script depend on the sidecar landing on disk.
321
- if (stats.withEmbeddings > 0) {
322
- log('Building HNSW sidecar...');
323
- const result = await buildAndWriteHnswSidecar(DB_PATH, projectRoot, {
324
- dimensions: EMBEDDING_DIMS,
325
- });
326
- log(`HNSW sidecar: ${result.vectorCount} vectors → ${result.sidecarPath} (${(result.bytes / 1024).toFixed(1)} KB)`);
327
- if (!existsSync(result.sidecarPath)) {
328
- throw new Error(`HNSW sidecar missing after write: ${result.sidecarPath}`);
329
- }
330
- }
331
348
  }
332
349
 
333
350
  main().catch(err => {
@@ -1,29 +1,16 @@
1
1
  /**
2
- * Pure-JS counterpart to src/cli/services/moflo-paths.ts (#699, #727).
2
+ * Pure-JS counterpart to src/cli/services/moflo-paths.ts.
3
3
  *
4
4
  * Lives in bin/lib because session-start-launcher.mjs and other bin/ scripts
5
5
  * run before any TS compilation has happened — they can't import the .ts
6
6
  * source. The TS version is the canonical programmatic API; this version
7
- * mirrors the same algorithm so migration also runs from the consumer
8
- * launcher path. Algorithm parity is enforced by the parity case in
9
- * src/cli/__tests__/services/moflo-paths-migration.test.ts.
7
+ * exposes the same path constants + helpers.
8
+ *
9
+ * Per #851, the legacy `.claude-flow/` rename + `.swarm/memory.db` byte-copy
10
+ * helpers no longer ship: the version-bump-gated cherry-pick lives entirely
11
+ * in the launcher and the TS service `cli/services/cherry-pick-learnings.ts`.
10
12
  */
11
- import {
12
- closeSync,
13
- copyFileSync,
14
- existsSync,
15
- mkdirSync,
16
- openSync,
17
- readFileSync,
18
- readSync,
19
- readdirSync,
20
- renameSync,
21
- rmdirSync,
22
- statSync,
23
- unlinkSync,
24
- writeFileSync,
25
- } from 'node:fs';
26
- import { dirname, join } from 'node:path';
13
+ import { join } from 'node:path';
27
14
 
28
15
  export const MOFLO_DIR = '.moflo';
29
16
  export const MEMORY_DB_FILE = 'moflo.db';
@@ -70,199 +57,3 @@ export function memoryDbCandidatePaths(projectRoot) {
70
57
  join(projectRoot, '.claude', LEGACY_MEMORY_DB_FILE),
71
58
  ];
72
59
  }
73
-
74
- /**
75
- * One-time migration of `.claude-flow/` → `.moflo/`. Idempotent — safe to call
76
- * on every session start. See moflo-paths.ts for the full contract.
77
- *
78
- * Returns `{ migrated, reason?, movedCount?, collisions? }`. The launcher
79
- * uses `movedCount` for the "migrated N files" message and `collisions` to
80
- * warn about subdirs (e.g. models/) that exist in both locations.
81
- */
82
- export function migrateClaudeFlowToMoflo(projectRoot) {
83
- const legacy = legacyClaudeFlowDir(projectRoot);
84
- const target = mofloDir(projectRoot);
85
-
86
- if (!existsSync(legacy)) return { migrated: false, reason: 'no-legacy' };
87
-
88
- if (!existsSync(target)) {
89
- let movedCount = 0;
90
- try { movedCount = readdirSync(legacy).length; } catch { /* count is cosmetic */ }
91
- renameSync(legacy, target);
92
- rewriteEmbeddingsModelPath(projectRoot);
93
- return { migrated: true, movedCount };
94
- }
95
-
96
- let entries;
97
- try {
98
- entries = readdirSync(legacy);
99
- } catch {
100
- return { migrated: false, reason: 'legacy-unreadable' };
101
- }
102
-
103
- let moved = 0;
104
- let modelsMoved = false;
105
- const collisions = [];
106
- for (const name of entries) {
107
- const dst = join(target, name);
108
- if (existsSync(dst)) {
109
- collisions.push(name);
110
- continue;
111
- }
112
- try {
113
- renameSync(join(legacy, name), dst);
114
- moved++;
115
- if (name === 'models') modelsMoved = true;
116
- } catch {
117
- // Best-effort — single failed move shouldn't abort the rest.
118
- }
119
- }
120
-
121
- try {
122
- if (readdirSync(legacy).length === 0) rmdirSync(legacy);
123
- } catch {
124
- // Non-fatal — leftover legacy dir means migration runs next time.
125
- }
126
-
127
- if (modelsMoved) rewriteEmbeddingsModelPath(projectRoot);
128
-
129
- if (moved === 0) {
130
- return { migrated: false, reason: 'merged-nothing', movedCount: 0, collisions };
131
- }
132
- return { migrated: true, movedCount: moved, collisions };
133
- }
134
-
135
- /**
136
- * Rewrite `.moflo/embeddings.json:modelPath` if it still references the
137
- * legacy `.claude-flow/` location (#735). Best-effort: file-not-present,
138
- * malformed JSON, missing field, or already-correct path → silent no-op.
139
- * Mirrors the TS twin in src/cli/services/moflo-paths.ts. Uses tmp+rename
140
- * so SIGINT mid-flush can't leave embeddings.json truncated.
141
- */
142
- export function rewriteEmbeddingsModelPath(projectRoot) {
143
- const cfgPath = join(projectRoot, MOFLO_DIR, 'embeddings.json');
144
-
145
- let raw;
146
- try { raw = readFileSync(cfgPath, 'utf8'); } catch { return false; }
147
-
148
- let cfg;
149
- try { cfg = JSON.parse(raw); } catch { return false; }
150
-
151
- if (typeof cfg.modelPath !== 'string') return false;
152
- if (!cfg.modelPath.includes(LEGACY_CLAUDE_FLOW_DIR)) return false;
153
-
154
- cfg.modelPath = cfg.modelPath.split(LEGACY_CLAUDE_FLOW_DIR).join(MOFLO_DIR);
155
-
156
- const tmpPath = `${cfgPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
157
- try {
158
- writeFileSync(tmpPath, JSON.stringify(cfg, null, 2));
159
- renameSync(tmpPath, cfgPath);
160
- return true;
161
- } catch {
162
- try { unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
163
- return false;
164
- }
165
- }
166
-
167
- const SQLITE_MAGIC_HEADER = Buffer.from('SQLite format 3\0', 'utf8');
168
-
169
- function looksLikeSqliteFile(filePath) {
170
- let fd = null;
171
- try {
172
- fd = openSync(filePath, 'r');
173
- const buf = Buffer.alloc(SQLITE_MAGIC_HEADER.length);
174
- const read = readSync(fd, buf, 0, buf.length, 0);
175
- if (read < SQLITE_MAGIC_HEADER.length) return false;
176
- return buf.equals(SQLITE_MAGIC_HEADER);
177
- } catch {
178
- return false;
179
- } finally {
180
- if (fd !== null) try { closeSync(fd); } catch { /* non-fatal */ }
181
- }
182
- }
183
-
184
- function verifyByteEqual(srcPath, dstPath) {
185
- try {
186
- const srcStat = statSync(srcPath);
187
- const dstStat = statSync(dstPath);
188
- if (srcStat.size !== dstStat.size) return false;
189
- const srcBuf = readFileSync(srcPath);
190
- const dstBuf = readFileSync(dstPath);
191
- return srcBuf.equals(dstBuf);
192
- } catch {
193
- return false;
194
- }
195
- }
196
-
197
- function tryUnlink(filePath) {
198
- try { unlinkSync(filePath); } catch { /* non-fatal */ }
199
- }
200
-
201
- function migrateHnswIndex(projectRoot) {
202
- const src = legacyHnswIndexPath(projectRoot);
203
- const dst = hnswIndexPath(projectRoot);
204
-
205
- if (!existsSync(src)) return false;
206
- if (existsSync(dst)) return false;
207
-
208
- try {
209
- mkdirSync(dirname(dst), { recursive: true });
210
- copyFileSync(src, dst);
211
- } catch {
212
- tryUnlink(dst);
213
- return false;
214
- }
215
-
216
- if (!verifyByteEqual(src, dst)) {
217
- tryUnlink(dst);
218
- return false;
219
- }
220
-
221
- tryUnlink(src);
222
- return true;
223
- }
224
-
225
- /**
226
- * One-time relocation of memory DB from `.swarm/memory.db` → `.moflo/moflo.db`
227
- * (story #727). Idempotent. See moflo-paths.ts for the full contract and the
228
- * SQLite-header / byte-equal verification rationale.
229
- *
230
- * MUST run before any long-lived sql.js consumer (MCP server, daemon) opens
231
- * the DB — sql.js dumps the whole snapshot on every flush and would clobber
232
- * the relocated file. Launcher section 0b is the safe boundary.
233
- */
234
- export function migrateMemoryDbToMoflo(projectRoot) {
235
- const target = memoryDbPath(projectRoot);
236
- if (existsSync(target)) return { migrated: false, reason: 'target-exists' };
237
-
238
- const source = legacyMemoryDbPath(projectRoot);
239
- if (!existsSync(source)) return { migrated: false, reason: 'no-legacy' };
240
-
241
- try {
242
- mkdirSync(dirname(target), { recursive: true });
243
- } catch {
244
- return { migrated: false, reason: 'copy-failed' };
245
- }
246
-
247
- try {
248
- copyFileSync(source, target);
249
- } catch {
250
- tryUnlink(target);
251
- return { migrated: false, reason: 'copy-failed' };
252
- }
253
-
254
- if (!verifyByteEqual(source, target) || !looksLikeSqliteFile(target)) {
255
- tryUnlink(target);
256
- return { migrated: false, reason: 'verify-failed' };
257
- }
258
-
259
- const hnswMoved = migrateHnswIndex(projectRoot);
260
-
261
- try {
262
- renameSync(source, legacyMemoryDbBakPath(projectRoot));
263
- } catch {
264
- return { migrated: true, reason: 'rename-failed', hnswMoved };
265
- }
266
-
267
- return { migrated: true, hnswMoved };
268
- }
@@ -217,12 +217,17 @@ export function createProcessManager(root) {
217
217
  if (!isAlive(entry.pid)) continue;
218
218
  try {
219
219
  if (process.platform === 'win32') {
220
- execFileSync('taskkill', ['/T', '/F', '/PID', String(entry.pid)], { windowsHide: true, timeout: 5000 });
220
+ execFileSync('taskkill', ['/T', '/F', '/PID', String(entry.pid)], { windowsHide: true, timeout: 10000 });
221
221
  } else {
222
222
  process.kill(entry.pid, 'SIGTERM');
223
223
  }
224
224
  killed++;
225
- } catch { /* already gone */ }
225
+ } catch {
226
+ // Wrapper call threw (e.g. taskkill exceeded its timeout under load).
227
+ // The process itself may still have been terminated — count it as
228
+ // killed if it is no longer alive.
229
+ if (!isAlive(entry.pid)) killed++;
230
+ }
226
231
  }
227
232
 
228
233
  // Clear registry and lock