@adhdev/daemon-core 0.9.82-rc.459 → 0.9.82-rc.460

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.
@@ -51,8 +51,14 @@ export interface CoordinatorRecentActivity {
51
51
  /** Short task title/message, already truncated by the caller. */
52
52
  summary?: string;
53
53
  }>;
54
- /** Count of task_failed entries inside the recent (30-min) window. */
54
+ /** Count of task_failed entries inside the recent window. */
55
55
  recentFailureCount?: number;
56
+ /**
57
+ * Size of the "recent" window in minutes. Drives the "failed in the last
58
+ * N min" phrasing. Omitted → defaults to 30, matching the prior hardcoded
59
+ * wording so existing callers render identically.
60
+ */
61
+ windowMinutes?: number;
56
62
  /** Pending (unclaimed) tasks in the work queue. */
57
63
  pendingTasks?: number;
58
64
  /** Assigned-but-not-yet-terminal tasks in the work queue. */
@@ -122,7 +128,76 @@ export interface CoordinatorPromptContext {
122
128
  * That layering lets a user customize prompts at three increasing scopes
123
129
  * (machine, mesh, single launch) without losing the daemon's stock rules.
124
130
  */
131
+ /**
132
+ * 6-4: total prompt soft cap. When the assembled prompt exceeds this, we shed
133
+ * the two runtime-accumulated, daemon-generated sections — operating notes
134
+ * first, then recent activity — because they grow unboundedly from the ledger.
135
+ * We NEVER trim user append/override content or the fixed hardcoded sections
136
+ * (identity/nodes/policy/tools/workflow/onboarding/rules): those carry user
137
+ * intent or invariant instructions. If shedding both still overflows, we keep
138
+ * the prompt as-is rather than mangling protected content.
139
+ */
140
+ const PROMPT_SOFT_CAP_BYTES = 60 * 1024;
141
+
142
+ /**
143
+ * Which daemon-generated optional sections to drop from the default base.
144
+ * Used only by the 6-4 soft-cap retry — an override base ignores these
145
+ * because its operating-notes/recent-activity content comes from the user's
146
+ * own {{placeholder}}s and is not ours to trim.
147
+ */
148
+ interface DefaultPromptDropFlags {
149
+ dropOperatingNotes?: boolean;
150
+ dropRecentActivity?: boolean;
151
+ }
152
+
125
153
  export function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): string {
154
+ // First pass: assemble with everything included.
155
+ let prompt = assembleCoordinatorPrompt(ctx, {});
156
+ if (byteLength(prompt) <= PROMPT_SOFT_CAP_BYTES) return prompt;
157
+
158
+ // Over the soft cap. Only the default base carries daemon-generated
159
+ // operating-notes / recent-activity sections we're allowed to shed; an
160
+ // override base is user content and stays whole. If we're on an override
161
+ // base there's nothing safe to trim, so return the first pass unchanged.
162
+ if (usesOverrideBase(ctx)) return prompt;
163
+
164
+ const shed: string[] = [];
165
+
166
+ // 1) Shed operating notes first.
167
+ prompt = assembleCoordinatorPrompt(ctx, { dropOperatingNotes: true });
168
+ shed.push('operating notes');
169
+ if (byteLength(prompt) <= PROMPT_SOFT_CAP_BYTES) {
170
+ return appendTruncationNotice(prompt, shed);
171
+ }
172
+
173
+ // 2) Still over → also shed recent activity.
174
+ prompt = assembleCoordinatorPrompt(ctx, { dropOperatingNotes: true, dropRecentActivity: true });
175
+ shed.push('recent activity');
176
+ return appendTruncationNotice(prompt, shed);
177
+ }
178
+
179
+ /** True when the base prompt is a mesh-level or user-file override (not the daemon default). */
180
+ function usesOverrideBase(ctx: CoordinatorPromptContext): boolean {
181
+ if (ctx.mesh.coordinator?.systemPromptOverride?.trim()) return true;
182
+ return readUserPromptFile(ctx.coordinatorCliType, 'md') !== null;
183
+ }
184
+
185
+ /** UTF-8 byte length — the cap is a byte budget, not a code-unit count. */
186
+ function byteLength(s: string): number {
187
+ return Buffer.byteLength(s, 'utf8');
188
+ }
189
+
190
+ /**
191
+ * Append a single trailing line recording which daemon-generated sections were
192
+ * shed to fit the soft cap, so the coordinator (and anyone reading the prompt)
193
+ * knows the omission was deliberate, not a data-loss bug.
194
+ */
195
+ function appendTruncationNotice(prompt: string, shed: string[]): string {
196
+ if (shed.length === 0) return prompt;
197
+ return `${prompt}\n\n_Prompt exceeded the ${Math.floor(PROMPT_SOFT_CAP_BYTES / 1024)}KB soft cap; omitted to fit: ${shed.join(', ')}. Full detail remains in the ledger (\`mesh_task_history\` / \`mesh_record_note\`)._`;
198
+ }
199
+
200
+ function assembleCoordinatorPrompt(ctx: CoordinatorPromptContext, drop: DefaultPromptDropFlags): string {
126
201
  const { mesh, userInstruction, coordinatorCliType } = ctx;
127
202
 
128
203
  // ── Pick the base prompt ──
@@ -135,7 +210,7 @@ export function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): str
135
210
  if (userOverride !== null) {
136
211
  base = expandPromptPlaceholders(userOverride, ctx);
137
212
  } else {
138
- base = buildDefaultCoordinatorPrompt(ctx);
213
+ base = buildDefaultCoordinatorPrompt(ctx, drop);
139
214
  }
