@adhdev/daemon-core 0.9.82-rc.413 → 0.9.82-rc.415
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/dist/cli-adapters/resolve-executable.d.ts +55 -0
- package/dist/config/mesh-config.d.ts +15 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +194 -20
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +190 -20
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-coordinator.d.ts +1 -1
- package/dist/mesh/mesh-events.d.ts +1 -1
- package/dist/mesh/mesh-queue-assignment.d.ts +18 -0
- package/dist/mesh/mesh-work-queue.d.ts +9 -0
- package/dist/repo-mesh-types.d.ts +8 -0
- package/package.json +2 -2
- package/src/cli-adapters/resolve-executable.ts +117 -3
- package/src/commands/med-family/mesh-queue.ts +23 -1
- package/src/config/mesh-config.ts +116 -0
- package/src/index.ts +10 -0
- package/src/mesh/mesh-event-forwarding.ts +23 -7
- package/src/mesh/mesh-events-coordinator.ts +1 -0
- package/src/mesh/mesh-events-pending.ts +15 -0
- package/src/mesh/mesh-events.ts +1 -0
- package/src/mesh/mesh-queue-assignment.ts +24 -0
- package/src/mesh/mesh-refine-gates.ts +12 -5
- package/src/mesh/mesh-work-queue.ts +13 -0
- package/src/mesh/worktree-bootstrap-config.ts +6 -2
- package/src/repo-mesh-types.ts +8 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { isMeshCoordinatorEvent, MESH_FORCE_INJECT_EVENTS, shouldForceInjectMeshEvent, } from './mesh-event-classify.js';
|
|
2
|
-
export { __orderEligibleNodesForTests, __resolveSchedulingStrategyForTests, __resetIdleAutoFastForwardForTests, activeReadonlyAssignedCount, activeWriteAssignedCount, triggerMeshQueue, tryAssignQueueTask, } from './mesh-queue-assignment.js';
|
|
2
|
+
export { __orderEligibleNodesForTests, __resolveSchedulingStrategyForTests, __resetIdleAutoFastForwardForTests, activeReadonlyAssignedCount, activeWriteAssignedCount, isSessionActivelyGenerating, triggerMeshQueue, tryAssignQueueTask, } from './mesh-queue-assignment.js';
|
|
3
3
|
export type { MeshQueueTriggerResult } from './mesh-queue-assignment.js';
|
|
4
4
|
export { __resetMeshWorkspaceCacheForTests, buildRelayMetadataEvent, handleMeshForwardEvent, resolveForwardEventMeshId, setupMeshEventForwarding, } from './mesh-event-forwarding.js';
|
|
@@ -3,4 +3,4 @@ export { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, ge
|
|
|
3
3
|
export { reconcileDirectDispatchCompletionFromTranscript, } from './mesh-events-stale.js';
|
|
4
4
|
export { setupMeshReconcileLoop, runMeshReconcileTick, resolveCoordinatorDrainDeliverability, shouldHoldPendingDrainForBusyLocalCoordinator, } from './mesh-reconcile-loop.js';
|
|
5
5
|
export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
|
|
6
|
-
export { tryAssignQueueTask, triggerMeshQueue, handleMeshForwardEvent, setupMeshEventForwarding, isMeshCoordinatorEvent, __resetIdleAutoFastForwardForTests, __resetMeshWorkspaceCacheForTests, } from './mesh-events-coordinator.js';
|
|
6
|
+
export { tryAssignQueueTask, isSessionActivelyGenerating, triggerMeshQueue, handleMeshForwardEvent, setupMeshEventForwarding, isMeshCoordinatorEvent, __resetIdleAutoFastForwardForTests, __resetMeshWorkspaceCacheForTests, } from './mesh-events-coordinator.js';
|
|
@@ -47,6 +47,24 @@ export declare function __orderEligibleNodesForTests(meshId: string, strategy: R
|
|
|
47
47
|
bumpCursor?: boolean;
|
|
48
48
|
}): RankableNode[];
|
|
49
49
|
export declare function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* CANON-IDENTITY single-flight hardening (restart-safe, observation-based).
|
|
52
|
+
*
|
|
53
|
+
* The in-memory single-flight Set (mesh-task-inflight) is process-local and is LOST on a
|
|
54
|
+
* daemon restart — after a restart, a task still being generated by a live local worker is
|
|
55
|
+
* no longer marked in-flight, so requeueTask's Set check passes and would re-open the task
|
|
56
|
+
* for a duplicate second dispatch. This recovers the "still generating" signal from
|
|
57
|
+
* observable runtime state instead of the in-memory mark: a session is actively generating
|
|
58
|
+
* when its live local CLI instance reports an active (generating/streaming/…) status — the
|
|
59
|
+
* same predicate the dispatch active-work gate uses (sessionStateLooksActive).
|
|
60
|
+
*
|
|
61
|
+
* Local-only by design: it inspects THIS daemon's instanceManager. The primary cross-process
|
|
62
|
+
* fix (IpcTransport requeue delegating to the mesh-host daemon) keeps begin (dispatch) and
|
|
63
|
+
* check (requeue guard) co-located so the in-memory mark stays authoritative in the common
|
|
64
|
+
* path; this is the restart-safety net for sessions hosted on this daemon. A genuinely
|
|
65
|
+
* dead/stale session is not generating → returns false → the requeue proceeds as before.
|
|
66
|
+
*/
|
|
67
|
+
export declare function isSessionActivelyGenerating(components: DaemonComponents, sessionId: string): boolean;
|
|
50
68
|
export interface MeshQueueTriggerResult {
|
|
51
69
|
success: true;
|
|
52
70
|
meshId: string;
|
|
@@ -65,6 +65,13 @@ export interface MeshWorkQueueEntry {
|
|
|
65
65
|
dependsOn?: string[];
|
|
66
66
|
/** M1/M3: mission this task belongs to (joins mesh_missions). */
|
|
67
67
|
missionId?: string;
|
|
68
|
+
/**
|
|
69
|
+
* MAGI: consensus group id shared by every replica of one mesh_magi_review
|
|
70
|
+
* fan-out. Marks the task as part of an INTENTIONAL same-prompt quorum so the
|
|
71
|
+
* completion-event dedup (mesh-events-pending) never collapses grouped
|
|
72
|
+
* replicas. Absent on ordinary tasks. Rides in the payload JSON (no column).
|
|
73
|
+
*/
|
|
74
|
+
consensusGroupId?: string;
|
|
68
75
|
/**
|
|
69
76
|
* M1: why this task is held back (e.g. "dependency_failed:<taskId>").
|
|
70
77
|
* Only set by the system on dependency failure under the 'block' policy;
|
|
@@ -177,6 +184,8 @@ export declare function enqueueTask(meshId: string, message: string, opts?: {
|
|
|
177
184
|
dependsOn?: string[];
|
|
178
185
|
/** M1/M3: mission this task belongs to. */
|
|
179
186
|
missionId?: string;
|
|
187
|
+
/** MAGI: consensus group id shared by every replica of a mesh_magi_review fan-out. */
|
|
188
|
+
consensusGroupId?: string;
|
|
180
189
|
/** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
|
|
181
190
|
id?: string;
|
|
182
191
|
/** (3) Originating coordinator session id (for session-anchored completion routing). */
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
|
|
14
14
|
import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-missions.js';
|
|
15
|
+
import type { MagiPanelMap } from '@adhdev/mesh-shared';
|
|
15
16
|
export interface RepoMesh {
|
|
16
17
|
id: string;
|
|
17
18
|
name: string;
|
|
@@ -466,6 +467,13 @@ export interface RepoMeshCoordinatorConfig {
|
|
|
466
467
|
*/
|
|
467
468
|
export interface LocalMeshConfig {
|
|
468
469
|
meshes: LocalMeshEntry[];
|
|
470
|
+
/**
|
|
471
|
+
* MAGI cross-verification panels (machine-local). Keyed by panel name; each
|
|
472
|
+
* binds concrete `(node × provider)` members — machine-dependent facts — so
|
|
473
|
+
* panels live here in meshes.json, never in the repo-shared .adhdev/mesh.json.
|
|
474
|
+
* Optional: absent on configs written before MAGI existed.
|
|
475
|
+
*/
|
|
476
|
+
magiPanels?: MagiPanelMap;
|
|
469
477
|
}
|
|
470
478
|
export interface LocalMeshEntry {
|
|
471
479
|
id: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.415",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.415",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -6,6 +6,12 @@ import * as path from 'path';
|
|
|
6
6
|
// need a cmd.exe wrapper).
|
|
7
7
|
const DIRECT_EXEC_EXT = new Set(['.exe', '.com']);
|
|
8
8
|
|
|
9
|
+
// Batch-style shims: absolute and launchable by node-pty's ConPTY, but NOT by
|
|
10
|
+
// child_process.execFile/spawn without a cmd.exe wrapper (Node ≥18.20/20.12/22/24
|
|
11
|
+
// refuse to exec a .cmd/.bat directly — CVE-2024-27980 mitigation). Preferred
|
|
12
|
+
// over extensionless Unix wrappers, which are not win32-executable at all.
|
|
13
|
+
const SHIM_EXEC_EXT = new Set(['.cmd', '.bat']);
|
|
14
|
+
|
|
9
15
|
// Executable extensions to probe when scanning a directory ourselves, ordered
|
|
10
16
|
// most-directly-launchable first. node-pty's ConPTY backend launches an
|
|
11
17
|
// absolute `.cmd`/`.bat` shim fine (verified) — it only fails to *resolve* a
|
|
@@ -42,6 +48,29 @@ function resolveWin32GlobalBin(trimmed: string): string | null {
|
|
|
42
48
|
return null;
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Pick the best launch target from `where`'s match list on win32.
|
|
53
|
+
*
|
|
54
|
+
* `where npm` on a typical install returns BOTH the extensionless Unix wrapper
|
|
55
|
+
* (e.g. `C:\Program Files\nodejs\npm`, a bash shell script) AND the `npm.cmd`
|
|
56
|
+
* shim. The extensionless wrapper is NOT a win32 executable — handing it to a
|
|
57
|
+
* spawn boundary ENOENTs (errno -4058). So:
|
|
58
|
+
* 1. Prefer a directly-launchable `.exe`/`.com`.
|
|
59
|
+
* 2. Otherwise take a `.cmd`/`.bat` shim (absolute → works for ConPTY, and for
|
|
60
|
+
* execFile once wrapped via buildWin32ExecFileSpawn).
|
|
61
|
+
* 3. NEVER fall back to an extensionless match — return null so the caller can
|
|
62
|
+
* try other resolution strategies (global-bin scan) rather than emit a
|
|
63
|
+
* path that cannot be exec'd.
|
|
64
|
+
*/
|
|
65
|
+
export function selectWin32ExecutableMatch(matches: string[]): string | null {
|
|
66
|
+
const cleaned = matches.map((m) => m.trim()).filter(Boolean);
|
|
67
|
+
const direct = cleaned.find((m) => DIRECT_EXEC_EXT.has(path.extname(m).toLowerCase()));
|
|
68
|
+
if (direct) return direct;
|
|
69
|
+
const shim = cleaned.find((m) => SHIM_EXEC_EXT.has(path.extname(m).toLowerCase()));
|
|
70
|
+
if (shim) return shim;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
45
74
|
/**
|
|
46
75
|
* Resolve a launch command to an absolute executable path on Windows.
|
|
47
76
|
*
|
|
@@ -70,9 +99,12 @@ export function resolveWin32Executable(command: string): string {
|
|
|
70
99
|
}).trim();
|
|
71
100
|
if (out) {
|
|
72
101
|
const matches = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
73
|
-
// Prefer
|
|
74
|
-
|
|
75
|
-
|
|
102
|
+
// Prefer .exe/.com, then a .cmd/.bat shim; never an extensionless Unix
|
|
103
|
+
// wrapper (the old `matches[0]` fallback returned exactly that and made
|
|
104
|
+
// the spawn boundary ENOENT). On no usable match, fall through to the
|
|
105
|
+
// off-PATH global-bin scan below rather than returning a dead path.
|
|
106
|
+
const selected = selectWin32ExecutableMatch(matches);
|
|
107
|
+
if (selected) return selected;
|
|
76
108
|
}
|
|
77
109
|
} catch {
|
|
78
110
|
// `where` not found / non-zero exit — fall through to the global-bin scan.
|
|
@@ -88,3 +120,85 @@ export function resolveWin32Executable(command: string): string {
|
|
|
88
120
|
|
|
89
121
|
return command;
|
|
90
122
|
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Quote one argument for a cmd.exe command line using the standard
|
|
126
|
+
* CommandLineToArgvW rules (the same algorithm Node uses internally): wrap in
|
|
127
|
+
* double quotes only when needed, double up the backslashes that precede a
|
|
128
|
+
* quote, and escape embedded quotes. We do per-argument quoting ourselves
|
|
129
|
+
* (rather than `{ shell: true }`) because Node's shell mode joins argv with bare
|
|
130
|
+
* spaces and applies NO quoting — any argument containing a space (a path, a
|
|
131
|
+
* test name) would split. Inputs here are repo-mesh validation/bootstrap command
|
|
132
|
+
* tokens (trusted config, not network data), so argv-quoting for spaces/quotes
|
|
133
|
+
* is sufficient; we deliberately do not attempt full cmd.exe metacharacter
|
|
134
|
+
* (& | < > ^ %) escaping.
|
|
135
|
+
*/
|
|
136
|
+
export function quoteWin32CmdArg(arg: string): string {
|
|
137
|
+
if (arg.length > 0 && !/[ \t"]/.test(arg)) return arg;
|
|
138
|
+
let result = '"';
|
|
139
|
+
let backslashes = 0;
|
|
140
|
+
for (const ch of arg) {
|
|
141
|
+
if (ch === '\\') {
|
|
142
|
+
backslashes += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (ch === '"') {
|
|
146
|
+
// Escape every pending backslash (they precede a quote) plus the quote.
|
|
147
|
+
result += '\\'.repeat(backslashes * 2 + 1) + '"';
|
|
148
|
+
backslashes = 0;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
result += '\\'.repeat(backslashes) + ch;
|
|
152
|
+
backslashes = 0;
|
|
153
|
+
}
|
|
154
|
+
// Trailing backslashes precede the closing quote → must be doubled.
|
|
155
|
+
result += '\\'.repeat(backslashes * 2) + '"';
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface Win32ExecFileSpawn {
|
|
160
|
+
file: string;
|
|
161
|
+
args: string[];
|
|
162
|
+
/** Set when the args are pre-quoted for cmd.exe and must not be re-quoted. */
|
|
163
|
+
windowsVerbatimArguments?: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build child_process.execFile/spawn parameters for an already-resolved command.
|
|
168
|
+
*
|
|
169
|
+
* On win32 a `.cmd`/`.bat` shim (what `npm`/`npx`/`tsc`/`vitest` resolve to)
|
|
170
|
+
* cannot be launched by execFile directly — modern Node refuses it (CVE-2024-27980
|
|
171
|
+
* mitigation) and CreateProcess cannot exec a batch file. So wrap it in
|
|
172
|
+
* `cmd.exe /d /s /c "<quoted command line>"` with `windowsVerbatimArguments` so
|
|
173
|
+
* our own per-argument quoting is preserved. `.exe`/`.com` (and every non-win32
|
|
174
|
+
* platform, and any already-cmd.exe target) pass through unchanged — this is a
|
|
175
|
+
* strict no-op off win32, guarding against regressions on linux/macOS.
|
|
176
|
+
*/
|
|
177
|
+
export function buildWin32ExecFileSpawn(resolvedCommand: string, args: string[]): Win32ExecFileSpawn {
|
|
178
|
+
if (process.platform !== 'win32') return { file: resolvedCommand, args };
|
|
179
|
+
const ext = path.extname(resolvedCommand).toLowerCase();
|
|
180
|
+
if (!SHIM_EXEC_EXT.has(ext)) return { file: resolvedCommand, args };
|
|
181
|
+
// cmd.exe /d (skip AutoRun) /s (treat the rest, between the outer quotes, as
|
|
182
|
+
// the verbatim command) /c (run then exit). Mirrors Node's internal shell
|
|
183
|
+
// wrapping but with each token individually quoted.
|
|
184
|
+
const commandLine = [resolvedCommand, ...args].map(quoteWin32CmdArg).join(' ');
|
|
185
|
+
return {
|
|
186
|
+
file: process.env.ComSpec || 'cmd.exe',
|
|
187
|
+
args: ['/d', '/s', '/c', `"${commandLine}"`],
|
|
188
|
+
windowsVerbatimArguments: true,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Convenience: resolve a bare command to an absolute win32 path AND build the
|
|
194
|
+
* execFile spawn parameters (cmd.exe-wrapping a .cmd/.bat shim). Returns the
|
|
195
|
+
* resolved command alongside the spawn spec so callers can still surface the
|
|
196
|
+
* resolved path in diagnostics.
|
|
197
|
+
*/
|
|
198
|
+
export function resolveWin32ExecFileSpawn(
|
|
199
|
+
command: string,
|
|
200
|
+
args: string[],
|
|
201
|
+
): Win32ExecFileSpawn & { resolvedCommand: string } {
|
|
202
|
+
const resolvedCommand = resolveWin32Executable(command);
|
|
203
|
+
return { resolvedCommand, ...buildWin32ExecFileSpawn(resolvedCommand, args) };
|
|
204
|
+
}
|
|
@@ -67,7 +67,29 @@ export const meshQueueHandlers: Record<string, MedFamilyHandler> = {
|
|
|
67
67
|
const ownerFailure = await ctx.requireMeshHostMutationOwner(meshId, args?.inlineMesh, 'queue requeue');
|
|
68
68
|
if (ownerFailure) return ownerFailure;
|
|
69
69
|
try {
|
|
70
|
-
const { requeueTask } = await import('../../mesh/mesh-work-queue.js');
|
|
70
|
+
const { requeueTask, getQueue } = await import('../../mesh/mesh-work-queue.js');
|
|
71
|
+
// CANON-IDENTITY single-flight hardening (restart-safe): the in-memory in-flight
|
|
72
|
+
// mark requeueTask consults is process-local and is lost across a daemon restart.
|
|
73
|
+
// Independently of that mark, if the row is still 'assigned' to a session this
|
|
74
|
+
// daemon hosts that is actively generating, requeueing would flip it back to
|
|
75
|
+
// pending and let a SECOND session claim the SAME task (the duplicate dispatch).
|
|
76
|
+
// Refuse unless force. A genuinely dead/stale session is not generating, so this
|
|
77
|
+
// observation passes and a legitimate requeue proceeds. force=true (operator
|
|
78
|
+
// override) bypasses BOTH this and the in-memory guard.
|
|
79
|
+
// MAGI-NOTE: a future consensus group fan-out (separate mission) will exempt
|
|
80
|
+
// group-tagged tasks from this guard; the exemption hook belongs here.
|
|
81
|
+
if (args?.force !== true) {
|
|
82
|
+
const { isSessionActivelyGenerating } = await import('../../mesh/mesh-events.js');
|
|
83
|
+
const existing = getQueue(meshId).find((t: any) => t?.id === taskId) as { status?: string; assignedSessionId?: string } | undefined;
|
|
84
|
+
if (existing?.status === 'assigned' && existing.assignedSessionId
|
|
85
|
+
&& isSessionActivelyGenerating(ctx.deps as any, existing.assignedSessionId)) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
error: `Task '${taskId}' is actively dispatched/generating (live session ${existing.assignedSessionId}); requeue refused to avoid a duplicate second dispatch. Pass force:true to override, or cancel and re-enqueue.`,
|
|
89
|
+
task: existing,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
71
93
|
const task = requeueTask(meshId, taskId, {
|
|
72
94
|
reason: typeof args?.reason === 'string' ? args.reason : undefined,
|
|
73
95
|
targetNodeId: typeof args?.targetNodeId === 'string' ? args.targetNodeId.trim() : undefined,
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
RepoMeshHostMetadata,
|
|
23
23
|
RepoMeshDaemonRole,
|
|
24
24
|
} from '../repo-mesh-types.js';
|
|
25
|
+
import type { MagiPanel, MagiPanelMember } from '@adhdev/mesh-shared';
|
|
25
26
|
import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
|
|
26
27
|
import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
|
|
27
28
|
|
|
@@ -584,3 +585,118 @@ export function updateNode(
|
|
|
584
585
|
saveMeshConfig(config);
|
|
585
586
|
return node;
|
|
586
587
|
}
|
|
588
|
+
|
|
589
|
+
// ─── MAGI Panels (machine-local cross-verification quorums) ──
|
|
590
|
+
|
|
591
|
+
/** Hard cap on members per panel — a sanity bound, not the per-invocation replica cap. */
|
|
592
|
+
const MAX_MAGI_PANEL_MEMBERS = 24;
|
|
593
|
+
|
|
594
|
+
function normalizeReplicaCount(value: unknown): number | undefined {
|
|
595
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
|
|
596
|
+
const n = Math.floor(value);
|
|
597
|
+
return n >= 1 ? n : undefined;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Validate + normalize a panel config before persisting. Mirrors the node-config
|
|
602
|
+
* normalization style (mesh-config addNode/updateNode): trims strings, drops
|
|
603
|
+
* empties, requires a provider per member, clamps replica counts. Throws on
|
|
604
|
+
* structurally invalid input so the calling tool returns a clear error rather than
|
|
605
|
+
* writing a malformed panel.
|
|
606
|
+
*/
|
|
607
|
+
function normalizeMagiPanel(config: unknown): MagiPanel {
|
|
608
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
609
|
+
throw new Error('invalid_magi_panel: config must be an object');
|
|
610
|
+
}
|
|
611
|
+
const raw = config as Record<string, unknown>;
|
|
612
|
+
const rawMembers = raw.members;
|
|
613
|
+
if (!Array.isArray(rawMembers) || rawMembers.length === 0) {
|
|
614
|
+
throw new Error('invalid_magi_panel: members must be a non-empty array');
|
|
615
|
+
}
|
|
616
|
+
if (rawMembers.length > MAX_MAGI_PANEL_MEMBERS) {
|
|
617
|
+
throw new Error(`invalid_magi_panel: too many members (max ${MAX_MAGI_PANEL_MEMBERS})`);
|
|
618
|
+
}
|
|
619
|
+
const members: MagiPanelMember[] = rawMembers.map((entry, idx) => {
|
|
620
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
621
|
+
throw new Error(`invalid_magi_panel: member[${idx}] must be an object`);
|
|
622
|
+
}
|
|
623
|
+
const m = entry as Record<string, unknown>;
|
|
624
|
+
const provider = typeof m.provider === 'string' ? m.provider.trim() : '';
|
|
625
|
+
if (!provider) {
|
|
626
|
+
throw new Error(`invalid_magi_panel: member[${idx}].provider is required`);
|
|
627
|
+
}
|
|
628
|
+
const nodeId = typeof m.nodeId === 'string' && m.nodeId.trim() ? m.nodeId.trim() : undefined;
|
|
629
|
+
const capabilityTags = normalizeCapabilityTags(m.capabilityTags);
|
|
630
|
+
const n = normalizeReplicaCount(m.n);
|
|
631
|
+
return {
|
|
632
|
+
provider,
|
|
633
|
+
...(nodeId ? { nodeId } : {}),
|
|
634
|
+
...(capabilityTags ? { capabilityTags } : {}),
|
|
635
|
+
...(n !== undefined ? { n } : {}),
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
const description = typeof raw.description === 'string' && raw.description.trim()
|
|
639
|
+
? raw.description.trim().slice(0, 200)
|
|
640
|
+
: undefined;
|
|
641
|
+
const defaultN = normalizeReplicaCount(raw.defaultN);
|
|
642
|
+
return {
|
|
643
|
+
...(description ? { description } : {}),
|
|
644
|
+
members,
|
|
645
|
+
...(defaultN !== undefined ? { defaultN } : {}),
|
|
646
|
+
// dedupExempt is always meaningful for a MAGI panel (intentional same-prompt
|
|
647
|
+
// fan-out). Persist it true unless the caller explicitly disables it.
|
|
648
|
+
dedupExempt: raw.dedupExempt === false ? false : true,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function normalizePanelName(name: unknown): string {
|
|
653
|
+
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
654
|
+
if (!trimmed) throw new Error('invalid_magi_panel: panel name is required');
|
|
655
|
+
return trimmed.slice(0, 100);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** All configured MAGI panels (machine-local), keyed by name. Empty when none. */
|
|
659
|
+
export function listMagiPanels(): Record<string, MagiPanel> {
|
|
660
|
+
return loadMeshConfig().magiPanels ?? {};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** A single panel by name, or undefined when not configured. */
|
|
664
|
+
export function getMagiPanel(name: string): MagiPanel | undefined {
|
|
665
|
+
const key = typeof name === 'string' ? name.trim() : '';
|
|
666
|
+
if (!key) return undefined;
|
|
667
|
+
return loadMeshConfig().magiPanels?.[key];
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Upsert a named panel into meshes.json. Defaults to refusing to clobber an
|
|
672
|
+
* existing panel (overwrite=false) — mirrors the mesh_init write/overwrite
|
|
673
|
+
* precedent. Returns the normalized, persisted panel.
|
|
674
|
+
*/
|
|
675
|
+
export function upsertMagiPanel(
|
|
676
|
+
name: string,
|
|
677
|
+
config: unknown,
|
|
678
|
+
opts: { overwrite?: boolean } = {},
|
|
679
|
+
): MagiPanel {
|
|
680
|
+
const key = normalizePanelName(name);
|
|
681
|
+
const panel = normalizeMagiPanel(config);
|
|
682
|
+
const stored = loadMeshConfig();
|
|
683
|
+
const panels = stored.magiPanels ?? {};
|
|
684
|
+
if (panels[key] && opts.overwrite !== true) {
|
|
685
|
+
throw new Error(`magi_panel_exists: panel '${key}' already exists — pass overwrite=true to replace it`);
|
|
686
|
+
}
|
|
687
|
+
panels[key] = panel;
|
|
688
|
+
stored.magiPanels = panels;
|
|
689
|
+
saveMeshConfig(stored);
|
|
690
|
+
return panel;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Remove a named panel. Returns true when a panel was removed. */
|
|
694
|
+
export function removeMagiPanel(name: string): boolean {
|
|
695
|
+
const key = typeof name === 'string' ? name.trim() : '';
|
|
696
|
+
if (!key) return false;
|
|
697
|
+
const stored = loadMeshConfig();
|
|
698
|
+
if (!stored.magiPanels || !stored.magiPanels[key]) return false;
|
|
699
|
+
delete stored.magiPanels[key];
|
|
700
|
+
saveMeshConfig(stored);
|
|
701
|
+
return true;
|
|
702
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -192,8 +192,18 @@ export type { SavedProviderSessionEntry } from './config/saved-sessions.js';
|
|
|
192
192
|
export {
|
|
193
193
|
listMeshes, getMesh, getMeshByRepo, createMesh, updateMesh, deleteMesh,
|
|
194
194
|
addNode, removeNode, updateNode, normalizeRepoIdentity,
|
|
195
|
+
listMagiPanels, getMagiPanel, upsertMagiPanel, removeMagiPanel,
|
|
195
196
|
} from './config/mesh-config.js';
|
|
196
197
|
export type { CreateMeshOptions, UpdateMeshOptions, AddNodeOptions } from './config/mesh-config.js';
|
|
198
|
+
// MAGI panel / common-output / synthesis types (re-exported from the mesh-shared
|
|
199
|
+
// leaf so the mcp-server — which depends only on @adhdev/daemon-core — can consume
|
|
200
|
+
// them without taking a direct @adhdev/mesh-shared dependency).
|
|
201
|
+
export type {
|
|
202
|
+
MagiPanel, MagiPanelMember, MagiPanelMap, MagiMode,
|
|
203
|
+
MagiClaim, MagiClaimStance, MagiAgentResponse,
|
|
204
|
+
MagiResponseSource, MagiSynthesizedResponse,
|
|
205
|
+
MagiClusterCategory, MagiClusterMember, MagiClaimCluster, MagiSynthesis,
|
|
206
|
+
} from '@adhdev/mesh-shared';
|
|
197
207
|
|
|
198
208
|
// ── Mesh shared daemon-id / node-id helpers (re-export so external tooling —
|
|
199
209
|
// e.g. the mcp-server, which depends only on @adhdev/daemon-core — can
|
|
@@ -719,13 +719,29 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
719
719
|
// coordinator has no local instance and keeps relying on the relayed fields — unchanged.
|
|
720
720
|
const enrichedMetadataEvent = ((): Record<string, unknown> => {
|
|
721
721
|
const last = sourceSession ? getLastDisplayMessage(sourceSession.getState()) : null;
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
722
|
+
const base = (!last || !last.preview)
|
|
723
|
+
? args.metadataEvent
|
|
724
|
+
: {
|
|
725
|
+
...args.metadataEvent,
|
|
726
|
+
lastMessagePreview: last.preview,
|
|
727
|
+
lastMessageRole: last.role,
|
|
728
|
+
...(last.receivedAt > 0 ? { lastMessageAt: last.receivedAt } : {}),
|
|
729
|
+
};
|
|
730
|
+
// MAGI: stamp the queue task's consensusGroupId onto the completion metadata
|
|
731
|
+
// so the intentional-fan-out dedup exemption (buildPendingEventFingerprint)
|
|
732
|
+
// can see it. The work queue is owned by THIS host/coordinator daemon — where
|
|
733
|
+
// both the lookup and the dedup run — so the local lookup covers local and
|
|
734
|
+
// relayed workers alike. Best-effort: never fail the event path on a miss, and
|
|
735
|
+
// never clobber a consensusGroupId the worker already relayed.
|
|
736
|
+
if (readNonEmptyString((base as Record<string, unknown>).consensusGroupId)) return base;
|
|
737
|
+
const eventTaskId = readNonEmptyString(args.metadataEvent.taskId);
|
|
738
|
+
if (!eventTaskId) return base;
|
|
739
|
+
try {
|
|
740
|
+
const entry = MeshRuntimeStore.getInstance().findQueueEntryById(args.meshId, eventTaskId);
|
|
741
|
+
const consensusGroupId = readNonEmptyString((entry as { consensusGroupId?: unknown } | null)?.consensusGroupId);
|
|
742
|
+
if (consensusGroupId) return { ...base, consensusGroupId };
|
|
743
|
+
} catch { /* queue lookup is best-effort; absence just falls back to the generic fingerprint */ }
|
|
744
|
+
return base;
|
|
729
745
|
})();
|
|
730
746
|
|
|
731
747
|
// R2: cloud P2P dashboard metadata sync. The cloud daemon used to do this from its own
|
|
@@ -121,6 +121,21 @@ export function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent)
|
|
|
121
121
|
].join('::');
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
+
// MAGI consensus-group exemption: a consensusGroupId marks an INTENTIONAL
|
|
125
|
+
// same-prompt fan-out across N replicas — the exact opposite of the accidental
|
|
126
|
+
// duplicates this dedup collapses. Anchor the fingerprint on the unique
|
|
127
|
+
// (taskId, consensusGroupId) so grouped replicas can NEVER be collapsed by any
|
|
128
|
+
// future prompt-content-based tightening of this builder. Mirrors the
|
|
129
|
+
// bootstrap-event exemption above and serves as the explicit fan-out marker.
|
|
130
|
+
// (Today this is belt-and-suspenders: each replica already gets a distinct
|
|
131
|
+
// taskId, so the generic key below would not collapse them either.)
|
|
132
|
+
const consensusGroupId = readNonEmptyString(metadata.consensusGroupId)
|
|
133
|
+
|| readNonEmptyString(readRecord(metadata.payload)?.consensusGroupId);
|
|
134
|
+
if (consensusGroupId) {
|
|
135
|
+
const groupTaskId = readNonEmptyString(metadata.taskId)
|
|
136
|
+
|| readNonEmptyString(readRecord(metadata.payload)?.taskId);
|
|
137
|
+
return [event.meshId, event.event, groupTaskId || '', consensusGroupId, 'group'].join('::');
|
|
138
|
+
}
|
|
124
139
|
const sessionId = resolveEventSessionId(metadata);
|
|
125
140
|
const providerSessionId = readNonEmptyString(metadata.providerSessionId);
|
|
126
141
|
const taskId = readNonEmptyString(metadata.taskId) || readNonEmptyString(readRecord(metadata.payload)?.taskId);
|
package/src/mesh/mesh-events.ts
CHANGED
|
@@ -987,6 +987,30 @@ export function sessionHasActiveAssignment(meshId: string, sessionId: string): b
|
|
|
987
987
|
return false;
|
|
988
988
|
}
|
|
989
989
|
|
|
990
|
+
/**
|
|
991
|
+
* CANON-IDENTITY single-flight hardening (restart-safe, observation-based).
|
|
992
|
+
*
|
|
993
|
+
* The in-memory single-flight Set (mesh-task-inflight) is process-local and is LOST on a
|
|
994
|
+
* daemon restart — after a restart, a task still being generated by a live local worker is
|
|
995
|
+
* no longer marked in-flight, so requeueTask's Set check passes and would re-open the task
|
|
996
|
+
* for a duplicate second dispatch. This recovers the "still generating" signal from
|
|
997
|
+
* observable runtime state instead of the in-memory mark: a session is actively generating
|
|
998
|
+
* when its live local CLI instance reports an active (generating/streaming/…) status — the
|
|
999
|
+
* same predicate the dispatch active-work gate uses (sessionStateLooksActive).
|
|
1000
|
+
*
|
|
1001
|
+
* Local-only by design: it inspects THIS daemon's instanceManager. The primary cross-process
|
|
1002
|
+
* fix (IpcTransport requeue delegating to the mesh-host daemon) keeps begin (dispatch) and
|
|
1003
|
+
* check (requeue guard) co-located so the in-memory mark stays authoritative in the common
|
|
1004
|
+
* path; this is the restart-safety net for sessions hosted on this daemon. A genuinely
|
|
1005
|
+
* dead/stale session is not generating → returns false → the requeue proceeds as before.
|
|
1006
|
+
*/
|
|
1007
|
+
export function isSessionActivelyGenerating(components: DaemonComponents, sessionId: string): boolean {
|
|
1008
|
+
if (!sessionId) return false;
|
|
1009
|
+
const state = components.instanceManager?.getInstance?.(sessionId)?.getState?.();
|
|
1010
|
+
if (!state) return false;
|
|
1011
|
+
return sessionStateLooksActive(state);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
990
1014
|
function liveSessionCountForNode(components: DaemonComponents, meshId: string, nodeId: string): number {
|
|
991
1015
|
return components.instanceManager.getByCategory('cli').filter((inst: any) => {
|
|
992
1016
|
const state = inst.getState();
|
|
@@ -21,7 +21,7 @@ import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.j
|
|
|
21
21
|
import { basename as pathBasename, join as pathJoin, resolve as pathResolve } from 'path';
|
|
22
22
|
import * as fs from 'fs';
|
|
23
23
|
import { execFileSync } from 'node:child_process';
|
|
24
|
-
import { resolveWin32Executable } from '../cli-adapters/resolve-executable.js';
|
|
24
|
+
import { resolveWin32Executable, buildWin32ExecFileSpawn } from '../cli-adapters/resolve-executable.js';
|
|
25
25
|
import type { CommandRouterResult } from '../commands/router.js';
|
|
26
26
|
|
|
27
27
|
// Fix (4): resolve the git executable to an absolute path once on win32. A bare `git` handed to
|
|
@@ -1573,13 +1573,18 @@ export async function runMeshRefineValidationGate(
|
|
|
1573
1573
|
// Resolve to an absolute path via the same helper the PTY path uses
|
|
1574
1574
|
// (no-op on non-win32 and when the command is already absolute).
|
|
1575
1575
|
const resolvedCommand = resolveWin32Executable(candidate.command);
|
|
1576
|
+
// A win32 .cmd/.bat shim cannot be exec'd directly — wrap it in
|
|
1577
|
+
// cmd.exe /c (no-op off win32 / for a real .exe). Keep
|
|
1578
|
+
// resolvedCommand for diagnostics.
|
|
1579
|
+
const spawn = buildWin32ExecFileSpawn(resolvedCommand, candidate.args);
|
|
1576
1580
|
try {
|
|
1577
|
-
const result = await execFileAsync(
|
|
1581
|
+
const result = await execFileAsync(spawn.file, spawn.args, {
|
|
1578
1582
|
cwd,
|
|
1579
1583
|
encoding: 'utf8',
|
|
1580
1584
|
timeout,
|
|
1581
1585
|
maxBuffer: candidate.outputLimitBytes || REFINE_VALIDATION_OUTPUT_LIMIT_BYTES,
|
|
1582
1586
|
env: { ...process.env, CI: process.env.CI || '1', ...(candidate.env || {}) },
|
|
1587
|
+
...(spawn.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
|
|
1583
1588
|
});
|
|
1584
1589
|
summary.bootstrapCommandsRun.push(commandRecord(candidate, cwd, startedAt, result, true, { exitCode: 0 }));
|
|
1585
1590
|
} catch (error: any) {
|
|
@@ -1592,7 +1597,7 @@ export async function runMeshRefineValidationGate(
|
|
|
1592
1597
|
? { failureKind: 'spawn_resolution_failed', resolvedCommand }
|
|
1593
1598
|
: { failureKind: 'dependency_bootstrap_failed' }),
|
|
1594
1599
|
}));
|
|
1595
|
-
summary.bootstrap = { stage: 'failed', error: describeSpawnError(error,
|
|
1600
|
+
summary.bootstrap = { stage: 'failed', error: describeSpawnError(error, resolvedCommand, spawnResolutionFailed) };
|
|
1596
1601
|
summary.status = 'failed';
|
|
1597
1602
|
summary.failureKind = spawnResolutionFailed ? 'spawn_resolution_failed' : 'dependency_bootstrap_failed';
|
|
1598
1603
|
summary.failureCode = spawnResolutionFailed ? 'spawn_resolution_failed' : 'dependency_bootstrap_failed';
|
|
@@ -1622,13 +1627,15 @@ export async function runMeshRefineValidationGate(
|
|
|
1622
1627
|
// See the bootstrap loop above: resolve the win32 .cmd shim to an
|
|
1623
1628
|
// absolute path before handing it to the spawn boundary.
|
|
1624
1629
|
const resolvedCommand = resolveWin32Executable(candidate.command);
|
|
1630
|
+
const spawn = buildWin32ExecFileSpawn(resolvedCommand, candidate.args);
|
|
1625
1631
|
try {
|
|
1626
|
-
const result = await execFileAsync(
|
|
1632
|
+
const result = await execFileAsync(spawn.file, spawn.args, {
|
|
1627
1633
|
cwd,
|
|
1628
1634
|
encoding: 'utf8',
|
|
1629
1635
|
timeout,
|
|
1630
1636
|
maxBuffer: candidate.outputLimitBytes || REFINE_VALIDATION_OUTPUT_LIMIT_BYTES,
|
|
1631
1637
|
env: { ...process.env, CI: process.env.CI || '1', ...(candidate.env || {}) },
|
|
1638
|
+
...(spawn.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
|
|
1632
1639
|
});
|
|
1633
1640
|
summary.commandsRun.push(commandRecord(candidate, cwd, startedAt, result, true, { exitCode: 0 }));
|
|
1634
1641
|
} catch (error: any) {
|
|
@@ -1652,7 +1659,7 @@ export async function runMeshRefineValidationGate(
|
|
|
1652
1659
|
if (spawnResolutionFailed) {
|
|
1653
1660
|
summary.failureKind = 'spawn_resolution_failed';
|
|
1654
1661
|
summary.failureCode = 'spawn_resolution_failed';
|
|
1655
|
-
summary.spawnResolutionError = describeSpawnError(error,
|
|
1662
|
+
summary.spawnResolutionError = describeSpawnError(error, resolvedCommand, true);
|
|
1656
1663
|
} else if (missingDependencyFailure) {
|
|
1657
1664
|
summary.failureKind = 'missing_dependencies';
|
|
1658
1665
|
summary.failureCode = 'missing_dependencies';
|
|
@@ -495,6 +495,13 @@ export interface MeshWorkQueueEntry {
|
|
|
495
495
|
dependsOn?: string[];
|
|
496
496
|
/** M1/M3: mission this task belongs to (joins mesh_missions). */
|
|
497
497
|
missionId?: string;
|
|
498
|
+
/**
|
|
499
|
+
* MAGI: consensus group id shared by every replica of one mesh_magi_review
|
|
500
|
+
* fan-out. Marks the task as part of an INTENTIONAL same-prompt quorum so the
|
|
501
|
+
* completion-event dedup (mesh-events-pending) never collapses grouped
|
|
502
|
+
* replicas. Absent on ordinary tasks. Rides in the payload JSON (no column).
|
|
503
|
+
*/
|
|
504
|
+
consensusGroupId?: string;
|
|
498
505
|
/**
|
|
499
506
|
* M1: why this task is held back (e.g. "dependency_failed:<taskId>").
|
|
500
507
|
* Only set by the system on dependency failure under the 'block' policy;
|
|
@@ -763,6 +770,8 @@ export function enqueueTask(
|
|
|
763
770
|
dependsOn?: string[];
|
|
764
771
|
/** M1/M3: mission this task belongs to. */
|
|
765
772
|
missionId?: string;
|
|
773
|
+
/** MAGI: consensus group id shared by every replica of a mesh_magi_review fan-out. */
|
|
774
|
+
consensusGroupId?: string;
|
|
766
775
|
/** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
|
|
767
776
|
id?: string;
|
|
768
777
|
/** (3) Originating coordinator session id (for session-anchored completion routing). */
|
|
@@ -806,6 +815,7 @@ export function enqueueTask(
|
|
|
806
815
|
requiredTags: resolvedRequiredTags,
|
|
807
816
|
...(dependsOn.length > 0 ? { dependsOn } : {}),
|
|
808
817
|
...(typeof opts?.missionId === 'string' && opts.missionId.trim() ? { missionId: opts.missionId.trim() } : {}),
|
|
818
|
+
...(typeof opts?.consensusGroupId === 'string' && opts.consensusGroupId.trim() ? { consensusGroupId: opts.consensusGroupId.trim() } : {}),
|
|
809
819
|
...(typeof opts?.sourceCoordinatorSessionId === 'string' && opts.sourceCoordinatorSessionId.trim()
|
|
810
820
|
? { sourceCoordinatorSessionId: opts.sourceCoordinatorSessionId.trim() }
|
|
811
821
|
: {}),
|
|
@@ -1086,6 +1096,9 @@ export function requeueTask(
|
|
|
1086
1096
|
// STALE assigned row (dead session, dispatch never confirmed) is NOT in-flight
|
|
1087
1097
|
// — its mark was cleared on the dispatch failure — so it still requeues as
|
|
1088
1098
|
// before. An explicit operator override (`force`) bypasses this guard.
|
|
1099
|
+
// MAGI-NOTE: the future consensus group fan-out (separate mission) intentionally
|
|
1100
|
+
// re-dispatches a group-tagged task into multiple sessions and must be exempted
|
|
1101
|
+
// from this single-flight guard; the exemption hook (group-id check) belongs here.
|
|
1089
1102
|
if (!opts?.force && isTaskDispatchInFlight(meshId, taskId)) {
|
|
1090
1103
|
LOG.warn('MeshQueue', `Refusing to requeue task ${taskId} on mesh ${meshId}: it is actively dispatched/generating (single-flight in-flight). Requeueing now would open a duplicate second dispatch into another session. Pass force to override.`);
|
|
1091
1104
|
return entry;
|