@adhdev/daemon-core 0.9.82-rc.414 → 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.
@@ -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.414",
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.414",
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 a directly-launchable executable (.exe/.com) over .cmd/.bat shims.
74
- const direct = matches.find((m) => DIRECT_EXEC_EXT.has(path.extname(m).toLowerCase()));
75
- return direct || matches[0] || command;
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
- if (!last || !last.preview) return args.metadataEvent;
723
- return {
724
- ...args.metadataEvent,
725
- lastMessagePreview: last.preview,
726
- lastMessageRole: last.role,
727
- ...(last.receivedAt > 0 ? { lastMessageAt: last.receivedAt } : {}),
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
@@ -17,6 +17,7 @@ export {
17
17
  __resetIdleAutoFastForwardForTests,
18
18
  activeReadonlyAssignedCount,
19
19
  activeWriteAssignedCount,
20
+ isSessionActivelyGenerating,
20
21
  triggerMeshQueue,
21
22
  tryAssignQueueTask,
22
23
  } from './mesh-queue-assignment.js';
@@ -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);
@@ -27,6 +27,7 @@ export {
27
27
  export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
28
28
  export {
29
29
  tryAssignQueueTask,
30
+ isSessionActivelyGenerating,
30
31
  triggerMeshQueue,
31
32
  handleMeshForwardEvent,
32
33
  setupMeshEventForwarding,
@@ -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(resolvedCommand, candidate.args, {
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, candidate.command, spawnResolutionFailed) };
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(resolvedCommand, candidate.args, {
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, candidate.command, true);
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;