140
215
  }
141
216
 
@@ -163,7 +238,7 @@ export function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): str
163
238
  return sections.join('\n\n');
164
239
  }
165
240
 
166
- function buildDefaultCoordinatorPrompt(ctx: CoordinatorPromptContext): string {
241
+ function buildDefaultCoordinatorPrompt(ctx: CoordinatorPromptContext, drop: DefaultPromptDropFlags = {}): string {
167
242
  const { mesh, status, coordinatorCliType } = ctx;
168
243
  const sections: string[] = [];
169
244
 
@@ -187,13 +262,19 @@ Repository: \`${mesh.repoIdentity}\`${mesh.defaultBranch ? `\nDefault branch: \`
187
262
  sections.push(ctx.missionSection.trim());
188
263
  }
189
264
 
190
- // ── Recent Activity (Gap1) — only present when there's something to show ──
191
- const recentActivity = buildRecentActivitySection(ctx.recentActivity);
192
- if (recentActivity) sections.push(recentActivity);
265
+ // ── Recent Activity (Gap1) — only present when there's something to show.
266
+ // Shed under the 6-4 soft cap (drop.dropRecentActivity). ──
267
+ if (!drop.dropRecentActivity) {
268
+ const recentActivity = buildRecentActivitySection(ctx.recentActivity);
269
+ if (recentActivity) sections.push(recentActivity);
270
+ }
193
271
 
194
- // ── Operating Notes (Gap2-A) — only present when notes exist ──
195
- const operatingNotes = buildOperatingNotesSection(ctx.operatingNotes);
196
- if (operatingNotes) sections.push(operatingNotes);
272
+ // ── Operating Notes (Gap2-A) — only present when notes exist. Shed first
273
+ // under the 6-4 soft cap (drop.dropOperatingNotes). ──
274
+ if (!drop.dropOperatingNotes) {
275
+ const operatingNotes = buildOperatingNotesSection(ctx.operatingNotes);
276
+ if (operatingNotes) sections.push(operatingNotes);
277
+ }
197
278
 
198
279
  // ── Policy ──
199
280
  sections.push(buildPolicySection(mergeAndNormalizePolicy(undefined, mesh.policy)));
@@ -390,6 +471,9 @@ function buildRecentActivitySection(activity?: CoordinatorRecentActivity): strin
390
471
  const recentFailureCount = Number.isFinite(activity.recentFailureCount)
391
472
  ? Number(activity.recentFailureCount)
392
473
  : failures.length;
474
+ const windowMinutes = Number.isFinite(activity.windowMinutes) && Number(activity.windowMinutes) > 0
475
+ ? Math.floor(Number(activity.windowMinutes))
476
+ : 30;
393
477
 
394
478
  // Nothing actionable to show → omit the section entirely.
395
479
  if (failures.length === 0 && pending === 0 && assigned === 0 && stalled === 0 && recentFailureCount === 0) {
@@ -404,7 +488,7 @@ function buildRecentActivitySection(activity?: CoordinatorRecentActivity): strin
404
488
  if (pending > 0) counts.push(`**${pending}** pending`);
405
489
  if (assigned > 0) counts.push(`**${assigned}** assigned`);
406
490
  if (stalled > 0) counts.push(`**${stalled}** stalled`);
407
- if (recentFailureCount > 0) counts.push(`**${recentFailureCount}** failed in the last 30 min`);
491
+ if (recentFailureCount > 0) counts.push(`**${recentFailureCount}** failed in the last ${windowMinutes} min`);
408
492
  if (counts.length) lines.push(`- Queue/ledger: ${counts.join(', ')}.`);
409
493
  if (activity.lastActivityAt) lines.push(`- Last ledger activity: ${activity.lastActivityAt}.`);
410
494
 
@@ -430,6 +514,15 @@ function buildRecentActivitySection(activity?: CoordinatorRecentActivity): strin
430
514
  * note gets the unchanged prompt. Notes are runtime-accumulated lessons that
431
515
  * persist across coordinator restarts and are provider-neutral.
432
516
  */
517
+ /**
518
+ * 6-4 prompt-build caps for the Operating Notes section. These bound how much
519
+ * of the ledger rides into every coordinator prompt — the ledger itself keeps
520
+ * more (keep-latest 100 prune lives in mesh-ledger.ts); this is a separate,
521
+ * tighter cap applied only when composing the prompt.
522
+ */
523
+ const OPERATING_NOTES_PROMPT_CAP = 20;
524
+ const OPERATING_NOTE_MAX_CHARS = 300;
525
+
433
526
  function buildOperatingNotesSection(notes?: CoordinatorOperatingNote[]): string {
434
527
  const valid = Array.isArray(notes)
435
528
  ? notes.filter(n => n && typeof n.text === 'string' && n.text.trim())
@@ -442,16 +535,35 @@ function buildOperatingNotesSection(notes?: CoordinatorOperatingNote[]): string
442
535
  recovery_lesson: 'recovery lesson',
443
536
  };
444
537
 
538
+ // Keep only the most recent OPERATING_NOTES_PROMPT_CAP notes in the prompt.
539
+ // `valid` is oldest-first (ledger order), so the newest are at the tail.
540
+ const omittedCount = Math.max(0, valid.length - OPERATING_NOTES_PROMPT_CAP);
541
+ const shown = omittedCount > 0 ? valid.slice(-OPERATING_NOTES_PROMPT_CAP) : valid;
542
+
445
543
  const lines: string[] = ['## Operating Notes', ''];
446
544
  lines.push('Lessons earlier coordinators on this mesh recorded via `mesh_record_note`. Treat them as accumulated operating knowledge — apply them. When you learn a durable lesson (a provider quirk, a pattern to avoid, a recovery lesson), record it with `mesh_record_note` so future coordinators inherit it.');
447
545
  lines.push('');
448
- for (const n of valid) {
546
+ for (const n of shown) {
449
547
  const cat = n.category && categoryLabel[n.category] ? `[${categoryLabel[n.category]}] ` : '';
450
- lines.push(`- ${cat}${n.text.trim()}`);
548
+ lines.push(`- ${cat}${truncateNote(n.text.trim())}`);
549
+ }
550
+ if (omittedCount > 0) {
551
+ lines.push('');
552
+ lines.push(`_${omittedCount} older note${omittedCount === 1 ? '' : 's'} omitted (kept in ledger; prune with \`mesh_forget_note\`)._`);
451
553
  }
452
554
  return lines.join('\n');
453
555
  }
