@adhdev/daemon-core 0.9.82-rc.481 → 0.9.82-rc.483
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/config/mesh-config.d.ts +10 -28
- package/dist/index.d.ts +2 -2
- package/dist/index.js +337 -249
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +337 -244
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/contracts.d.ts +35 -9
- package/dist/mesh/mesh-reconcile-loop.d.ts +4 -0
- package/dist/mesh/mesh-runtime-store.d.ts +47 -2
- package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +4 -0
- package/dist/repo-mesh-types.d.ts +8 -14
- package/dist/types.d.ts +15 -6
- package/package.json +3 -3
- package/src/commands/chat-commands-read.ts +81 -60
- package/src/commands/low-family/coordinator-prompt.ts +53 -0
- package/src/commands/med-family/mesh-crud.ts +19 -62
- package/src/config/mesh-config.ts +21 -140
- package/src/index.ts +1 -2
- package/src/mesh/contracts.ts +59 -18
- package/src/mesh/coordinator-prompt.ts +22 -6
- package/src/mesh/mesh-event-forwarding.ts +38 -102
- package/src/mesh/mesh-events-pending.ts +20 -0
- package/src/mesh/mesh-reconcile-loop.ts +157 -2
- package/src/mesh/mesh-runtime-store.ts +118 -3
- package/src/mesh/mesh-unresolved-forward-outbox.ts +30 -0
- package/src/repo-mesh-types.ts +8 -14
- package/src/types.ts +21 -6
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
RepoMeshHostMetadata,
|
|
23
23
|
RepoMeshDaemonRole,
|
|
24
24
|
} from '../repo-mesh-types.js';
|
|
25
|
-
import type {
|
|
25
|
+
import type { MagiKindPanelMap, MagiSlot, MagiTaskKind } from '@adhdev/mesh-shared';
|
|
26
26
|
import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
|
|
27
27
|
import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
|
|
28
28
|
|
|
@@ -570,6 +570,11 @@ export function updateNode(
|
|
|
570
570
|
opts: {
|
|
571
571
|
userOverrides?: Partial<RepoMeshNodeCapabilities>;
|
|
572
572
|
policy?: RepoMeshNodePolicy;
|
|
573
|
+
/** Operator-defined custom capability tags used by mesh queue matching.
|
|
574
|
+
* Passing an array replaces the node's custom tags (empty/whitespace
|
|
575
|
+
* entries dropped, deduped); an empty result clears them. Omit to leave
|
|
576
|
+
* the existing tags untouched. */
|
|
577
|
+
capabilities?: string[];
|
|
573
578
|
worktreeBootstrap?: LocalMeshNodeEntry['worktreeBootstrap'];
|
|
574
579
|
/** Per-node instruction surfaced in the coordinator prompt. Pass an
|
|
575
580
|
* empty string or undefined to clear it. */
|
|
@@ -610,6 +615,13 @@ export function updateNode(
|
|
|
610
615
|
node.reportedDaemonBuildVersion = opts.reportedDaemonBuildVersion.trim();
|
|
611
616
|
}
|
|
612
617
|
if (opts.policy) node.policy = { ...node.policy, ...opts.policy };
|
|
618
|
+
if (Object.prototype.hasOwnProperty.call(opts, 'capabilities')) {
|
|
619
|
+
// Explicit replace: normalize (trim/dedup/drop-empties); an empty result
|
|
620
|
+
// clears the tags entirely so the field never persists as [].
|
|
621
|
+
const tags = normalizeCapabilityTags(opts.capabilities);
|
|
622
|
+
if (tags && tags.length) node.capabilities = tags;
|
|
623
|
+
else delete node.capabilities;
|
|
624
|
+
}
|
|
613
625
|
if (opts.worktreeBootstrap) node.worktreeBootstrap = opts.worktreeBootstrap;
|
|
614
626
|
if (Object.prototype.hasOwnProperty.call(opts, 'systemPrompt')) {
|
|
615
627
|
// Honor explicit clears: { systemPrompt: undefined } drops the field.
|
|
@@ -626,8 +638,10 @@ export function updateNode(
|
|
|
626
638
|
|
|
627
639
|
// ─── MAGI Panels (machine-local cross-verification quorums) ──
|
|
628
640
|
|
|
629
|
-
|
|
630
|
-
|
|
641
|
+
// NOTE: the named-panel model (normalizeMagiPanel / list / get / upsert / remove,
|
|
642
|
+
// stored under meshes.json `magiPanels`) was REMOVED. MAGI now resolves its fan-out
|
|
643
|
+
// slots SOLELY from the per-task_kind `magiKindPanels` binding below. `normalizeMagiSlots`
|
|
644
|
+
// is the sole slot normalizer.
|
|
631
645
|
|
|
632
646
|
function normalizeReplicaCount(value: unknown): number | undefined {
|
|
633
647
|
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
|
|
@@ -635,138 +649,6 @@ function normalizeReplicaCount(value: unknown): number | undefined {
|
|
|
635
649
|
return n >= 1 ? n : undefined;
|
|
636
650
|
}
|
|
637
651
|
|
|
638
|
-
/**
|
|
639
|
-
* Normalize a panel `defaultKind` (the non-binding default output kind). Returns
|
|
640
|
-
* undefined (drop, don't throw) for any absent / unknown value so a stray field
|
|
641
|
-
* never blocks a panel write. 'freeform' is explicitly DROPPED with a warning: a
|
|
642
|
-
* panel is a cross-verification tool and freeform contributes no structured claims
|
|
643
|
-
* (claims:[]), so defaulting to it would silently zero out the very thing the panel
|
|
644
|
-
* exists for. Only the evidence-bearing kinds (claim_audit / rca / design) survive.
|
|
645
|
-
*/
|
|
646
|
-
function normalizeMagiPanelDefaultKind(raw: unknown): MagiPanelDefaultKind | undefined {
|
|
647
|
-
if (raw == null) return undefined;
|
|
648
|
-
const s = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
|
649
|
-
if (s === 'claim_audit' || s === 'rca' || s === 'design') return s;
|
|
650
|
-
if (s === 'freeform') {
|
|
651
|
-
// eslint-disable-next-line no-console
|
|
652
|
-
console.warn(
|
|
653
|
-
"[magi] panel defaultKind='freeform' rejected — freeform contributes no structured claims to cross-verification; dropping (use claim_audit / rca / design, or omit).",
|
|
654
|
-
);
|
|
655
|
-
return undefined;
|
|
656
|
-
}
|
|
657
|
-
// Any other value (typo / unsupported kind): drop silently — the panel still
|
|
658
|
-
// resolves to the claim_audit fallback at review time.
|
|
659
|
-
return undefined;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
/**
|
|
663
|
-
* Validate + normalize a panel config before persisting. Mirrors the node-config
|
|
664
|
-
* normalization style (mesh-config addNode/updateNode): trims strings, drops
|
|
665
|
-
* empties, requires a provider per member, clamps replica counts. Throws on
|
|
666
|
-
* structurally invalid input so the calling tool returns a clear error rather than
|
|
667
|
-
* writing a malformed panel.
|
|
668
|
-
*/
|
|
669
|
-
export function normalizeMagiPanel(config: unknown): MagiPanel {
|
|
670
|
-
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
671
|
-
throw new Error('invalid_magi_panel: config must be an object');
|
|
672
|
-
}
|
|
673
|
-
const raw = config as Record<string, unknown>;
|
|
674
|
-
const rawMembers = raw.members;
|
|
675
|
-
if (!Array.isArray(rawMembers) || rawMembers.length === 0) {
|
|
676
|
-
throw new Error('invalid_magi_panel: members must be a non-empty array');
|
|
677
|
-
}
|
|
678
|
-
if (rawMembers.length > MAX_MAGI_PANEL_MEMBERS) {
|
|
679
|
-
throw new Error(`invalid_magi_panel: too many members (max ${MAX_MAGI_PANEL_MEMBERS})`);
|
|
680
|
-
}
|
|
681
|
-
const members: MagiPanelMember[] = rawMembers.map((entry, idx) => {
|
|
682
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
683
|
-
throw new Error(`invalid_magi_panel: member[${idx}] must be an object`);
|
|
684
|
-
}
|
|
685
|
-
const m = entry as Record<string, unknown>;
|
|
686
|
-
const provider = typeof m.provider === 'string' ? m.provider.trim() : '';
|
|
687
|
-
if (!provider) {
|
|
688
|
-
throw new Error(`invalid_magi_panel: member[${idx}].provider is required`);
|
|
689
|
-
}
|
|
690
|
-
const nodeId = typeof m.nodeId === 'string' && m.nodeId.trim() ? m.nodeId.trim() : undefined;
|
|
691
|
-
const model = typeof m.model === 'string' && m.model.trim() ? m.model.trim() : undefined;
|
|
692
|
-
const capabilityTags = normalizeCapabilityTags(m.capabilityTags);
|
|
693
|
-
const n = normalizeReplicaCount(m.n);
|
|
694
|
-
return {
|
|
695
|
-
provider,
|
|
696
|
-
...(nodeId ? { nodeId } : {}),
|
|
697
|
-
...(model ? { model } : {}),
|
|
698
|
-
...(capabilityTags ? { capabilityTags } : {}),
|
|
699
|
-
...(n !== undefined ? { n } : {}),
|
|
700
|
-
};
|
|
701
|
-
});
|
|
702
|
-
const description = typeof raw.description === 'string' && raw.description.trim()
|
|
703
|
-
? raw.description.trim().slice(0, 200)
|
|
704
|
-
: undefined;
|
|
705
|
-
const defaultN = normalizeReplicaCount(raw.defaultN);
|
|
706
|
-
const defaultKind = normalizeMagiPanelDefaultKind(raw.defaultKind);
|
|
707
|
-
return {
|
|
708
|
-
...(description ? { description } : {}),
|
|
709
|
-
members,
|
|
710
|
-
...(defaultN !== undefined ? { defaultN } : {}),
|
|
711
|
-
...(defaultKind !== undefined ? { defaultKind } : {}),
|
|
712
|
-
// dedupExempt is always meaningful for a MAGI panel (intentional same-prompt
|
|
713
|
-
// fan-out). Persist it true unless the caller explicitly disables it.
|
|
714
|
-
dedupExempt: raw.dedupExempt === false ? false : true,
|
|
715
|
-
};
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function normalizePanelName(name: unknown): string {
|
|
719
|
-
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
720
|
-
if (!trimmed) throw new Error('invalid_magi_panel: panel name is required');
|
|
721
|
-
return trimmed.slice(0, 100);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/** All configured MAGI panels (machine-local), keyed by name. Empty when none. */
|
|
725
|
-
export function listMagiPanels(): Record<string, MagiPanel> {
|
|
726
|
-
return loadMeshConfig().magiPanels ?? {};
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
/** A single panel by name, or undefined when not configured. */
|
|
730
|
-
export function getMagiPanel(name: string): MagiPanel | undefined {
|
|
731
|
-
const key = typeof name === 'string' ? name.trim() : '';
|
|
732
|
-
if (!key) return undefined;
|
|
733
|
-
return loadMeshConfig().magiPanels?.[key];
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* Upsert a named panel into meshes.json. Defaults to refusing to clobber an
|
|
738
|
-
* existing panel (overwrite=false) — mirrors the mesh_init write/overwrite
|
|
739
|
-
* precedent. Returns the normalized, persisted panel.
|
|
740
|
-
*/
|
|
741
|
-
export function upsertMagiPanel(
|
|
742
|
-
name: string,
|
|
743
|
-
config: unknown,
|
|
744
|
-
opts: { overwrite?: boolean } = {},
|
|
745
|
-
): MagiPanel {
|
|
746
|
-
const key = normalizePanelName(name);
|
|
747
|
-
const panel = normalizeMagiPanel(config);
|
|
748
|
-
const stored = loadMeshConfig();
|
|
749
|
-
const panels = stored.magiPanels ?? {};
|
|
750
|
-
if (panels[key] && opts.overwrite !== true) {
|
|
751
|
-
throw new Error(`magi_panel_exists: panel '${key}' already exists — pass overwrite=true to replace it`);
|
|
752
|
-
}
|
|
753
|
-
panels[key] = panel;
|
|
754
|
-
stored.magiPanels = panels;
|
|
755
|
-
saveMeshConfig(stored);
|
|
756
|
-
return panel;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
/** Remove a named panel. Returns true when a panel was removed. */
|
|
760
|
-
export function removeMagiPanel(name: string): boolean {
|
|
761
|
-
const key = typeof name === 'string' ? name.trim() : '';
|
|
762
|
-
if (!key) return false;
|
|
763
|
-
const stored = loadMeshConfig();
|
|
764
|
-
if (!stored.magiPanels || !stored.magiPanels[key]) return false;
|
|
765
|
-
delete stored.magiPanels[key];
|
|
766
|
-
saveMeshConfig(stored);
|
|
767
|
-
return true;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
652
|
// ─── MAGI kind → panel bindings (MAGI-KIND-PANEL) ─────────
|
|
771
653
|
//
|
|
772
654
|
// Per-task_kind slot lists (machine-local, meshes.json `magiKindPanels`). A bare
|
|
@@ -788,11 +670,10 @@ function normalizeMagiTaskKindKey(raw: unknown): MagiTaskKind {
|
|
|
788
670
|
}
|
|
789
671
|
|
|
790
672
|
/**
|
|
791
|
-
* Validate + normalize a kind-panel's slots
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
* error. Returns the normalized slot array.
|
|
673
|
+
* Validate + normalize a kind-panel's slots (the SOLE MAGI slot normalizer): provider
|
|
674
|
+
* required per slot, trims strings, drops empties, clamps replica counts, and carries
|
|
675
|
+
* an optional per-slot `model`. Throws on structurally invalid input (empty list / no
|
|
676
|
+
* provider) so the write returns a clear error. Returns the normalized slot array.
|
|
796
677
|
*/
|
|
797
678
|
export function normalizeMagiSlots(slots: unknown): MagiSlot[] {
|
|
798
679
|
if (!Array.isArray(slots) || slots.length === 0) {
|
package/src/index.ts
CHANGED
|
@@ -195,7 +195,6 @@ export type { SavedProviderSessionEntry } from './config/saved-sessions.js';
|
|
|
195
195
|
export {
|
|
196
196
|
listMeshes, getMesh, getMeshByRepo, createMesh, updateMesh, deleteMesh,
|
|
197
197
|
addNode, removeNode, updateNode, normalizeRepoIdentity,
|
|
198
|
-
listMagiPanels, getMagiPanel, upsertMagiPanel, removeMagiPanel, normalizeMagiPanel,
|
|
199
198
|
listMagiKindPanels, getMagiKindPanel, setMagiKindPanel, removeMagiKindPanel, normalizeMagiSlots,
|
|
200
199
|
} from './config/mesh-config.js';
|
|
201
200
|
export type { CreateMeshOptions, UpdateMeshOptions, AddNodeOptions } from './config/mesh-config.js';
|
|
@@ -203,7 +202,7 @@ export type { CreateMeshOptions, UpdateMeshOptions, AddNodeOptions } from './con
|
|
|
203
202
|
// leaf so the mcp-server — which depends only on @adhdev/daemon-core — can consume
|
|
204
203
|
// them without taking a direct @adhdev/mesh-shared dependency).
|
|
205
204
|
export type {
|
|
206
|
-
|
|
205
|
+
MagiMode, MagiTaskKind,
|
|
207
206
|
MagiSlot, MagiKindPanelMap,
|
|
208
207
|
MagiClaim, MagiClaimStance, MagiAgentResponse,
|
|
209
208
|
MagiResponseSource, MagiReplicaGitRef, MagiGitSkew, MagiSynthesizedResponse,
|
package/src/mesh/contracts.ts
CHANGED
|
@@ -332,7 +332,12 @@ export function assertPendingMeshCoordinatorEventV2(raw: unknown, path = '$'): P
|
|
|
332
332
|
* Decide whether a v2 pending event should be delivered to the given drainer.
|
|
333
333
|
* Centralised so every drain implementation uses the same rule.
|
|
334
334
|
*
|
|
335
|
-
* - 'broadcast': always delivered.
|
|
335
|
+
* - 'broadcast': always delivered. NOTE: a terminal task event that reached the
|
|
336
|
+
* queue as broadcast is an ownership leak (it belongs to its dispatching
|
|
337
|
+
* coordinator). This pure helper does not have the drain-window
|
|
338
|
+
* daemon-form/session matching semantics, so the terminal+broadcast
|
|
339
|
+
* dispatchedBy filter is applied one layer up in the drainer (see
|
|
340
|
+
* mesh-events-pending routeV2EventsForDrainer) where those semantics live.
|
|
336
341
|
* - 'system': never delivered to coordinators (system handler only).
|
|
337
342
|
* - 'unicast': delivered iff intendedFor matches drainer identity.
|
|
338
343
|
*
|
|
@@ -368,25 +373,43 @@ const TERMINAL_TASK_EVENTS: ReadonlySet<string> = new Set([
|
|
|
368
373
|
]);
|
|
369
374
|
|
|
370
375
|
/**
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
376
|
+
* True for a terminal task event (completion / stop / refine outcome). A terminal
|
|
377
|
+
* event belongs to exactly the coordinator that dispatched the task, so it must
|
|
378
|
+
* never fan out to sibling coordinators that did not dispatch it. Used by the
|
|
379
|
+
* emit-side stamp (to avoid downgrading an unaddressed terminal event to full
|
|
380
|
+
* broadcast) and by the drain-side filter (defense-in-depth for any terminal
|
|
381
|
+
* event that already reached the queue as broadcast).
|
|
374
382
|
*/
|
|
375
|
-
|
|
383
|
+
export function isTerminalTaskEvent(eventName: string): boolean {
|
|
384
|
+
return TERMINAL_TASK_EVENTS.has(eventName);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Coordinator-addressed dispatch-plane alerts. `mesh:dispatch_blocked` is the
|
|
389
|
+
* Fix (1) actionable dispatch-skip notification: it exists precisely to page the
|
|
390
|
+
* ORIGINATING coordinator (it carries a why+how coordinatorMessage and is
|
|
391
|
+
* targetCoordinator*-addressed by its producer), so it routes unicast exactly
|
|
392
|
+
* like a terminal task event. B2a originally classed it 'system' — that made it
|
|
393
|
+
* a dead letter: no daemon-level system drain exists, so the blocker never
|
|
394
|
+
* reached any coordinator and the task sat silently undispatched.
|
|
395
|
+
*/
|
|
396
|
+
const COORDINATOR_ALERT_EVENTS: ReadonlySet<string> = new Set([
|
|
376
397
|
'mesh:dispatch_blocked',
|
|
377
398
|
]);
|
|
378
399
|
|
|
379
400
|
/**
|
|
380
401
|
* Default the v2 scope for an event by its producer event name (design decision
|
|
381
|
-
* §3). Terminal task events → unicast (routed
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
402
|
+
* §3). Terminal task events and coordinator-addressed alerts → unicast (routed
|
|
403
|
+
* to the originating coordinator). Everything else — node lifecycle and
|
|
404
|
+
* progress signals — → broadcast, which also matches v1's implicit "deliver to
|
|
405
|
+
* any coordinator" behaviour, so an unstamped v1 event and a v2-stamped-as-
|
|
406
|
+
* broadcast event route identically during rollout. No event currently defaults
|
|
407
|
+
* to 'system'; the scope remains in MESH_EVENT_SCOPES for wire compatibility
|
|
408
|
+
* (an already-queued or version-skewed 'system' event still routes away from
|
|
409
|
+
* coordinators).
|
|
386
410
|
*/
|
|
387
411
|
export function defaultScopeForEvent(eventName: string): MeshEventScope {
|
|
388
|
-
if (
|
|
389
|
-
if (TERMINAL_TASK_EVENTS.has(eventName)) return 'unicast';
|
|
412
|
+
if (TERMINAL_TASK_EVENTS.has(eventName) || COORDINATOR_ALERT_EVENTS.has(eventName)) return 'unicast';
|
|
390
413
|
return 'broadcast';
|
|
391
414
|
}
|
|
392
415
|
|
|
@@ -439,9 +462,18 @@ export function coordinatorIdentityFromEmitFields(fields: {
|
|
|
439
462
|
* returns undefined: the event stays a v1 (unstamped) event and is broadcast-
|
|
440
463
|
* treated during rollout, exactly as before — no regression, no fabricated
|
|
441
464
|
* identity. When the resolved scope is 'unicast' but no `intendedFor` is
|
|
442
|
-
* available, the
|
|
443
|
-
*
|
|
444
|
-
*
|
|
465
|
+
* available, the fallback depends on the event class:
|
|
466
|
+
*
|
|
467
|
+
* - Terminal task events (completion / stop / refine outcome) MUST NOT be
|
|
468
|
+
* broadcast to every coordinator — a completion belongs to the coordinator
|
|
469
|
+
* that dispatched the task, and broadcasting it makes non-owner coordinators
|
|
470
|
+
* (e.g. sibling MAGI coordinators that never dispatched this replica's task)
|
|
471
|
+
* act on a completion that is not theirs (MAGI-REPLICA-COMPLETION-EVENT-LEAK).
|
|
472
|
+
* For these we address the event to `dispatchedBy` (the dispatching
|
|
473
|
+
* coordinator) and KEEP it unicast, so the stamp stays contract-valid and the
|
|
474
|
+
* event reaches only its originating coordinator.
|
|
475
|
+
* - Any other unicast event with no addressable target falls back to broadcast
|
|
476
|
+
* (contract-valid, still delivered, never dropped) — unchanged.
|
|
445
477
|
*/
|
|
446
478
|
export function buildPendingEventEmitStamp(opts: {
|
|
447
479
|
eventName: string;
|
|
@@ -454,9 +486,18 @@ export function buildPendingEventEmitStamp(opts: {
|
|
|
454
486
|
let scope: MeshEventScope = opts.scope ?? defaultScopeForEvent(opts.eventName);
|
|
455
487
|
let intendedFor = opts.intendedFor;
|
|
456
488
|
if (scope === 'unicast' && !intendedFor) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
489
|
+
if (isTerminalTaskEvent(opts.eventName)) {
|
|
490
|
+
// Terminal event with no explicit target: address it to the dispatching
|
|
491
|
+
// coordinator rather than broadcasting to every coordinator. dispatchedBy
|
|
492
|
+
// is the coordinator that owns the task, so this is the correct — and
|
|
493
|
+
// contract-valid (unicast requires intendedFor) — narrowing.
|
|
494
|
+
intendedFor = opts.dispatchedBy;
|
|
495
|
+
} else {
|
|
496
|
+
// No addressable target for a non-terminal unicast event — fall back to
|
|
497
|
+
// broadcast so the stamp is contract-valid and the event is still
|
|
498
|
+
// delivered (never dropped).
|
|
499
|
+
scope = 'broadcast';
|
|
500
|
+
}
|
|
460
501
|
}
|
|
461
502
|
if (scope !== 'unicast') intendedFor = undefined;
|
|
462
503
|
return {
|
|
@@ -454,6 +454,23 @@ function buildNodeConfigSection(mesh: LocalMeshEntry): string {
|
|
|
454
454
|
: [];
|
|
455
455
|
const providerRolesSuffix = providerRoles.length ? ` | caps: ${providerRoles.join(', ')}` : '';
|
|
456
456
|
lines.push(`- ${explicitLabel} nodeId: \`${n.id}\` | workspace: \`${n.workspace}\`${n.daemonId ? ` | daemon: \`${n.daemonId}\`` : ''}${providerPriority}${providerRolesSuffix}${suffix}`);
|
|
457
|
+
// Routing tags: what this node advertises for mesh_enqueue_task required_tags.
|
|
458
|
+
// Surfaced so the coordinator can route by-capability (e.g. enqueue a Windows
|
|
459
|
+
// build with required_tags:["os=win32"], or a custom "test-runner" node).
|
|
460
|
+
// os=/arch= use the same userOverrides → reported precedence as the matcher;
|
|
461
|
+
// the internal converge= tag is omitted (it is not something to target by hand).
|
|
462
|
+
const routingTags: string[] = [];
|
|
463
|
+
const custom = Array.isArray((n as any).capabilities) ? (n as any).capabilities : [];
|
|
464
|
+
for (const t of custom) { const s = typeof t === 'string' ? t.trim() : ''; if (s) routingTags.push(s); }
|
|
465
|
+
const tagOs = ((n as any).userOverrides?.platform || (n as any).reportedPlatform || '').toString().trim();
|
|
466
|
+
const tagArch = ((n as any).userOverrides?.arch || (n as any).reportedArch || '').toString().trim();
|
|
467
|
+
if (tagOs) routingTags.push(`os=${tagOs}`);
|
|
468
|
+
if (tagArch) routingTags.push(`arch=${tagArch}`);
|
|
469
|
+
const wtBranch = typeof (n as any).worktreeBranch === 'string' ? (n as any).worktreeBranch.trim() : '';
|
|
470
|
+
if (n.isLocalWorktree && wtBranch) routingTags.push(`worktree=${wtBranch}`);
|
|
471
|
+
if (routingTags.length) {
|
|
472
|
+
lines.push(` 🏷️ routing tags: ${routingTags.map(t => `\`${t}\``).join(', ')}`);
|
|
473
|
+
}
|
|
457
474
|
const nodePrompt = typeof (n as any).systemPrompt === 'string' ? (n as any).systemPrompt.trim() : '';
|
|
458
475
|
if (nodePrompt) {
|
|
459
476
|
lines.push(` 📌 Node instruction: ${indentFollowing(nodePrompt, ' ')}`);
|
|
@@ -649,9 +666,7 @@ const TOOLS_SECTION = `## Available Tools
|
|
|
649
666
|
| \`mesh_write_mesh_json_config\` | Gated write of \`.adhdev/mesh.json\` (repo coordinator-prompt config) from the mesh entry — dry-run/overwrite like mesh_init |
|
|
650
667
|
| \`mesh_magi_review\` | Cross-verify a read-only investigation across a standing panel of independent mesh agents (different machines/providers) instead of a single worker |
|
|
651
668
|
| \`mesh_magi_collect\` | Collect + synthesize a previously dispatched MAGI fan-out by its consensus group id (async companion to mesh_magi_review wait:false) |
|
|
652
|
-
| \`
|
|
653
|
-
| \`mesh_magi_panel_list\` | List configured MAGI panels and resolve each member's availability against the current mesh (read-only) |
|
|
654
|
-
| \`mesh_magi_kind_panel_set\` | Bind a task_kind → MAGI kind-panel slots (machine-local, wholesale replacement — approve current-vs-new first) |
|
|
669
|
+
| \`mesh_magi_kind_panel_set\` | Bind a task_kind → MAGI kind-panel slots (the SOLE MAGI panel-resolution surface; machine-local, wholesale replacement — approve current-vs-new first) |
|
|
655
670
|
| \`mesh_magi_kind_panel_list\` | List configured task_kind → MAGI kind-panel slot bindings (machine-local, read-only) |`;
|
|
656
671
|
|
|
657
672
|
const TOOL_EXPOSURE_PREFLIGHT_SECTION = `## Tool Exposure Preflight
|
|
@@ -665,6 +680,7 @@ const WORKFLOW_SECTION = `## Orchestration Workflow
|
|
|
665
680
|
3. **Queue / Delegate** — The Mesh uses an autonomous pull-based Work Queue:
|
|
666
681
|
a. **General Tasks**: Enqueue tasks using \`mesh_enqueue_task\`. Idle node agents will automatically pull tasks from the queue and begin working.
|
|
667
682
|
b. **Node Preparation**: Reuse an existing idle session on the correct node/provider before launching a new chat/session. Call \`mesh_launch_session\` only when no suitable session exists, when the user explicitly asks for a fresh provider/session, or when branch/worktree isolation requires it. If you need branch isolation for parallel work, call \`mesh_clone_node\` to create a worktree node first.
|
|
683
|
+
b1. **Keep a branch's work on its worktree (worktree affinity).** A worktree node is a durable per-branch workspace, not a one-task throwaway — implement, review, and fix for the same branch all belong on the SAME worktree, and it lives until its work is converged (merged/pushed) and it is cleaned up. So once you clone a worktree for a branch, route every subsequent \`code_change\`/\`validation\`/fix task for that branch back to that same node: pass \`required_tags: ["worktree=<branch>"]\` or \`target_node_id: <that worktree node's id>\`. **Where to get the node id / tag:** the \`mesh_clone_node\` result returns the new node's \`id\` and \`worktreeBranch\` directly — use them immediately. The Configured Nodes list in this prompt is a launch-time snapshot and will NOT list a worktree you cloned after this session started, so do not rely on it for freshly-cloned worktrees; take the id/branch from the \`mesh_clone_node\` result, or call \`mesh_status\` to re-list the live nodes (each worktree there advertises its \`worktree=<branch>\` tag). Do NOT leave same-branch follow-ups untargeted — an untargeted task is claimed by whichever node polls first (usually the base machine node), which strands the work off the branch's worktree. The ONE exception is a \`convergence\` task (merge/push): that is base-only and must NOT be pinned to the worktree.
|
|
668
684
|
c. **Targeted Tasks**: Use \`mesh_send_task\` only when you need to bypass the queue and force a specific node to execute a task immediately.
|
|
669
685
|
d. For the first dispatch of a new task, provide a **complete, self-contained** instruction that includes all context the agent needs (file paths, line numbers, what to change, why). Do not send partial instructions expecting future follow-up.
|
|
670
686
|
e. For a continuation of the same issue in an existing session, send a concise **delta instruction**: current verified state, the exact failed/blocked step, the newly approved action, and final reporting requirements. Do not resend the full original task or open a new chat solely to continue the same work; that wastes coordinator and worker context.
|
|
@@ -694,7 +710,7 @@ When the user asks to **set up / configure / onboard** this repo for Repo Mesh (
|
|
|
694
710
|
|
|
695
711
|
**Save scopes — label every draft with its scope before asking for approval:**
|
|
696
712
|
- **repo-file (commit target)** — \`.adhdev/refine.json\`, \`.adhdev/worktree_bootstrap.json\`, \`.adhdev/change-impact.json\`, \`.adhdev/mesh.json\`. These are committed to the repository and shared with every machine/contributor.
|
|
697
|
-
- **machine-local** — MAGI kind→panel bindings
|
|
713
|
+
- **machine-local** — MAGI kind→panel bindings, node providerPriority (\`~/.adhdev/meshes.json\`). These stay on this machine and are NOT committed.
|
|
698
714
|
|
|
699
715
|
**Guided sequence:**
|
|
700
716
|
1. **Scan (dry-run)** — Call \`mesh_init\` (write=false, the default). It returns per-domain suggested configs for refine / worktree_bootstrap / change-impact, a recommended providerPriority, AND \`currentConfig\` — the currently-saved config per domain (repo files + machine-local \`magiKindPanels\`). Nothing is written.
|
|
@@ -702,8 +718,7 @@ When the user asks to **set up / configure / onboard** this repo for Repo Mesh (
|
|
|
702
718
|
3. **Approve → gated write** — Only after the user approves, call the matching gated-write tool:
|
|
703
719
|
- repo \`.adhdev/*\` config files → \`mesh_init\` with \`write=true\` (and \`overwrite=true\` ONLY for domains the user approved replacing).
|
|
704
720
|
- \`.adhdev/mesh.json\` (coordinator prompt / operating notes) → \`mesh_write_mesh_json_config\` (write=true, overwrite only if approved).
|
|
705
|
-
- machine-local MAGI kind→panel slots → \`mesh_magi_kind_panel_set\` (write=true). NOTE: a kind binding is a **wholesale replacement** of that kind's slot list — present the current-vs-new slots first.
|
|
706
|
-
- machine-local named MAGI panels → \`mesh_magi_panel_set\`. providerPriority → apply via node policy update.
|
|
721
|
+
- machine-local MAGI kind→panel slots → \`mesh_magi_kind_panel_set\` (write=true). NOTE: a kind binding is a **wholesale replacement** of that kind's slot list — present the current-vs-new slots first. providerPriority → apply via node policy update.
|
|
707
722
|
|
|
708
723
|
**init vs reinit:**
|
|
709
724
|
- **\`mesh_init\`** — for a fresh, never-onboarded repo. Existing config files are kept (existing-wins) unless the user explicitly approves overwrite. Use for first-time setup.
|
|
@@ -721,6 +736,7 @@ function buildRulesSection(coordinatorCliType?: string): string {
|
|
|
721
736
|
- **Route, don't implement.** Delegate all code reading, analysis, and execution to node agents. Never read source files or run commands in the coordinator — keep context lean.
|
|
722
737
|
- **Front-load task messages.** Include everything the agent needs (files, problem, expected fix) in \`mesh_enqueue_task\` / \`mesh_send_task\`. Append a structured result request at the end: ask the worker to conclude with a JSON block containing \`status\`, \`changedFiles\`, \`gitStatus\`, \`validationResults\`, \`errors\`, \`nextAction\`. The daemon parses this automatically; you can read it from \`mesh_task_history\`.
|
|
723
738
|
- **Reuse idle sessions.** For follow-up, retry, commit/push, or cleanup on the same issue, send only the delta to the existing idle session. Start fresh only for independent work, provider mismatch, transcript contamination, or required worktree isolation.
|
|
739
|
+
- **Worktree affinity.** A worktree is a durable per-branch workspace; keep all of a branch's code_change/fix/review work on its worktree node by targeting \`required_tags: ["worktree=<branch>"]\` or \`target_node_id\`. Get the id/branch from the \`mesh_clone_node\` result or a live \`mesh_status\` — the Configured Nodes snapshot won't list a worktree cloned after launch. Untargeted same-branch follow-ups drift to the base node. Only \`convergence\` (merge/push) runs on the base, never pinned to the worktree.
|
|
724
740
|
- **Respect explicit provider requests.** Map: Hermes → \`hermes-cli\`, Claude/Claude Code → \`claude-cli\`, Codex → \`codex-cli\`, Gemini → \`gemini-cli\`, Antigravity → \`antigravity-cli\`. Never substitute the coordinator's own runtime.
|
|
725
741
|
- **Verify via git, not source.** Use \`mesh_git_status\` to confirm side effects. Treat agent summaries as self-reports, not verification.
|
|
726
742
|
- **Limit parallelism.** Start with 1–2 tasks; scale only on success. Never duplicate a session because \`mesh_read_chat\` shows no final message while tool/terminal activity is ongoing. This caps *concurrent* load — it does not mean serialize independent work: when a new, independent request arrives and there is headroom under \`maxParallelTasks\`, dispatch it right away rather than waiting for an in-flight task or a user nudge (read-only diagnosis especially, since it has no merge cost).
|
|
@@ -6,12 +6,12 @@ import { appendLedgerEntry, buildTaskCompletionEvidence, getSessionRecoveryConte
|
|
|
6
6
|
import type { SessionRecoveryContext } from './mesh-ledger.js';
|
|
7
7
|
import { updateSessionTaskStatus, enqueueTask, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches, hasPendingDependents, getQueue } from './mesh-work-queue.js';
|
|
8
8
|
import { markSessionDeliveriesTerminal, updateSessionDeliveryStatus } from './mesh-delivery-policy.js';
|
|
9
|
-
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
9
|
+
import { MeshRuntimeStore, pruneMeshRuntimeRetention } from './mesh-runtime-store.js';
|
|
10
10
|
import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, prunePendingMeshCoordinatorEventsRetention, readV2EnvelopeFromWire, type PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
11
11
|
import type { ProviderInstance } from '../providers/provider-instance.js';
|
|
12
12
|
import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent, isUnroutableDelegateRejection } from './mesh-routing.js';
|
|
13
13
|
import { resolveMeshHostStatus } from './mesh-host-ownership.js';
|
|
14
|
-
import { enqueueUnresolvedDelegateForward,
|
|
14
|
+
import { enqueueUnresolvedDelegateForward, nudgeUnresolvedForwardRetry } from './mesh-unresolved-forward-outbox.js';
|
|
15
15
|
import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
|
|
16
16
|
import { getLastDisplayMessage } from '../status/snapshot.js';
|
|
17
17
|
import { resolveDelegatedWorkerAutoApprove } from '../repo-mesh-types.js';
|
|
@@ -189,13 +189,16 @@ function sweepExpiredRemoteIdleSessions(): void {
|
|
|
189
189
|
try {
|
|
190
190
|
MeshRuntimeStore.getInstance().pruneExpiredRemoteIdleSessions();
|
|
191
191
|
} catch { /* best-effort */ }
|
|
192
|
-
// Piggyback the
|
|
193
|
-
//
|
|
194
|
-
//
|
|
192
|
+
// Piggyback the retention prunes on the same periodic sweep, but hourly — this
|
|
193
|
+
// is the maintenance hook that keeps mesh-runtime.db from accumulating stale
|
|
194
|
+
// rows without bound: mesh_pending_events (drained/orphaned rows) plus, on the
|
|
195
|
+
// SAME cadence (SoT 1-11 (b)), the event ledger / tool-call log / terminal
|
|
196
|
+
// queue retention in pruneMeshRuntimeRetention.
|
|
195
197
|
const now = Date.now();
|
|
196
198
|
if (now - lastPendingEventsPruneAt >= PENDING_EVENTS_PRUNE_INTERVAL_MS) {
|
|
197
199
|
lastPendingEventsPruneAt = now;
|
|
198
200
|
prunePendingMeshCoordinatorEventsRetention();
|
|
201
|
+
pruneMeshRuntimeRetention();
|
|
199
202
|
}
|
|
200
203
|
}
|
|
201
204
|
|
|
@@ -1591,43 +1594,6 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
|
|
|
1591
1594
|
});
|
|
1592
1595
|
}
|
|
1593
1596
|
|
|
1594
|
-
// ---------------------------------------------------------------------------
|
|
1595
|
-
// Per-coordinator forward serialization (P2P send-backpressure relief).
|
|
1596
|
-
//
|
|
1597
|
-
// When several workers finish at once, each completion runs forwardUnresolvedDelegate
|
|
1598
|
-
// Event and fires its own `mesh_forward_event` push. Firing the whole burst
|
|
1599
|
-
// concurrently dumps it into the single per-peer P2P DataChannel buffer in one tick,
|
|
1600
|
-
// which starves the rpc_ack/rpc_res replies the same channel must carry — a
|
|
1601
|
-
// coordinator's inbound `git_status` then times out even though the worker's own
|
|
1602
|
-
// forward acks return in ~1s. To cap the concurrent burst we serialize the immediate
|
|
1603
|
-
// pushes per coordinator: at most one push is in flight to a given coordinator at a
|
|
1604
|
-
// time, the rest run in arrival order behind it. A lone event (idle lane) still
|
|
1605
|
-
// dispatches immediately — only a genuine burst is paced. Durability is unchanged:
|
|
1606
|
-
// every event is already persisted to the outbox before the push runs, so serializing
|
|
1607
|
-
// only delays the best-effort fast path; PHASE 0 retry still covers any gap. This pairs
|
|
1608
|
-
// with the DataChannel send-buffer gate in daemon-cloud's mesh manager (writeRequest),
|
|
1609
|
-
// which is the hard guarantee; this throttle keeps the burst from piling up there.
|
|
1610
|
-
interface CoordinatorForwardLane { tail: Promise<unknown>; depth: number; }
|
|
1611
|
-
const coordinatorForwardLanes = new Map<string, CoordinatorForwardLane>();
|
|
1612
|
-
function enqueueCoordinatorForwardPush(coordinatorDaemonId: string, run: () => Promise<unknown>): void {
|
|
1613
|
-
let lane = coordinatorForwardLanes.get(coordinatorDaemonId);
|
|
1614
|
-
if (!lane) { lane = { tail: Promise.resolve(), depth: 0 }; coordinatorForwardLanes.set(coordinatorDaemonId, lane); }
|
|
1615
|
-
const wasIdle = lane.depth === 0;
|
|
1616
|
-
lane.depth += 1;
|
|
1617
|
-
const dec = (): void => { lane!.depth -= 1; };
|
|
1618
|
-
if (wasIdle) {
|
|
1619
|
-
// Idle lane → dispatch synchronously, so a lone completion (the common case) has
|
|
1620
|
-
// ZERO added latency and the push call happens in-line. Only a genuine burst —
|
|
1621
|
-
// events arriving while a push is still in flight — is paced (else branch).
|
|
1622
|
-
lane.tail = Promise.resolve(run()).catch(() => {}).then(dec, dec);
|
|
1623
|
-
} else {
|
|
1624
|
-
// Burst: queue behind the in-flight push(es) in arrival order so the whole burst
|
|
1625
|
-
// is not dumped into the shared DataChannel buffer at once. The tail is guarded
|
|
1626
|
-
// so one rejecting push never wedges the lane for the next.
|
|
1627
|
-
lane.tail = lane.tail.then(() => run()).catch(() => {}).then(dec, dec);
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
1597
|
// ---------------------------------------------------------------------------
|
|
1632
1598
|
// Worker-side fallback forward for unresolved-mesh delegates.
|
|
1633
1599
|
//
|
|
@@ -1641,8 +1607,9 @@ function enqueueCoordinatorForwardPush(coordinatorDaemonId: string, run: () => P
|
|
|
1641
1607
|
// the worker's queue — which it can't, because the worker never queued an unroutable
|
|
1642
1608
|
// event. Live symptom: `WARN [MeshEvents] delivery_unroutable: ... mesh unresolved`.
|
|
1643
1609
|
//
|
|
1644
|
-
// The fix: the routing object still carries coordinatorDaemonId.
|
|
1645
|
-
//
|
|
1610
|
+
// The fix: the routing object still carries coordinatorDaemonId. Persist the raw event
|
|
1611
|
+
// to the durable worker-side outbox addressed to that coordinator daemon; the reconcile
|
|
1612
|
+
// loop's PHASE 0 delivers it (mesh_forward_event, acked, retry-capped). The coordinator
|
|
1646
1613
|
// hosts the mesh, so it recovers the mesh id by workspace in handleMeshForwardEvent and
|
|
1647
1614
|
// injects/queues it normally. meshId is intentionally omitted from the payload (the
|
|
1648
1615
|
// worker has none); workspace is the routing anchor the coordinator resolves from.
|
|
@@ -1656,14 +1623,17 @@ function enqueueCoordinatorForwardPush(coordinatorDaemonId: string, run: () => P
|
|
|
1656
1623
|
//
|
|
1657
1624
|
// Returns true when the event was durably accepted for delivery to the coordinator
|
|
1658
1625
|
// daemon (so the caller skips the delivery_unroutable diagnostic); false when no
|
|
1659
|
-
// fallback was possible (no coordinator anchor / no dispatch transport)
|
|
1626
|
+
// fallback was possible (no coordinator anchor / no dispatch transport) OR the durable
|
|
1627
|
+
// enqueue itself failed — the delivery_unroutable diagnostic is thereby narrowed to
|
|
1628
|
+
// "could not even persist to the queue" (a real potential loss), per the polling-
|
|
1629
|
+
// single-model design (docs/refactoring/2026-06-16-mesh-completion-polling-single-model.md §2.1/§2.6).
|
|
1660
1630
|
//
|
|
1661
|
-
//
|
|
1662
|
-
//
|
|
1663
|
-
//
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1666
|
-
//
|
|
1631
|
+
// Single delivery path (polling single-model): the spontaneous best-effort immediate
|
|
1632
|
+
// push that used to run here was REMOVED. Every unresolved-delegate event is persisted
|
|
1633
|
+
// to the outbox and delivered ONLY by setupMeshReconcileLoop's PHASE 0 retry (acked;
|
|
1634
|
+
// a failed push leaves the row queued). For happy-path latency the enqueue emits a
|
|
1635
|
+
// data-free reconcile NUDGE (nudgeUnresolvedForwardRetry) asking the loop to run the
|
|
1636
|
+
// retry soon; a lost nudge costs at most one reconcile interval, never the event.
|
|
1667
1637
|
function forwardUnresolvedDelegateEvent(
|
|
1668
1638
|
components: DaemonComponents,
|
|
1669
1639
|
routing: ReturnType<typeof resolveWorkerDelegateRouting>,
|
|
@@ -1718,10 +1688,11 @@ function forwardUnresolvedDelegateEvent(
|
|
|
1718
1688
|
return true;
|
|
1719
1689
|
}
|
|
1720
1690
|
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
//
|
|
1691
|
+
// Persist durably. Idempotent on fingerprint, so a re-fired completion does not
|
|
1692
|
+
// duplicate the outbox row. The outbox is the ONLY delivery route now (no
|
|
1693
|
+
// spontaneous push), so a hard persistence failure means the event has nowhere to
|
|
1694
|
+
// live — return false so the caller records the delivery_unroutable diagnostic,
|
|
1695
|
+
// which is thereby narrowed to exactly this "could not even enqueue" real-loss case.
|
|
1725
1696
|
const persisted = enqueueUnresolvedDelegateForward(coordinatorDaemonId, eventName, payload);
|
|
1726
1697
|
// EVTTRACE: unresolved-mesh worker persisted its completion to the outbox (no meshId
|
|
1727
1698
|
// available locally; coordinator will recover it on receive).
|
|
@@ -1731,56 +1702,21 @@ function forwardUnresolvedDelegateEvent(
|
|
|
1731
1702
|
nodeId: readNonEmptyString(routing.nodeId) || readNonEmptyString(event.meshNodeId),
|
|
1732
1703
|
event: eventName,
|
|
1733
1704
|
};
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
traceMeshEventStage('
|
|
1739
|
-
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
.then((result: any) => {
|
|
1747
|
-
if (result && result.success === false) {
|
|
1748
|
-
LOG.warn('MeshEvents', `Immediate forward of ${eventName} to coordinator ${coordinatorDaemonId} rejected (${readNonEmptyString(result.error) || 'no reason'}) — left queued for retry`);
|
|
1749
|
-
traceMeshEventDrop('immediate_forward_rejected', fwdTraceCtx, readNonEmptyString(result.error) || 'no reason');
|
|
1750
|
-
return;
|
|
1751
|
-
}
|
|
1752
|
-
// Acked. Mark the durable copy delivered so the retry loop skips it.
|
|
1753
|
-
if (persisted) ackUnresolvedDelegateForwardByFingerprint(coordinatorDaemonId, eventName, payload);
|
|
1754
|
-
})
|
|
1755
|
-
.catch((e: any) => {
|
|
1756
|
-
// Coordinator momentarily unreachable; the durable row stays queued and the
|
|
1757
|
-
// reconcile loop retries it. Trace so the relay attempt is visible.
|
|
1758
|
-
LOG.warn('MeshEvents', `Immediate forward of ${eventName} to coordinator ${coordinatorDaemonId} failed: ${e?.message || e} — left queued for retry`);
|
|
1759
|
-
}));
|
|
1760
|
-
LOG.info('MeshEvents', `Durably forwarded ${eventName} for unresolved-mesh worker at ${routing.workspace || '(no workspace)'} to coordinator daemon ${coordinatorDaemonId}`);
|
|
1705
|
+
if (!persisted) {
|
|
1706
|
+
traceMeshEventDrop('outbox_enqueue_failed', fwdTraceCtx, `coordinatorDaemon=${coordinatorDaemonId}`);
|
|
1707
|
+
return false;
|
|
1708
|
+
}
|
|
1709
|
+
traceMeshEventStage('outbox_enqueue', fwdTraceCtx, `coordinatorDaemon=${coordinatorDaemonId} meshId=${readNonEmptyString(payload.meshId) || 'absent'}`);
|
|
1710
|
+
|
|
1711
|
+
// Data-free reconcile nudge (polling single-model §2.1 (B)): ask the reconcile
|
|
1712
|
+
// loop to run its PHASE 0 outbox retry soon instead of pushing the payload here.
|
|
1713
|
+
// Delivery itself stays on the single acked PHASE 0 path; losing the nudge costs
|
|
1714
|
+
// at most one reconcile interval of latency, never the event.
|
|
1715
|
+
nudgeUnresolvedForwardRetry();
|
|
1716
|
+
LOG.info('MeshEvents', `Durably queued ${eventName} for unresolved-mesh worker at ${routing.workspace || '(no workspace)'} to coordinator daemon ${coordinatorDaemonId} (reconcile PHASE 0 delivers)`);
|
|
1761
1717
|
return true;
|
|
1762
1718
|
}
|
|
1763
1719
|
|
|
1764
|
-
// Ack a just-pushed outbox entry by re-deriving its row from the same coordinator +
|
|
1765
|
-
// event + payload. We don't thread the row id back from enqueue (the immediate push is
|
|
1766
|
-
// fire-then-ack), so locate it among the undrained entries by matching coordinator and
|
|
1767
|
-
// the flat payload's forward identity. A miss is harmless — the retry loop's own
|
|
1768
|
-
// receiver-side dedup suppresses a duplicate delivery.
|
|
1769
|
-
function ackUnresolvedDelegateForwardByFingerprint(
|
|
1770
|
-
coordinatorDaemonId: string,
|
|
1771
|
-
eventName: string,
|
|
1772
|
-
payload: Record<string, unknown>,
|
|
1773
|
-
): void {
|
|
1774
|
-
const match = peekUnresolvedDelegateForwards().find(entry =>
|
|
1775
|
-
daemonIdsEquivalent(entry.coordinatorDaemonId, coordinatorDaemonId)
|
|
1776
|
-
&& readNonEmptyString(entry.payload.event) === eventName
|
|
1777
|
-
&& readNonEmptyString(entry.payload.targetSessionId || entry.payload.sessionId || entry.payload.instanceId)
|
|
1778
|
-
=== readNonEmptyString(payload.targetSessionId || payload.sessionId || payload.instanceId)
|
|
1779
|
-
&& readNonEmptyString(entry.payload.workspace) === readNonEmptyString(payload.workspace),
|
|
1780
|
-
);
|
|
1781
|
-
if (match) ackUnresolvedDelegateForward(match.id);
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
1720
|
/**
|
|
1785
1721
|
* NOTIF-HELD-DRAIN (Fix 2): event-driven coordinator drain. The reconcile loop delivers a
|
|
1786
1722
|
* worker's queued completion to an IDLE local coordinator only on its periodic poll. When a
|