monomind 2.7.7 → 2.7.9
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/package.json +3 -2
- package/packages/@monomind/cli/.claude/agents/core/coder.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/coordinator.md +62 -0
- package/packages/@monomind/cli/.claude/agents/core/planner.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/reviewer.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/tester.md +8 -0
- package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +180 -47
- package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +32 -3
- package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +55 -3
- package/packages/@monomind/cli/.claude/helpers/intelligence.cjs +3 -1
- package/packages/@monomind/cli/.claude/helpers/statusline.cjs +27 -3
- package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +104 -18
- package/packages/@monomind/cli/.claude/settings.json +1 -1
- package/packages/@monomind/cli/dist/src/browser/dashboard/server.js +6 -1
- package/packages/@monomind/cli/dist/src/capabilities/index.d.ts +0 -1
- package/packages/@monomind/cli/dist/src/capabilities/index.js +8 -1
- package/packages/@monomind/cli/dist/src/commands/agent-lifecycle.js +5 -1
- package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +8 -1
- package/packages/@monomind/cli/dist/src/commands/guidance.js +8 -2
- package/packages/@monomind/cli/dist/src/commands/init.js +18 -4
- package/packages/@monomind/cli/dist/src/commands/memory-crud.js +7 -1
- package/packages/@monomind/cli/dist/src/commands/org-observe.js +17 -7
- package/packages/@monomind/cli/dist/src/commands/org.d.ts +11 -0
- package/packages/@monomind/cli/dist/src/commands/org.js +162 -15
- package/packages/@monomind/cli/dist/src/commands/security-scan.d.ts +30 -1
- package/packages/@monomind/cli/dist/src/commands/security-scan.js +182 -69
- package/packages/@monomind/cli/dist/src/commands/swarm.js +41 -14
- package/packages/@monomind/cli/dist/src/consensus/audit-writer.js +11 -10
- package/packages/@monomind/cli/dist/src/init/executor.js +138 -22
- package/packages/@monomind/cli/dist/src/init/settings-generator.js +4 -1
- package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.d.ts +17 -0
- package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +62 -3
- package/packages/@monomind/cli/dist/src/mcp-tools/embeddings-tools.js +16 -8
- package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +27 -13
- package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +1 -1
- package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +5 -1
- package/packages/@monomind/cli/dist/src/memory/memory-read.d.ts +7 -2
- package/packages/@monomind/cli/dist/src/memory/memory-read.js +10 -2
- package/packages/@monomind/cli/dist/src/monovector/diff-classifier.js +25 -6
- package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +21 -1
- package/packages/@monomind/cli/dist/src/orgrt/daemon.js +82 -11
- package/packages/@monomind/cli/dist/src/orgrt/inbox.js +53 -20
- package/packages/@monomind/cli/dist/src/parser.d.ts +4 -2
- package/packages/@monomind/cli/dist/src/parser.js +61 -25
- package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +10 -2
- package/packages/@monomind/cli/dist/src/services/config-file-manager.js +10 -2
- package/packages/@monomind/cli/dist/src/ui/collector.mjs +43 -3
- package/packages/@monomind/cli/dist/src/ui/dashboard.html +27 -8
- package/packages/@monomind/cli/dist/src/ui/server.mjs +144 -14
- package/packages/@monomind/cli/package.json +4 -4
- package/packages/@monomind/cli/dist/src/capabilities/watcher.d.ts +0 -18
- package/packages/@monomind/cli/dist/src/capabilities/watcher.js +0 -107
- package/packages/@monomind/cli/dist/src/config-adapter.d.ts +0 -16
- package/packages/@monomind/cli/dist/src/config-adapter.js +0 -220
|
@@ -82,36 +82,50 @@ const knowledgeSearch = {
|
|
|
82
82
|
limit: { type: 'number', description: 'Max results (default: 10)' },
|
|
83
83
|
minScore: { type: 'number', description: 'Minimum similarity threshold (default: 0.3)' },
|
|
84
84
|
surfaces: { type: 'array', items: { type: 'string' }, description: "Override routing: any of 'chunks','kg','rules','memory'" },
|
|
85
|
+
includeSuperseded: { type: 'boolean', description: 'Also return chunks from older, re-ingested versions of a document (flagged superseded). Default false.' },
|
|
85
86
|
},
|
|
86
87
|
required: ['query'],
|
|
87
88
|
},
|
|
88
89
|
handler: async (input) => {
|
|
89
90
|
const { searchKnowledge } = await import('../knowledge/document-pipeline.js');
|
|
90
|
-
const { routeQuery, rrfFuse } = await import('../memory/query-router.js');
|
|
91
|
+
const { routeQuery, rrfFuse, recordRouteOverride } = await import('../memory/query-router.js');
|
|
91
92
|
try {
|
|
92
93
|
const query = String(input.query);
|
|
93
94
|
const limit = input.limit ? Number(input.limit) : 10;
|
|
94
95
|
const route = routeQuery(query);
|
|
95
|
-
const
|
|
96
|
+
const explicitSurfaces = Array.isArray(input.surfaces) && input.surfaces.length
|
|
96
97
|
? input.surfaces
|
|
97
|
-
:
|
|
98
|
+
: null;
|
|
99
|
+
const surfaces = explicitSurfaces
|
|
100
|
+
?? (route.confident ? route.surfaces : ['chunks', ...route.surfaces.filter(s => s !== 'chunks')]);
|
|
101
|
+
const chunkOpts = {
|
|
102
|
+
scope: input.scope ? String(input.scope) : undefined,
|
|
103
|
+
limit,
|
|
104
|
+
minScore: input.minScore ? Number(input.minScore) : undefined,
|
|
105
|
+
includeSuperseded: input.includeSuperseded === true,
|
|
106
|
+
};
|
|
98
107
|
const bridge = await import('../memory/memory-bridge.js');
|
|
99
108
|
const kg = await import('../memory/memory-kg.js');
|
|
100
109
|
const [excerpts, graph, rules, memories] = await Promise.all([
|
|
101
|
-
surfaces.includes('chunks')
|
|
102
|
-
? searchKnowledge(query, {
|
|
103
|
-
scope: input.scope ? String(input.scope) : undefined,
|
|
104
|
-
limit,
|
|
105
|
-
minScore: input.minScore ? Number(input.minScore) : undefined,
|
|
106
|
-
})
|
|
107
|
-
: [],
|
|
110
|
+
surfaces.includes('chunks') ? searchKnowledge(query, chunkOpts) : [],
|
|
108
111
|
surfaces.includes('kg') ? kg.kgSearch({ query, limit: 6 }) : null,
|
|
109
112
|
surfaces.includes('rules') ? bridge.bridgeSearchEntries({ query, namespace: 'rules', limit: 3, threshold: 0.35 }) : null,
|
|
110
113
|
surfaces.includes('memory') ? bridge.bridgeSearchEntries({ query, namespace: 'patterns', limit: 3 }) : null,
|
|
111
114
|
]);
|
|
115
|
+
// Confident non-chunk routing against an empty surface (e.g. a project
|
|
116
|
+
// with no KG yet) must not read as "no knowledge" — fall back to chunks.
|
|
117
|
+
// Same rule the CLI `doc search` path applies; without it agents got
|
|
118
|
+
// "no results" where the CLI returned document excerpts.
|
|
119
|
+
let fellBack = false;
|
|
120
|
+
let chunkExcerpts = excerpts;
|
|
121
|
+
if (!explicitSurfaces && !chunkExcerpts.length && !(graph?.triplets?.length) && !(rules?.results?.length) && !(memories?.results?.length) && !surfaces.includes('chunks')) {
|
|
122
|
+
fellBack = true;
|
|
123
|
+
recordRouteOverride(surfaces[0], 'chunks');
|
|
124
|
+
chunkExcerpts = await searchKnowledge(query, chunkOpts);
|
|
125
|
+
}
|
|
112
126
|
// Rank-fuse heterogeneous lists (raw scores aren't comparable).
|
|
113
127
|
const fused = rrfFuse([
|
|
114
|
-
|
|
128
|
+
chunkExcerpts.map(e => ({ id: e.id || `${e.filePath}#${e.chunkIndex}`, kind: 'excerpt', ...e })),
|
|
115
129
|
(graph?.triplets ?? []).map((t, i) => ({ id: `kg:${i}:${t.source}|${t.relation}|${t.target}`, kind: 'triplet', ...t })),
|
|
116
130
|
(rules?.results ?? []).map(r => ({ id: r.id, kind: 'rule', key: r.key, text: r.content, importance: 0.7 })),
|
|
117
131
|
(memories?.results ?? []).map(r => ({ id: r.id, kind: 'memory', key: r.key, text: r.content })),
|
|
@@ -122,10 +136,10 @@ const knowledgeSearch = {
|
|
|
122
136
|
text: JSON.stringify({
|
|
123
137
|
success: true,
|
|
124
138
|
count: fused.length,
|
|
125
|
-
routing: { surfaces, confident: route.confident },
|
|
139
|
+
routing: { surfaces, confident: route.confident, fellBackToChunks: fellBack },
|
|
126
140
|
results: fused,
|
|
127
141
|
// Back-compat: excerpt-only view for existing consumers.
|
|
128
|
-
excerpts,
|
|
142
|
+
excerpts: chunkExcerpts,
|
|
129
143
|
}),
|
|
130
144
|
}],
|
|
131
145
|
};
|
|
@@ -67,7 +67,7 @@ export declare function bridgeSearchEntries(options: {
|
|
|
67
67
|
* vector path was attempted and did not produce the results. */
|
|
68
68
|
searchMethod?: 'semantic' | 'keyword' | 'keyword-fallback';
|
|
69
69
|
/** Why the vector path did not serve these results (absent when it did). */
|
|
70
|
-
fallbackReason?: 'no-embedding-model' | 'embedding-failed' | 'no-semantic-matches';
|
|
70
|
+
fallbackReason?: 'no-embedding-model' | 'empty-query' | 'embedding-failed' | 'no-semantic-matches';
|
|
71
71
|
error?: string;
|
|
72
72
|
} | null>;
|
|
73
73
|
export declare function bridgeListEntries(options: {
|
|
@@ -407,7 +407,11 @@ export async function bridgeSearchEntries(options) {
|
|
|
407
407
|
let results = [];
|
|
408
408
|
let searchMethod = 'keyword';
|
|
409
409
|
// Reported to callers so "(semantic)" can never be printed over keyword hits.
|
|
410
|
-
|
|
410
|
+
// The two reasons for skipping the vector path are distinct and must not be
|
|
411
|
+
// conflated: a healthy model given an empty query is not a missing model.
|
|
412
|
+
let fallbackReason = !_embedder ? 'no-embedding-model'
|
|
413
|
+
: queryStr.length === 0 ? 'empty-query'
|
|
414
|
+
: undefined;
|
|
411
415
|
let semanticAttempted = false;
|
|
412
416
|
if (_embedder && queryStr.length > 0) {
|
|
413
417
|
semanticAttempted = true;
|
|
@@ -26,8 +26,13 @@ export declare function searchEntries(options: {
|
|
|
26
26
|
}[];
|
|
27
27
|
searchTime: number;
|
|
28
28
|
/** What actually ran — propagated from the bridge so callers can report the
|
|
29
|
-
* real method instead of echoing the requested one.
|
|
30
|
-
|
|
29
|
+
* real method instead of echoing the requested one.
|
|
30
|
+
*
|
|
31
|
+
* 'hash-vector'/'hash-hybrid' are the sql.js paths running on
|
|
32
|
+
* generateEmbedding()'s deterministic hash fallback: a vector search was
|
|
33
|
+
* performed, but over hashes with no semantic content, so neither may be
|
|
34
|
+
* reported as 'semantic'/'hybrid'. */
|
|
35
|
+
searchMethod?: 'semantic' | 'keyword' | 'keyword-fallback' | 'hybrid' | 'hash-vector' | 'hash-hybrid';
|
|
31
36
|
fallbackReason?: string;
|
|
32
37
|
error?: string;
|
|
33
38
|
}>;
|
|
@@ -59,6 +59,11 @@ export async function searchEntries(options) {
|
|
|
59
59
|
}
|
|
60
60
|
const queryEmb = await generateEmbedding(query);
|
|
61
61
|
const queryEmbedding = queryEmb.embedding;
|
|
62
|
+
// generateEmbedding() never fails — when no ONNX model can be loaded it
|
|
63
|
+
// silently returns generateHashEmbedding() output tagged 'hash-fallback'.
|
|
64
|
+
// Cosine over those hashes is a deterministic lexical trick with no
|
|
65
|
+
// semantic content, so the methods below must not claim otherwise.
|
|
66
|
+
const realVectors = queryEmb.model !== 'hash-fallback';
|
|
62
67
|
// Try HNSW search first (150x faster)
|
|
63
68
|
const hnswResults = await searchHNSWIndex(queryEmbedding, { k: limit, namespace: effectiveNamespace });
|
|
64
69
|
if (hnswResults && hnswResults.length > 0) {
|
|
@@ -67,7 +72,8 @@ export async function searchEntries(options) {
|
|
|
67
72
|
success: true,
|
|
68
73
|
results: filtered,
|
|
69
74
|
searchTime: Date.now() - startTime,
|
|
70
|
-
searchMethod: 'semantic'
|
|
75
|
+
searchMethod: realVectors ? 'semantic' : 'hash-vector',
|
|
76
|
+
...(realVectors ? {} : { fallbackReason: 'no-embedding-model' })
|
|
71
77
|
};
|
|
72
78
|
}
|
|
73
79
|
// Fall back to brute-force SQLite search
|
|
@@ -130,7 +136,9 @@ export async function searchEntries(options) {
|
|
|
130
136
|
results: results.slice(0, limit),
|
|
131
137
|
searchTime: Date.now() - startTime,
|
|
132
138
|
// Per-row: cosine when the entry had a vector, keyword overlap otherwise.
|
|
133
|
-
|
|
139
|
+
// With hash-fallback vectors the cosine half is not semantic either.
|
|
140
|
+
searchMethod: realVectors ? 'hybrid' : 'hash-hybrid',
|
|
141
|
+
...(realVectors ? {} : { fallbackReason: 'no-embedding-model' })
|
|
134
142
|
};
|
|
135
143
|
}
|
|
136
144
|
catch (error) {
|
|
@@ -352,12 +352,15 @@ export function getGitDiffNumstat(ref = 'HEAD') {
|
|
|
352
352
|
try {
|
|
353
353
|
// SECURITY: Use execFileSync with args array instead of shell string
|
|
354
354
|
// This prevents command injection via the ref parameter
|
|
355
|
+
// stdio must be fully piped: execFileSync otherwise forwards git's stderr
|
|
356
|
+
// straight to the parent's terminal AND leaves err.stderr null, so the
|
|
357
|
+
// thrown error below would carry no explanation of what git objected to.
|
|
355
358
|
const numstatOutput = execFileSync('git', [
|
|
356
359
|
'diff', '--numstat', '--diff-filter=ACDMRTUXB', ref
|
|
357
|
-
], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
360
|
+
], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
358
361
|
const statusOutput = execFileSync('git', [
|
|
359
362
|
'diff', '--name-status', ref
|
|
360
|
-
], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
363
|
+
], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
361
364
|
const output = numstatOutput + '---STATUS---' + statusOutput;
|
|
362
365
|
const [numstatPart, statusPart] = output.split('---STATUS---');
|
|
363
366
|
// Parse status (usually smaller, parse first)
|
|
@@ -411,10 +414,25 @@ export function getGitDiffNumstat(ref = 'HEAD') {
|
|
|
411
414
|
diffCache.set(cacheKey, { files, timestamp: Date.now() });
|
|
412
415
|
return files;
|
|
413
416
|
}
|
|
414
|
-
catch {
|
|
415
|
-
return []
|
|
417
|
+
catch (e) {
|
|
418
|
+
// Do NOT return [] here. An empty array is indistinguishable from a genuinely
|
|
419
|
+
// clean diff, so a broken git invocation (not a repo, bad ref, git missing)
|
|
420
|
+
// used to surface as "low risk, 0 files changed". Callers (analyze_* MCP
|
|
421
|
+
// tools) already convert a throw into an explicit error response.
|
|
422
|
+
throw new Error(`git diff failed for ref "${ref}": ${describeGitError(e)}`);
|
|
416
423
|
}
|
|
417
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* Extract a useful message from an execFile error, preferring git's own stderr.
|
|
427
|
+
*/
|
|
428
|
+
function describeGitError(e) {
|
|
429
|
+
const err = e;
|
|
430
|
+
const stderr = err && typeof err.stderr === 'string' ? err.stderr.trim() : '';
|
|
431
|
+
if (stderr)
|
|
432
|
+
return stderr.split('\n')[0].slice(0, 300);
|
|
433
|
+
const msg = err && typeof err.message === 'string' ? err.message : String(e);
|
|
434
|
+
return msg.slice(0, 300);
|
|
435
|
+
}
|
|
418
436
|
/**
|
|
419
437
|
* Async version of getGitDiffNumstat for non-blocking operation
|
|
420
438
|
*/
|
|
@@ -480,8 +498,9 @@ export async function getGitDiffNumstatAsync(ref = 'HEAD') {
|
|
|
480
498
|
diffCache.set(cacheKey, { files, timestamp: Date.now() });
|
|
481
499
|
return files;
|
|
482
500
|
}
|
|
483
|
-
catch {
|
|
484
|
-
|
|
501
|
+
catch (e) {
|
|
502
|
+
// See getGitDiffNumstat: a git failure must not look like a clean diff.
|
|
503
|
+
throw new Error(`git diff failed for ref "${ref}": ${describeGitError(e)}`);
|
|
485
504
|
}
|
|
486
505
|
}
|
|
487
506
|
/**
|
|
@@ -53,6 +53,9 @@ export declare class OrgDaemon {
|
|
|
53
53
|
subscribe(fn: (e: BusEvent) => void): () => void;
|
|
54
54
|
listOrgs(): RunningOrg[];
|
|
55
55
|
getOrg(name: string): RunningOrg | undefined;
|
|
56
|
+
/** Names of the orgs this daemon currently has running. Snapshot — safe to
|
|
57
|
+
* iterate while stopOrg() mutates the underlying map. */
|
|
58
|
+
listRunning(): string[];
|
|
56
59
|
startOrg(name: string, taskOverride?: string): Promise<RunningOrg>;
|
|
57
60
|
/** A role that failed its resource gate at boot isn't abandoned — keep polling
|
|
58
61
|
* for capacity in the background (bounded) and spawn it the moment resources
|
|
@@ -96,7 +99,16 @@ export declare class OrgDaemon {
|
|
|
96
99
|
* running, pushes straight into the role's live mailbox (picked up on its very next
|
|
97
100
|
* generator tick — see Mailbox.stream()). If the org has since stopped, queues the
|
|
98
101
|
* answer via the same offline fallback deliver()/receiveRemote() already use
|
|
99
|
-
* (inbox.ts + autoWake) and it's delivered when the org next starts.
|
|
102
|
+
* (inbox.ts + autoWake) and it's delivered when the org next starts.
|
|
103
|
+
*
|
|
104
|
+
* PERSIST-AFTER-DELIVERY: questions.json is only marked answered once delivery has
|
|
105
|
+
* actually happened (mailbox push, or the message landing in inbox.jsonl). Marking it
|
|
106
|
+
* first meant a rejected delivery — unknown role, mailbox closed mid-shutdown, a
|
|
107
|
+
* queueMessage that threw on a full/read-only disk — left the question recorded as
|
|
108
|
+
* answered while nobody ever received the answer, and the `already answered` guard
|
|
109
|
+
* then refused every retry. The answer was simply gone. The inverse failure (crash
|
|
110
|
+
* between delivery and the write) merely re-shows the question as pending, which a
|
|
111
|
+
* human can act on; a silently swallowed answer is not recoverable. */
|
|
100
112
|
answerQuestion(org: string, role: string, questionId: string, answer: string): Promise<{
|
|
101
113
|
ok: true;
|
|
102
114
|
} | {
|
|
@@ -150,6 +162,14 @@ export declare class OrgDaemon {
|
|
|
150
162
|
* future runs) can find it by meaning, not just recency. Best-effort. */
|
|
151
163
|
private storeRunMemory;
|
|
152
164
|
private persistState;
|
|
165
|
+
/** Mark every currently-running org as crashed in runtime.json.
|
|
166
|
+
* Called from process-level crash handlers — must be synchronous and best-effort. */
|
|
167
|
+
persistCrashStateAll(): void;
|
|
168
|
+
private heartbeatPath;
|
|
169
|
+
/** Write a heartbeat file so `org status` can distinguish "daemon alive" from
|
|
170
|
+
* "daemon gone" even when runtime.json still says running. */
|
|
171
|
+
writeHeartbeat(): void;
|
|
172
|
+
clearHeartbeat(): void;
|
|
153
173
|
}
|
|
154
174
|
export {};
|
|
155
175
|
//# sourceMappingURL=daemon.d.ts.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// packages/@monomind/cli/src/orgrt/daemon.ts
|
|
2
2
|
// monolean: single-process inter-org — upgrade path = daemon-to-daemon HTTP when multi-host is real
|
|
3
|
-
import { readFileSync, mkdirSync, writeFileSync, existsSync, renameSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync, renameSync, unlinkSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { reapOrphanedSdkProcesses } from '../utils/resource-governor.js';
|
|
6
6
|
import { OrgBus } from './bus.js';
|
|
@@ -36,6 +36,9 @@ export class OrgDaemon {
|
|
|
36
36
|
}
|
|
37
37
|
listOrgs() { return [...this.orgs.values()]; }
|
|
38
38
|
getOrg(name) { return this.orgs.get(name); }
|
|
39
|
+
/** Names of the orgs this daemon currently has running. Snapshot — safe to
|
|
40
|
+
* iterate while stopOrg() mutates the underlying map. */
|
|
41
|
+
listRunning() { return [...this.orgs.keys()]; }
|
|
39
42
|
async startOrg(name, taskOverride) {
|
|
40
43
|
if (this.orgs.has(name))
|
|
41
44
|
throw new Error(`org ${name} already running`);
|
|
@@ -500,7 +503,16 @@ export class OrgDaemon {
|
|
|
500
503
|
* running, pushes straight into the role's live mailbox (picked up on its very next
|
|
501
504
|
* generator tick — see Mailbox.stream()). If the org has since stopped, queues the
|
|
502
505
|
* answer via the same offline fallback deliver()/receiveRemote() already use
|
|
503
|
-
* (inbox.ts + autoWake) and it's delivered when the org next starts.
|
|
506
|
+
* (inbox.ts + autoWake) and it's delivered when the org next starts.
|
|
507
|
+
*
|
|
508
|
+
* PERSIST-AFTER-DELIVERY: questions.json is only marked answered once delivery has
|
|
509
|
+
* actually happened (mailbox push, or the message landing in inbox.jsonl). Marking it
|
|
510
|
+
* first meant a rejected delivery — unknown role, mailbox closed mid-shutdown, a
|
|
511
|
+
* queueMessage that threw on a full/read-only disk — left the question recorded as
|
|
512
|
+
* answered while nobody ever received the answer, and the `already answered` guard
|
|
513
|
+
* then refused every retry. The answer was simply gone. The inverse failure (crash
|
|
514
|
+
* between delivery and the write) merely re-shows the question as pending, which a
|
|
515
|
+
* human can act on; a silently swallowed answer is not recoverable. */
|
|
504
516
|
async answerQuestion(org, role, questionId, answer) {
|
|
505
517
|
const data = this.readQuestions(org);
|
|
506
518
|
const idx = data.questions.findIndex(q => q.questionId === questionId);
|
|
@@ -508,8 +520,19 @@ export class OrgDaemon {
|
|
|
508
520
|
return { ok: false, error: `question "${questionId}" not found for org "${org}"` };
|
|
509
521
|
if (data.questions[idx].answer !== null)
|
|
510
522
|
return { ok: false, error: `question "${questionId}" already answered` };
|
|
511
|
-
|
|
512
|
-
|
|
523
|
+
const question = data.questions[idx].question;
|
|
524
|
+
// Applied to questions.json ONLY after the delivery below succeeds.
|
|
525
|
+
const markAnswered = () => {
|
|
526
|
+
// Re-read so a question the daemon appended (or answered) meanwhile isn't
|
|
527
|
+
// clobbered by this stale snapshot — merge by questionId, never replace.
|
|
528
|
+
const fresh = this.readQuestions(org);
|
|
529
|
+
const fIdx = fresh.questions.findIndex(q => q.questionId === questionId);
|
|
530
|
+
if (fIdx === -1)
|
|
531
|
+
fresh.questions.push({ ...data.questions[idx], answer, answeredAt: Date.now() });
|
|
532
|
+
else
|
|
533
|
+
fresh.questions[fIdx] = { ...fresh.questions[fIdx], answer, answeredAt: Date.now() };
|
|
534
|
+
this.writeQuestions(org, fresh);
|
|
535
|
+
};
|
|
513
536
|
const running = this.orgs.get(org);
|
|
514
537
|
if (running) {
|
|
515
538
|
// Org IS running — deliver or report a real error, but never fall through to the
|
|
@@ -523,20 +546,32 @@ export class OrgDaemon {
|
|
|
523
546
|
return { ok: false, error: `role "${role}" not found in org "${org}"` };
|
|
524
547
|
if (agent.mailbox.isClosed)
|
|
525
548
|
return { ok: false, error: `role "${role}" in org "${org}" is shutting down — answer not delivered` };
|
|
549
|
+
try {
|
|
550
|
+
agent.mailbox.push(`[answer from human] question: ${question}\n\nanswer: ${answer}`);
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
return { ok: false, error: `delivery to "${role}" in org "${org}" failed — answer not recorded (${err instanceof Error ? err.message : String(err)})` };
|
|
554
|
+
}
|
|
555
|
+
markAnswered();
|
|
526
556
|
running.bus.emit({ type: 'status', from: role, msg: 'question answered', data: { questionId } });
|
|
527
|
-
agent.mailbox.push(`[answer from human] question: ${data.questions[idx].question}\n\nanswer: ${answer}`);
|
|
528
557
|
return { ok: true };
|
|
529
558
|
}
|
|
530
559
|
// Org not running at all — queue for delivery on next start, matching deliver()'s
|
|
531
560
|
// existing offline fallback exactly (inbox.ts + autoWake).
|
|
532
561
|
if (!this.hasOrgDef(org))
|
|
533
562
|
return { ok: false, error: `org "${org}" not found (no saved definition)` };
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
563
|
+
try {
|
|
564
|
+
queueMessage(this.root, org, {
|
|
565
|
+
fromQualified: 'human', toRole: role,
|
|
566
|
+
subject: `answer:${questionId}`,
|
|
567
|
+
body: `question: ${question}\n\nanswer: ${answer}`,
|
|
568
|
+
ts: Date.now(),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
return { ok: false, error: `could not queue answer for org "${org}" — answer not recorded (${err instanceof Error ? err.message : String(err)})` };
|
|
573
|
+
}
|
|
574
|
+
markAnswered();
|
|
540
575
|
this.autoWake(org);
|
|
541
576
|
return { ok: true };
|
|
542
577
|
}
|
|
@@ -890,5 +925,41 @@ export class OrgDaemon {
|
|
|
890
925
|
const p = join(this.root, ORG_DIR, name, 'runtime.json');
|
|
891
926
|
writeFileSync(p, JSON.stringify({ status, run, pid: process.pid, updated: new Date().toISOString() }, null, 2));
|
|
892
927
|
}
|
|
928
|
+
/** Mark every currently-running org as crashed in runtime.json.
|
|
929
|
+
* Called from process-level crash handlers — must be synchronous and best-effort. */
|
|
930
|
+
persistCrashStateAll() {
|
|
931
|
+
for (const [name, org] of this.orgs) {
|
|
932
|
+
try {
|
|
933
|
+
const p = join(this.root, ORG_DIR, name, 'runtime.json');
|
|
934
|
+
writeFileSync(p, JSON.stringify({
|
|
935
|
+
status: 'crashed', run: org.run, pid: process.pid,
|
|
936
|
+
updated: new Date().toISOString(), closedBy: 'crash-handler',
|
|
937
|
+
}, null, 2));
|
|
938
|
+
}
|
|
939
|
+
catch { /* best effort — filesystem may be unavailable */ }
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
heartbeatPath() {
|
|
943
|
+
return join(this.root, '.monomind', 'serve-heartbeat.json');
|
|
944
|
+
}
|
|
945
|
+
/** Write a heartbeat file so `org status` can distinguish "daemon alive" from
|
|
946
|
+
* "daemon gone" even when runtime.json still says running. */
|
|
947
|
+
writeHeartbeat() {
|
|
948
|
+
try {
|
|
949
|
+
const p = this.heartbeatPath();
|
|
950
|
+
mkdirSync(join(this.root, '.monomind'), { recursive: true });
|
|
951
|
+
writeFileSync(p, JSON.stringify({
|
|
952
|
+
pid: process.pid, updatedAt: new Date().toISOString(),
|
|
953
|
+
running: this.listRunning(),
|
|
954
|
+
}, null, 2));
|
|
955
|
+
}
|
|
956
|
+
catch { /* best effort */ }
|
|
957
|
+
}
|
|
958
|
+
clearHeartbeat() {
|
|
959
|
+
try {
|
|
960
|
+
unlinkSync(this.heartbeatPath());
|
|
961
|
+
}
|
|
962
|
+
catch { /* already gone or never written */ }
|
|
963
|
+
}
|
|
893
964
|
}
|
|
894
965
|
//# sourceMappingURL=daemon.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// packages/@monomind/cli/src/orgrt/inbox.ts
|
|
2
2
|
// Persistent message queue for offline orgs. Messages that can't be delivered
|
|
3
3
|
// (target org not running) are spooled here and drained when the org starts.
|
|
4
|
-
import { appendFileSync, readFileSync,
|
|
4
|
+
import { appendFileSync, readFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { ORG_DIR } from './types.js';
|
|
7
7
|
function inboxPath(root, orgName) {
|
|
@@ -12,37 +12,70 @@ export function queueMessage(root, orgName, msg) {
|
|
|
12
12
|
mkdirSync(dir, { recursive: true });
|
|
13
13
|
appendFileSync(inboxPath(root, orgName), JSON.stringify(msg) + '\n');
|
|
14
14
|
}
|
|
15
|
+
function parseLines(raw) {
|
|
16
|
+
const msgs = [];
|
|
17
|
+
for (const line of raw.trim().split('\n')) {
|
|
18
|
+
if (!line)
|
|
19
|
+
continue;
|
|
20
|
+
try {
|
|
21
|
+
msgs.push(JSON.parse(line));
|
|
22
|
+
}
|
|
23
|
+
catch { /* skip corrupt lines */ }
|
|
24
|
+
}
|
|
25
|
+
return msgs;
|
|
26
|
+
}
|
|
15
27
|
export function drainInbox(root, orgName) {
|
|
16
28
|
const path = inboxPath(root, orgName);
|
|
29
|
+
const draining = `${path}.draining`;
|
|
30
|
+
const msgs = [];
|
|
31
|
+
// Recover a .draining file left behind by a mid-drain crash. Without this the
|
|
32
|
+
// rename below would overwrite it and lose exactly the messages the rename-then-read
|
|
33
|
+
// scheme exists to protect.
|
|
34
|
+
if (existsSync(draining)) {
|
|
35
|
+
try {
|
|
36
|
+
msgs.push(...parseLines(readFileSync(draining, 'utf8')));
|
|
37
|
+
unlinkSync(draining);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
|
|
41
|
+
console.error('[inbox] drainInbox recovery of .draining failed:', e);
|
|
42
|
+
return msgs; // don't rename over a file we couldn't consume
|
|
43
|
+
}
|
|
44
|
+
}
|
|
17
45
|
if (!existsSync(path))
|
|
18
|
-
return
|
|
46
|
+
return msgs;
|
|
19
47
|
// Rename-then-read: if the process crashes after rename but before we finish
|
|
20
|
-
// reading, the .draining file survives
|
|
21
|
-
// read-then-truncate would lose messages on a mid-drain crash.
|
|
22
|
-
const draining = `${path}.draining`;
|
|
48
|
+
// reading, the .draining file survives (and is recovered above on the next
|
|
49
|
+
// drain). A plain read-then-truncate would lose messages on a mid-drain crash.
|
|
23
50
|
try {
|
|
24
51
|
renameSync(path, draining);
|
|
25
52
|
}
|
|
26
53
|
catch (e) {
|
|
27
54
|
if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
|
|
28
55
|
console.error('[inbox] drainInbox rename failed:', e);
|
|
29
|
-
return
|
|
56
|
+
return msgs;
|
|
30
57
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
renameSync(draining, path);
|
|
35
|
-
return [];
|
|
58
|
+
let raw = '';
|
|
59
|
+
try {
|
|
60
|
+
raw = readFileSync(draining, 'utf8');
|
|
36
61
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
catch (e) {
|
|
63
|
+
if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
|
|
64
|
+
console.error('[inbox] drainInbox read failed:', e);
|
|
65
|
+
return msgs;
|
|
66
|
+
}
|
|
67
|
+
msgs.push(...parseLines(raw));
|
|
68
|
+
// Unlink the drained snapshot rather than truncating it and renaming it back over
|
|
69
|
+
// `path`. A sender that appended between the rename above and this point created a
|
|
70
|
+
// fresh `path` (queueMessage mkdir+appends), and renaming the emptied snapshot back
|
|
71
|
+
// would clobber it — destroying messages whose sender already got a "queued" receipt.
|
|
72
|
+
// Those messages now simply stay in `path` and are picked up by the next drain.
|
|
73
|
+
try {
|
|
74
|
+
unlinkSync(draining);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
|
|
78
|
+
console.error('[inbox] drainInbox unlink failed:', e);
|
|
46
79
|
}
|
|
47
80
|
return msgs;
|
|
48
81
|
}
|
|
@@ -74,12 +74,14 @@ export declare class CommandParser {
|
|
|
74
74
|
* fixing collisions where multiple subcommands use the same short flag (e.g. -t).
|
|
75
75
|
*/
|
|
76
76
|
private buildScopedAliases;
|
|
77
|
+
/** Normalize a single command or a resolved chain into an array. */
|
|
78
|
+
private static asChain;
|
|
77
79
|
/**
|
|
78
|
-
* Get boolean flags scoped to a specific command/subcommand.
|
|
80
|
+
* Get boolean flags scoped to a specific command/subcommand chain.
|
|
79
81
|
*/
|
|
80
82
|
private getScopedBooleanFlags;
|
|
81
83
|
/**
|
|
82
|
-
* Get flags declared `type: 'array'`, scoped to a specific command/subcommand.
|
|
84
|
+
* Get flags declared `type: 'array'`, scoped to a specific command/subcommand chain.
|
|
83
85
|
*/
|
|
84
86
|
private getScopedArrayFlags;
|
|
85
87
|
private getBooleanFlags;
|