454
556
 
557
+ /**
558
+ * Truncate a single operating note to OPERATING_NOTE_MAX_CHARS, appending an
559
+ * ellipsis marker so the coordinator knows the note was clipped in the prompt
560
+ * (the full text stays in the ledger).
561
+ */
562
+ function truncateNote(text: string): string {
563
+ if (text.length <= OPERATING_NOTE_MAX_CHARS) return text;
564
+ return `${text.slice(0, OPERATING_NOTE_MAX_CHARS).trimEnd()}… [truncated]`;
565
+ }
566
+
455
567
  function buildPolicySection(policy: RepoMeshPolicy): string {
456
568
  const rules: string[] = [];
457
569
  if (policy.requirePreTaskCheckpoint) rules.push('- Create a git checkpoint **before** starting each task');
@@ -584,5 +696,13 @@ function buildRulesSection(coordinatorCliType?: string): string {
584
696
  - **Honor per-node instructions.** When a node carries a 📌 Node instruction in the nodes section, include the relevant parts of that instruction in the task message you send to that node. Don't paraphrase the instruction into your own words — quote it verbatim so the worker agent sees exactly what the user wrote.
585
697
  - **Mission status does not update itself.** When a mission's tasks are all done or the work is abandoned, explicitly call \`mesh_mission_upsert\` to set status \`completed\` or \`abandoned\`. Never leave a finished mission in \`active\`. All-cancelled tasks with no further work → \`abandoned\`.
586
698
  - **Never fabricate tool results.** Always call the actual tool.
587
- - **Keep the user informed.** One or two sentences after each delegation round.${coordinatorNote}`;
699
+ - **Keep the user informed.** One or two sentences after each delegation round.${coordinatorNote}
700
+
701
+ ### Task Messaging Requirements
702
+
703
+ When you compose the task message you dispatch to a node, include these requirements so the worker follows repo conventions the daemon can't enforce for it:
704
+
705
+ - **OSS English commits.** If a task commits anything under \`oss/\` (an AGPL public repo whose history external contributors read), tell the worker explicitly that commit messages in \`oss/\` MUST be English. Root-level commits (proprietary packages) may use any language.
706
+ - **Scoped test runs.** For a validation or code-change task, instruct the worker to run only the tests covering the changed files (\`vitest run <path>\` or \`-t <name>\`), not the whole suite. Run the full suite only when the task is explicitly a full-suite gate — a broad daemon-core run is minutes of wall-clock and the biggest source of worker slowness.
707
+ - **Branch convergence state.** For a worktree task, require the completion report to classify the touched branch into exactly one final state: \`merged_to_main\`, \`pushed_feature_branch_needs_merge\`, \`blocked_review\`, \`cleanup_candidate\`, or \`not_mergeable\`. A task that ends on a non-main branch is not complete unless the report names that state and the next step.`;
588
708
  }
@@ -6,6 +6,13 @@ import { getLedgerDir, readLedgerEntries, appendLedgerEntry } from './mesh-ledge
6
6
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
7
7
  import { buildMeshSystemMessage, readNonEmptyString, readRecord, resolveEventSessionId, readMeshCompletionSummary, isWeakCompletionMetadata } from './mesh-events-utils.js';
8
8
  import { expandDaemonIdForms } from '@adhdev/mesh-shared';
9
+ import {
10
+ buildPendingEventEmitStamp,
11
+ coordinatorIdentityFromEmitFields,
12
+ MESH_PROTOCOL_VERSION_V2,
13
+ type CoordinatorIdentity,
14
+ type MeshEventScope,
15
+ } from './contracts.js';
9
16
 
10
17
  // ---------------------------------------------------------------------------
11
18
  // MCP coordinator pending-event queue — FILE-BASED PERSISTENCE
@@ -44,6 +51,40 @@ export interface PendingMeshCoordinatorEvent {
44
51
  * the JSONL file without a dedicated column; it is NOT a drain-scoping key.
45
52
  */
46
53
  targetCoordinatorSessionId?: string;
54
+
55
+ // ─── v2 protocol envelope (B2a) — additive, populated at emit time ────────
56
+ // Stamped by queuePendingMeshCoordinatorEvent from the fields above plus an
57
+ // optional emit hint. A v1 reader that ignores these is unaffected (the v2
58
+ // shape is a strict superset). Absent → the event is a v1 event, treated as
59
+ // broadcast during rollout.
60
+ /** '2.0' once stamped. Absent on v1 events. */
61
+ protocolVersion?: typeof MESH_PROTOCOL_VERSION_V2;
62
+ /** Idempotency key (UUID). The receiver's authoritative dedup key (B3). */
63
+ eventId?: string;
64
+ /** Routing scope. Defaulted from the event name unless the emit hint overrides. */
65
+ scope?: MeshEventScope;
66
+ /** Identity of the coordinator that dispatched the work this event reports. */
67
+ dispatchedBy?: CoordinatorIdentity;
68
+ /** Present only for unicast scope: the coordinator this event is addressed to. */
69
+ intendedFor?: CoordinatorIdentity;
70
+ }
71
+
72
+ /**
73
+ * Optional emit-time hint passed to queuePendingMeshCoordinatorEvent so a call
74
+ * site can override the name-defaulted scope or supply richer coordinator
75
+ * identity (e.g. a coordinatorRunId the base event fields don't carry). Every
76
+ * field is optional; when omitted the stamp is derived entirely from the
77
+ * event's own targetCoordinatorDaemonId / targetCoordinatorSessionId. Kept
78
+ * separate from the event so existing single-arg callers are untouched.
79
+ */
80
+ export interface PendingEventEmitHint {
81
+ scope?: MeshEventScope;
82
+ /** Overrides the coordinator identity derived from the event's target fields. */
83
+ dispatchedBy?: CoordinatorIdentity;
84
+ /** Overrides the unicast target derived from the event's target fields. */
85
+ intendedFor?: CoordinatorIdentity;
86
+ /** coordinatorRunId to fold into the derived identity when the event lacks one. */
87
+ coordinatorRunId?: string;
47
88
  }
48
89
 
49
90
  const REFINE_TERMINAL_EVENTS = new Set(['refine:completed', 'refine:failed']);
@@ -327,7 +368,67 @@ function trimPendingEventsIfNeeded(path: string): void {
327
368
  } catch { /* best-effort; if trim fails, append still proceeds */ }
328
369
  }
