@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.
- package/dist/index.js +423 -50
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +423 -50
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/contracts.d.ts +66 -0
- package/dist/mesh/coordinator-prompt.d.ts +7 -20
- package/dist/mesh/mesh-events-pending.d.ts +45 -1
- package/dist/mesh/mesh-ledger.d.ts +18 -0
- package/dist/mesh/mesh-queue-assignment.d.ts +17 -0
- package/dist/mesh/mesh-runtime-store.d.ts +26 -0
- package/package.json +6 -3
- package/src/commands/router-refine.ts +15 -2
- package/src/mesh/contracts.ts +131 -0
- package/src/mesh/coordinator-prompt.ts +133 -13
- package/src/mesh/mesh-events-pending.ts +111 -1
- package/src/mesh/mesh-ledger.ts +31 -0
- package/src/mesh/mesh-node-identity.ts +6 -0
- package/src/mesh/mesh-queue-assignment.ts +55 -4
- package/src/mesh/mesh-reconcile-loop.ts +147 -17
- package/src/mesh/mesh-runtime-store.ts +164 -3
|
@@ -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
|
|
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
|
-
|
|
192
|
-
if (
|
|
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
|
-
|
|
196
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
package/src/mesh/mesh-ledger.ts
CHANGED
|
@@ -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
|
-
|
|
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];
|