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.
- package/.claude/guidance/shipped/moflo-error-handling.md +25 -0
- package/.claude/guidance/shipped/moflo-source-hygiene.md +16 -0
- package/bin/build-embeddings.mjs +31 -14
- package/bin/lib/moflo-paths.mjs +7 -216
- package/bin/lib/process-manager.mjs +7 -2
- package/bin/session-start-launcher.mjs +310 -128
- package/dist/src/cli/commands/memory.js +60 -2
- package/dist/src/cli/services/cherry-pick-learnings.js +209 -0
- package/dist/src/cli/services/moflo-paths.js +12 -256
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -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:
|
package/bin/build-embeddings.mjs
CHANGED
|
@@ -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 => {
|
package/bin/lib/moflo-paths.mjs
CHANGED
|
@@ -1,29 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure-JS counterpart to src/cli/services/moflo-paths.ts
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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:
|
|
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 {
|
|
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
|