329
370
 
330
- export function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean {
371
+ /**
372
+ * Stamp the v2 protocol envelope onto a pending event at emit time (B2a).
373
+ *
374
+ * Non-breaking: returns a NEW event object with protocolVersion/eventId/scope/
375
+ * dispatchedBy/intendedFor added when a coordinator identity can be derived,
376
+ * otherwise returns the input unchanged (a v1 event, broadcast-treated during
377
+ * rollout). Identity and the unicast target are derived from the event's own
378
+ * targetCoordinatorDaemonId / targetCoordinatorSessionId (already carried by
379
+ * every producer), so most call sites need no change; the optional `hint`
380
+ * overrides scope/identity where a site knows better.
381
+ *
382
+ * The eventId is generated here (randomUUID) exactly once, so re-queues that
383
+ * pass an already-stamped event keep their original eventId — the idempotency
384
+ * key is stable across re-delivery. An already-stamped event is returned as-is.
385
+ */
386
+ export function stampPendingEventV2(
387
+ event: PendingMeshCoordinatorEvent,
388
+ hint?: PendingEventEmitHint,
389
+ ): PendingMeshCoordinatorEvent {
390
+ // Preserve idempotency across re-queues: never re-stamp an event that already
391
+ // carries a v2 eventId (mesh-reconcile-loop / flushPendingForMeshIdleCoordinators
392
+ // re-queue built events verbatim).
393
+ if (event.protocolVersion === MESH_PROTOCOL_VERSION_V2 && readNonEmptyString(event.eventId)) {
394
+ return event;
395
+ }
396
+
397
+ const dispatchedBy = hint?.dispatchedBy ?? coordinatorIdentityFromEmitFields({
398
+ daemonId: event.targetCoordinatorDaemonId,
399
+ coordinatorRunId: hint?.coordinatorRunId,
400
+ sessionId: event.targetCoordinatorSessionId,
401
+ });
402
+ // The unicast target is, by default, the same coordinator the event is already
403
+ // routed to (its originating coordinator). A hint may override it.
404
+ const intendedFor: CoordinatorIdentity | undefined = hint?.intendedFor ?? dispatchedBy;
405
+
406
+ const stamp = buildPendingEventEmitStamp({
407
+ eventName: event.event,
408
+ eventId: randomUUID(),
409
+ dispatchedBy,
410
+ intendedFor,
411
+ scope: hint?.scope,
412
+ });
413
+ if (!stamp) return event; // no coordinator identity → stays a v1 event
414
+
415
+ return {
416
+ ...event,
417
+ protocolVersion: stamp.protocolVersion,
418
+ eventId: stamp.eventId,
419
+ scope: stamp.scope,
420
+ dispatchedBy: stamp.dispatchedBy,
421
+ ...(stamp.intendedFor ? { intendedFor: stamp.intendedFor } : {}),
422
+ };
423
+ }
424
+
425
+ export function queuePendingMeshCoordinatorEvent(
426
+ rawEvent: PendingMeshCoordinatorEvent,
427
+ hint?: PendingEventEmitHint,
428
+ ): boolean {
429
+ // B2a: stamp the v2 envelope before dedup/persist so the eventId/scope ride
430
+ // into both stores and the fingerprint/dedup logic sees the final shape.
431
+ const event = stampPendingEventV2(rawEvent, hint);
331
432
  try {
332
433
  if (hasPendingRefineTerminalEventDuplicate(event)) {
333
434
  LOG.info('MeshEvents', `Suppressed duplicate pending ${event.event} for refine job ${readRefineJobId(event)}`);
@@ -351,6 +452,15 @@ export function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEv
351
452
  payload: event,
352
453
  fingerprint: fingerprint || null,
353
454
  queuedAt: event.queuedAt,
455
+ // v2 envelope columns (B2a) — all nullable so v1 rows coexist. The
456
+ // authoritative copy still rides inside `payload`; these columns exist
457
+ // for queryable idempotency (event_id) and scope-based drain filtering
458
+ // (scope / intended_for) without JSON-parsing every row.
459
+ protocolVersion: event.protocolVersion ?? null,
460
+ eventId: event.eventId ?? null,
461
+ scope: event.scope ?? null,
462
+ dispatchedBy: event.dispatchedBy ? JSON.stringify(event.dispatchedBy) : null,
463
+ intendedFor: event.intendedFor ? JSON.stringify(event.intendedFor) : null,
354
464
  });
355
465
  sqliteOk = true;
356
466
  } catch {
@@ -20,6 +20,11 @@ import { getConfigDir } from '../config/config.js';
20
20
  import { daemonIdsEquivalent, sessionIdsEquivalent } from '@adhdev/mesh-shared';
21
21
  import { EventEmitter } from 'events';
22
22
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
23
+ import {
24
+ coordinatorIdentityFromEmitFields,
25
+ MESH_PROTOCOL_VERSION_V2,
26
+ type MeshLedgerOriginatingCoordinatorV2,
27
+ } from './contracts.js';
23
28
  // ─── Types ──────────────────────────────────────
24
29
 
25
30
  export type MeshLedgerKind =
@@ -627,6 +632,32 @@ export function buildTaskCompletionEvidence(opts: BuildTaskCompletionEvidenceOpt
627
632
  };
628
633
  }
629
634
 
635
+ /**
636
+ * Build the v2 originating-coordinator stamp for a task_dispatched ledger entry
637
+ * (B2a / design decision §2). This is the source of truth from which a worker's
638
+ * completion emit later restores `dispatchedBy` — it records which coordinator
639
+ * dispatched the task, so the terminal event can be routed (unicast) back to it.
640
+ *
641
+ * Nested under `payload.originatingCoordinator`; additive, so existing readers
642
+ * of the task_dispatched payload are unaffected. Returns undefined when no
643
+ * coordinator daemon id is known (the pre-v2 path) so the caller omits the stamp
644
+ * entirely rather than writing a malformed identity — those entries stay v1 and
645
+ * are broadcast-treated during rollout.
646
+ */
647
+ export function buildLedgerOriginatingCoordinatorStamp(fields: {
648
+ coordinatorDaemonId?: string | null;
649
+ coordinatorRunId?: string | null;
650
+ coordinatorSessionId?: string | null;
651
+ }): MeshLedgerOriginatingCoordinatorV2 | undefined {
652
+ const originatingCoordinator = coordinatorIdentityFromEmitFields({
653
+ daemonId: fields.coordinatorDaemonId,
654
+ coordinatorRunId: fields.coordinatorRunId,
655
+ sessionId: fields.coordinatorSessionId,
656
+ });
657
+ if (!originatingCoordinator) return undefined;
658
+ return { originatingCoordinator, protocolVersion: MESH_PROTOCOL_VERSION_V2 };
659
+ }
660
+
630
661
  /**
631
662
  * Append a new entry to the mesh ledger.
632
663
  * Handles file creation, rotation on size overflow, and atomic writes.
@@ -544,6 +544,12 @@ export function foldMeshNodeIdentityToCanonical(node: any): any {
544
544
  // flips, because both always agree). Mutating in place (not returning a new
545
545
  // object) preserves the cached node-object identity that callers warming an
546
546
  // inline mesh from an already-shared snapshot rely on.
547
+ // Intentional per-field raw compare against the already-computed canonical
548
+ // value: this is the fold's no-op fast path, checking whether EACH form field
549
+ // is already folded. Using meshNodeIdMatches (which normalizes across forms)
550
+ // would defeat the point — we must inspect each raw field's current state, not
551
+ // a form-agnostic match. Hence the identity-guard opt-out below.
552
+ // eslint-disable-next-line no-restricted-syntax -- verified same-source canonical no-op guard (see above)
547
553
  if (node.id === canonical && node.nodeId === canonical && node.node_id === undefined) return node;
548
554
  node.id = canonical;
549
555
  node.nodeId = canonical;
@@ -1094,6 +1094,57 @@ export function __orderEligibleNodesForTests(
1094
1094
  return orderEligibleNodes(meshId, strategy, nodes, opts);
1095
1095
  }
1096
1096
 
1097
+ /** One idle session eligible to claim a queued task, together with the resolved
1098
+ * mesh node record it belongs to. Local candidates come from live CLI instances,
1099
+ * remote candidates from the registered remote-idle-session store; the two arrive
1100
+ * with their `nodeId` under different serialization forms. */
1101
+ type IdleCandidate = { nodeId: string; sessionId: string; providerType: string; origin: 'local' | 'remote'; node: any };
1102
+
1103
+ /**
1104
+ * Merge local + remote idle candidates into the scheduling pool with a single,
1105
+ * form-canonical node identity.
1106
+ *
1107
+ * INVARIANT: every candidate in the returned pool carries its `nodeId` in
1108
+ * CANONICAL (normalized) form, and `uniqueNodes` has exactly one entry per
1109
+ * physical node. Local and remote candidates arrive with their `nodeId` under
1110
+ * mixed forms (config `id`, wire `nodeId`, DB `node_id`). Downstream scheduling
1111
+ * keys the pool by raw-string equality (Set dedup, baseIndex, rankIndex,
1112
+ * nodeActiveLoad), so two candidates for the SAME physical node under two
1113
+ * different forms would otherwise be treated as two distinct nodes — form-drift
1114
+ * that splits a node's load and double-ranks it. Canonicalizing here (via the
1115
+ * resolved node record, which meshNodeIdMatches already matched form-agnostically)
1116
+ * makes every later `=== nodeId` comparison operate on one agreed form. Falls back
1117
+ * to the raw candidate id when the node is unresolved (an unresolved candidate
1118
+ * cannot be normalized, but also has no sibling to collide with).
1119
+ */
1120
+ function buildSchedulingPool(
1121
+ localCandidates: IdleCandidate[],
1122
+ remoteCandidates: IdleCandidate[],
1123
+ ): { pool: IdleCandidate[]; uniqueNodes: RankableNode[] } {
1124
+ const pool = [...localCandidates, ...remoteCandidates].map(c => ({
1125
+ ...c,
1126
+ nodeId: normalizeMeshNodeId(c.node) ?? c.nodeId,
1127
+ }));
1128
+ // Both sides are canonical now, so raw `===` in the Set dedup and the node
1129
+ // re-lookup is form-safe.
1130
+ const uniqueNodes: RankableNode[] = [...new Set(pool.map(c => c.nodeId))]
1131
+ .map((nodeId, index) => ({
1132
+ nodeId,
1133
+ node: pool.find(c => meshNodeIdMatches({ id: c.nodeId } as MeshNodeIdentified, nodeId))?.node,
1134
+ index,
1135
+ }));
1136
+ return { pool, uniqueNodes };
1137
+ }
1138
+
1139
+ /** Test-only: the pool-canonicalization + unique-node collapse stage. Exposed so
1140
+ * the mixed-form dedup invariant can be unit-tested without a live daemon. */
1141
+ export function __buildSchedulingPoolForTests(
1142
+ localCandidates: IdleCandidate[],
1143
+ remoteCandidates: IdleCandidate[],
1144
+ ): { pool: IdleCandidate[]; uniqueNodes: RankableNode[] } {
1145
+ return buildSchedulingPool(localCandidates, remoteCandidates);
1146
+ }
1147
+
1097
1148
  function orderEligibleNodes(
1098
1149
  meshId: string,
1099
1150
  strategy: RepoMeshSchedulingStrategy,
@@ -1946,7 +1997,6 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
1946
1997
  // first and greedily absorbs all untargeted work before any remote idle
1947
1998
  // session is even considered — the comparator alone can't spread work if
1948
1999
  // local is always tried first.
1949
- type IdleCandidate = { nodeId: string; sessionId: string; providerType: string; origin: 'local' | 'remote'; node: any };
1950
2000
  const strategy = resolveSchedulingStrategy(mesh);
1951
2001
  const localCandidates: IdleCandidate[] = [];
1952
2002
 
@@ -2023,12 +2073,13 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
2023
2073
  // Merge local + remote into one pool and drain in scheduling order. Each
2024
2074
  // assignment mutates a node's active load, and the next pick re-reads it,
2025
2075
  // so re-ranking after every assignment keeps the spread fair as load shifts.
2026
- const pool = [...localCandidates, ...remoteCandidates];
2076
+ // buildSchedulingPool canonicalizes every candidate's nodeId so the Set
2077
+ // dedup, baseIndex, rankIndex, and nodeActiveLoad keying below all agree on
2078
+ // one form (see the invariant on that helper).
2079
+ const { pool, uniqueNodes } = buildSchedulingPool(localCandidates, remoteCandidates);
2027
2080
  const baseIndex = new Map<string, number>();
2028
2081
  pool.forEach((c, i) => { if (!baseIndex.has(c.nodeId)) baseIndex.set(c.nodeId, i); });
2029
2082
  // Bump the round-robin cursor once for this whole drain pass.
2030
- const uniqueNodes = [...new Set(pool.map(c => c.nodeId))]
2031
- .map((nodeId, index) => ({ nodeId, node: pool.find(c => c.nodeId === nodeId)?.node, index }));
2032
2083
  const ranked = orderEligibleNodes(meshId, strategy, uniqueNodes, { bumpCursor: true });
2033
2084
  const rankIndex = new Map<string, number>(ranked.map((r, i) => [r.nodeId, i]));
2034
2085
  const remaining = [...pool];