@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.
@@ -123,6 +123,15 @@ export interface PendingMeshCoordinatorEventV2 {
123
123
  readonly coordinatorMessage?: string;
124
124
  readonly queuedAt: number;
125
125
  readonly protocolVersion: MeshProtocolVersion;
126
+ /**
127
+ * Idempotency key. A UUID generated ONCE at emit time and preserved verbatim
128
+ * across every store (SQLite payload + column, JSONL) and the P2P relay
129
+ * boundary. The receiving drain uses eventId as the authoritative dedup key
130
+ * (B3): an event whose eventId was already drained is skipped even if its
131
+ * content fingerprint differs. Distinct from the content fingerprint, which
132
+ * stays the v1 dedup mechanism during rollout.
133
+ */
134
+ readonly eventId: string;
126
135
  readonly scope: MeshEventScope;
127
136
  readonly dispatchedBy: CoordinatorIdentity;
128
137
  /**
@@ -161,4 +170,61 @@ export declare function assertPendingMeshCoordinatorEventV2(raw: unknown, path?:
161
170
  * are quarantined to a dedicated drain endpoint instead.
162
171
  */
163
172
  export declare function shouldDeliverPendingEventToCoordinator(event: PendingMeshCoordinatorEventV2, drainer: CoordinatorIdentity): boolean;
173
+ /**
174
+ * Default the v2 scope for an event by its producer event name (design decision
175
+ * §3). Terminal task events → unicast (routed to the originating coordinator).
176
+ * Ledger-consistency / dispatch-plane events → system. Everything else — node
177
+ * lifecycle and progress signals — → broadcast, which also matches v1's
178
+ * implicit "deliver to any coordinator" behaviour, so an unstamped v1 event and
179
+ * a v2-stamped-as-broadcast event route identically during rollout.
180
+ */
181
+ export declare function defaultScopeForEvent(eventName: string): MeshEventScope;
182
+ /**
183
+ * The v2 envelope stamp applied to a pending coordinator event at emit time.
184
+ * Additive over the v1 shape: every field is consulted by v2-aware drainers
185
+ * only, so a v1 reader that ignores them is unaffected.
186
+ */
187
+ export interface PendingEventEmitStampV2 {
188
+ readonly protocolVersion: typeof MESH_PROTOCOL_VERSION_V2;
189
+ readonly eventId: string;
190
+ readonly scope: MeshEventScope;
191
+ readonly dispatchedBy: CoordinatorIdentity;
192
+ readonly intendedFor?: CoordinatorIdentity;
193
+ }
194
+ /**
195
+ * Build a coordinator identity from the loosely-typed fields the v1 emit call
196
+ * sites already carry (a daemonId, and — sometimes — a coordinator session id).
197
+ * Returns undefined when no usable daemonId is present; the caller then leaves
198
+ * the event unstamped (v1, broadcast-treated) rather than fabricating identity.
199
+ * coordinatorRunId is not known at every emit site yet (B2 wires the registry),
200
+ * so it falls back to the daemonId — a stable, non-empty value that keeps the
201
+ * identity well-formed for assertCoordinatorIdentity without inventing a UUID
202
+ * that would differ per emit and defeat equality.
203
+ */
204
+ export declare function coordinatorIdentityFromEmitFields(fields: {
205
+ daemonId?: string | null;
206
+ coordinatorRunId?: string | null;
207
+ sessionId?: string | null;
208
+ }): CoordinatorIdentity | undefined;
209
+ /**
210
+ * Compute the v2 emit stamp for an event (B2a). `eventId` MUST be supplied by
211
+ * the caller (generated via crypto.randomUUID at the daemon-core boundary so
212
+ * this module stays dependency-free and deterministically testable). Scope
213
+ * defaults from the event name unless explicitly overridden.
214
+ *
215
+ * When `dispatchedBy` cannot be constructed (no coordinator daemon known),
216
+ * returns undefined: the event stays a v1 (unstamped) event and is broadcast-
217
+ * treated during rollout, exactly as before — no regression, no fabricated
218
+ * identity. When the resolved scope is 'unicast' but no `intendedFor` is
219
+ * available, the scope is downgraded to 'broadcast' so the stamp never violates
220
+ * the "unicast requires intendedFor" contract (a terminal event that cannot be
221
+ * addressed to its originator is safest delivered broadly, not dropped).
222
+ */
223
+ export declare function buildPendingEventEmitStamp(opts: {
224
+ eventName: string;
225
+ eventId: string;
226
+ dispatchedBy?: CoordinatorIdentity;
227
+ intendedFor?: CoordinatorIdentity;
228
+ scope?: MeshEventScope;
229
+ }): PendingEventEmitStampV2 | undefined;
164
230
  export {};
@@ -39,8 +39,14 @@ export interface CoordinatorRecentActivity {
39
39
  /** Short task title/message, already truncated by the caller. */
40
40
  summary?: string;
41
41
  }>;
42
- /** Count of task_failed entries inside the recent (30-min) window. */
42
+ /** Count of task_failed entries inside the recent window. */
43
43
  recentFailureCount?: number;
44
+ /**
45
+ * Size of the "recent" window in minutes. Drives the "failed in the last
46
+ * N min" phrasing. Omitted → defaults to 30, matching the prior hardcoded
47
+ * wording so existing callers render identically.
48
+ */
49
+ windowMinutes?: number;
44
50
  /** Pending (unclaimed) tasks in the work queue. */
45
51
  pendingTasks?: number;
46
52
  /** Assigned-but-not-yet-terminal tasks in the work queue. */
@@ -86,23 +92,4 @@ export interface CoordinatorPromptContext {
86
92
  */
87
93
  operatingNotes?: CoordinatorOperatingNote[];
88
94
  }
89
- /**
90
- * Compose the final coordinator prompt from four layers, in this precedence:
91
- *
92
- * 1. Per-launch `extraSystemPrompt` (always appended, as "## Additional
93
- * Context"). Never wins as a base — it's launch-scope context.
94
- * 2. Mesh-level append (`mesh.coordinator.systemPromptAppend` or the legacy
95
- * `systemPromptSuffix`). Stacks after whichever base won.
96
- * 3. User-file append (`~/.adhdev/coordinator-prompts/<cli>.append.md` or
97
- * `default.append.md`). Also stacks; same placeholder expansion as the
98
- * override path.
99
- * 4. Base prompt, picked in this order:
100
- * a. `mesh.coordinator.systemPromptOverride` (mesh-level override)
101
- * b. user-file override (`~/.adhdev/coordinator-prompts/<cli>.md` or
102
- * `default.md`)
103
- * c. daemon default (assembled from identity/nodes/policy/tools/…)
104
- *
105
- * That layering lets a user customize prompts at three increasing scopes
106
- * (machine, mesh, single launch) without losing the daemon's stock rules.
107
- */
108
95
  export declare function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): string;
@@ -1,3 +1,4 @@
1
+ import { MESH_PROTOCOL_VERSION_V2, type CoordinatorIdentity, type MeshEventScope } from './contracts.js';
1
2
  export interface PendingMeshCoordinatorEvent {
2
3
  event: string;
3
4
  meshId: string;
@@ -23,13 +24,56 @@ export interface PendingMeshCoordinatorEvent {
23
24
  * the JSONL file without a dedicated column; it is NOT a drain-scoping key.
24
25
  */
25
26
  targetCoordinatorSessionId?: string;
27
+ /** '2.0' once stamped. Absent on v1 events. */
28
+ protocolVersion?: typeof MESH_PROTOCOL_VERSION_V2;
29
+ /** Idempotency key (UUID). The receiver's authoritative dedup key (B3). */
30
+ eventId?: string;
31
+ /** Routing scope. Defaulted from the event name unless the emit hint overrides. */
32
+ scope?: MeshEventScope;
33
+ /** Identity of the coordinator that dispatched the work this event reports. */
34
+ dispatchedBy?: CoordinatorIdentity;
35
+ /** Present only for unicast scope: the coordinator this event is addressed to. */
36
+ intendedFor?: CoordinatorIdentity;
37
+ }
38
+ /**
39
+ * Optional emit-time hint passed to queuePendingMeshCoordinatorEvent so a call
40
+ * site can override the name-defaulted scope or supply richer coordinator
41
+ * identity (e.g. a coordinatorRunId the base event fields don't carry). Every
42
+ * field is optional; when omitted the stamp is derived entirely from the
43
+ * event's own targetCoordinatorDaemonId / targetCoordinatorSessionId. Kept
44
+ * separate from the event so existing single-arg callers are untouched.
45
+ */
46
+ export interface PendingEventEmitHint {
47
+ scope?: MeshEventScope;
48
+ /** Overrides the coordinator identity derived from the event's target fields. */
49
+ dispatchedBy?: CoordinatorIdentity;
50
+ /** Overrides the unicast target derived from the event's target fields. */
51
+ intendedFor?: CoordinatorIdentity;
52
+ /** coordinatorRunId to fold into the derived identity when the event lacks one. */
53
+ coordinatorRunId?: string;
26
54
  }
27
55
  export declare function readRefineJobId(event: {
28
56
  metadataEvent?: Record<string, unknown>;
29
57
  } | Record<string, unknown>): string;
30
58
  export declare function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent): string;
31
59
  export declare function hasPendingCoordinatorEventDuplicate(event: PendingMeshCoordinatorEvent): boolean;
32
- export declare function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean;
60
+ /**
61
+ * Stamp the v2 protocol envelope onto a pending event at emit time (B2a).
62
+ *
63
+ * Non-breaking: returns a NEW event object with protocolVersion/eventId/scope/
64
+ * dispatchedBy/intendedFor added when a coordinator identity can be derived,
65
+ * otherwise returns the input unchanged (a v1 event, broadcast-treated during
66
+ * rollout). Identity and the unicast target are derived from the event's own
67
+ * targetCoordinatorDaemonId / targetCoordinatorSessionId (already carried by
68
+ * every producer), so most call sites need no change; the optional `hint`
69
+ * overrides scope/identity where a site knows better.
70
+ *
71
+ * The eventId is generated here (randomUUID) exactly once, so re-queues that
72
+ * pass an already-stamped event keep their original eventId — the idempotency
73
+ * key is stable across re-delivery. An already-stamped event is returned as-is.
74
+ */
75
+ export declare function stampPendingEventV2(event: PendingMeshCoordinatorEvent, hint?: PendingEventEmitHint): PendingMeshCoordinatorEvent;
76
+ export declare function queuePendingMeshCoordinatorEvent(rawEvent: PendingMeshCoordinatorEvent, hint?: PendingEventEmitHint): boolean;
33
77
  /**
34
78
  * Drain and return pending coordinator events for meshId, removing the drained
35
79
  * ones from both the SQLite inbox and the JSONL legacy file.
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { EventEmitter } from 'events';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
+ import { type MeshLedgerOriginatingCoordinatorV2 } from './contracts.js';
17
18
  export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed' | 'coordinator_operating_note' | 'coordinator_operating_note_tombstone' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis';
18
19
  export interface MeshLedgerEntry {
19
20
  id: string;
@@ -178,6 +179,23 @@ export declare function compactLedger(meshId: string): {
178
179
  };
179
180
  export declare function normalizeMeshWorkerResult(input?: Record<string, unknown>, source?: MeshWorkerResultArtifact['source']): MeshWorkerResultArtifact;
180
181
  export declare function buildTaskCompletionEvidence(opts: BuildTaskCompletionEvidenceOptions): MeshTaskCompletionEvidence;
182
+ /**
183
+ * Build the v2 originating-coordinator stamp for a task_dispatched ledger entry
184
+ * (B2a / design decision §2). This is the source of truth from which a worker's
185
+ * completion emit later restores `dispatchedBy` — it records which coordinator
186
+ * dispatched the task, so the terminal event can be routed (unicast) back to it.
187
+ *
188
+ * Nested under `payload.originatingCoordinator`; additive, so existing readers
189
+ * of the task_dispatched payload are unaffected. Returns undefined when no
190
+ * coordinator daemon id is known (the pre-v2 path) so the caller omits the stamp
191
+ * entirely rather than writing a malformed identity — those entries stay v1 and
192
+ * are broadcast-treated during rollout.
193
+ */
194
+ export declare function buildLedgerOriginatingCoordinatorStamp(fields: {
195
+ coordinatorDaemonId?: string | null;
196
+ coordinatorRunId?: string | null;
197
+ coordinatorSessionId?: string | null;
198
+ }): MeshLedgerOriginatingCoordinatorV2 | undefined;
181
199
  /**
182
200
  * Append a new entry to the mesh ledger.
183
201
  * Handles file creation, rotation on size overflow, and atomic writes.
@@ -52,6 +52,23 @@ export declare function __resolveSchedulingStrategyForTests(mesh: any): RepoMesh
52
52
  export declare function __orderEligibleNodesForTests(meshId: string, strategy: RepoMeshSchedulingStrategy, nodes: RankableNode[], opts?: {
53
53
  bumpCursor?: boolean;
54
54
  }): RankableNode[];
55
+ /** One idle session eligible to claim a queued task, together with the resolved
56
+ * mesh node record it belongs to. Local candidates come from live CLI instances,
57
+ * remote candidates from the registered remote-idle-session store; the two arrive
58
+ * with their `nodeId` under different serialization forms. */
59
+ type IdleCandidate = {
60
+ nodeId: string;
61
+ sessionId: string;
62
+ providerType: string;
63
+ origin: 'local' | 'remote';
64
+ node: any;
65
+ };
66
+ /** Test-only: the pool-canonicalization + unique-node collapse stage. Exposed so
67
+ * the mixed-form dedup invariant can be unit-tested without a live daemon. */
68
+ export declare function __buildSchedulingPoolForTests(localCandidates: IdleCandidate[], remoteCandidates: IdleCandidate[]): {
69
+ pool: IdleCandidate[];
70
+ uniqueNodes: RankableNode[];
71
+ };
55
72
  export declare function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean;
56
73
  /**
57
74
  * CANON-IDENTITY single-flight hardening (restart-safe, observation-based).
@@ -1,4 +1,13 @@
1
1
  import type { MeshTaskStatus, MeshWorkQueueEntry } from './mesh-work-queue.js';
2
+ export interface MeshInflightHoldRow {
3
+ taskId: string;
4
+ meshId: string | null;
5
+ holdReason: string | null;
6
+ heldAt: number | null;
7
+ firstIdleSinceAck: number | null;
8
+ readFailureCount: number | null;
9
+ updatedAt: number | null;
10
+ }
2
11
  export declare class MeshRuntimeStore {
3
12
  private static instance;
4
13
  private readonly db;
@@ -65,6 +74,18 @@ export declare class MeshRuntimeStore {
65
74
  * by for this pass). UPSERT keeps it lock-free across concurrent passes.
66
75
  */
67
76
  bumpSchedulerCursor(meshId: string): number;
77
+ private mapInflightHoldRow;
78
+ upsertInflightHold(entry: {
79
+ taskId: string;
80
+ meshId?: string | null;
81
+ holdReason?: string | null;
82
+ heldAt?: number | null;
83
+ firstIdleSinceAck?: number | null;
84
+ readFailureCount?: number | null;
85
+ }): void;
86
+ getInflightHold(taskId: string): MeshInflightHoldRow | null;
87
+ listInflightHoldsByMesh(meshId: string): MeshInflightHoldRow[];
88
+ deleteInflightHold(taskId: string): void;
68
89
  /**
69
90
  * Count active (status='assigned') tasks on a (node, provider) combination,
70
91
  * matched by the assignedProviderType stamped on the payload at claim time.
@@ -374,6 +395,11 @@ export declare class MeshRuntimeStore {
374
395
  payload?: unknown;
375
396
  fingerprint?: string | null;
376
397
  queuedAt: number;
398
+ protocolVersion?: string | null;
399
+ eventId?: string | null;
400
+ scope?: string | null;
401
+ dispatchedBy?: string | null;
402
+ intendedFor?: string | null;
377
403
  }): boolean;
378
404
  /**
379
405
  * Drain undrained pending events for a mesh, atomically marking them drained.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.459",
3
+ "version": "0.9.82-rc.460",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -28,6 +28,7 @@
28
28
  "build": "tsup && npm run build:types",
29
29
  "build:types": "tsc -p tsconfig.json --emitDeclarationOnly --declaration --declarationMap false",
30
30
  "dev": "tsup --watch --no-clean",
31
+ "lint": "eslint \"src/mesh/**/*.ts\"",
31
32
  "test": "vitest run",
32
33
  "test:watch": "vitest",
33
34
  "typecheck": "tsc --noEmit -p tsconfig.json"
@@ -46,8 +47,8 @@
46
47
  "author": "vilmire",
47
48
  "license": "AGPL-3.0-or-later",
48
49
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.459",
50
- "@adhdev/session-host-core": "0.9.82-rc.459",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.460",
51
+ "@adhdev/session-host-core": "0.9.82-rc.460",
51
52
  "@agentclientprotocol/sdk": "^0.16.1",
52
53
  "ajv": "^8.20.0",
53
54
  "ajv-formats": "^3.0.1",
@@ -67,8 +68,10 @@
67
68
  "@types/js-yaml": "^4.0.9",
68
69
  "@types/node": "^22.0.0",
69
70
  "@types/ws": "^8.18.1",
71
+ "eslint": "^9.39.4",
70
72
  "tsup": "^8.2.0",
71
73
  "typescript": "^5.5.0",
74
+ "typescript-eslint": "^8.62.0",
72
75
  "vitest": "^4.1.3"
73
76
  },
74
77
  "repository": {
@@ -131,7 +131,14 @@ export function queueRefineJobEvent(self: DaemonCommandRouter, event: 'refine:ac
131
131
 
132
132
  export async function appendRefineJobLedger(self: DaemonCommandRouter, kind: 'task_dispatched' | 'task_completed' | 'task_failed', handle: MeshRefineJobHandle, result?: Record<string, unknown>): Promise<void> {
133
133
  try {
134
- const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
134
+ const { appendLedgerEntry, buildLedgerOriginatingCoordinatorStamp } = await import('../mesh/mesh-ledger.js');
135
+ // B2a: on dispatch, stamp the originating coordinator so a later completion
136
+ // emit can restore `dispatchedBy` and route the terminal event back (unicast).
137
+ // Refine jobs carry only a coordinator DAEMON id (no session), which is enough
138
+ // to route to the daemon-level coordinator. Absent → omitted (v1 entry).
139
+ const originatingStamp = kind === 'task_dispatched'
140
+ ? buildLedgerOriginatingCoordinatorStamp({ coordinatorDaemonId: handle.targetCoordinatorDaemonId })
141
+ : undefined;
135
142
  appendLedgerEntry(handle.meshId, {
136
143
  kind,
137
144
  nodeId: handle.targetNodeId,
@@ -152,6 +159,7 @@ export async function appendRefineJobLedger(self: DaemonCommandRouter, kind: 'ta
152
159
  },
153
160
  async: true,
154
161
  retryOfJobId: handle.retryOfJobId,
162
+ ...(originatingStamp ? { originatingCoordinator: originatingStamp } : {}),
155
163
  ...(result ? {
156
164
  success: result.success === true,
157
165
  result,
@@ -1395,7 +1403,11 @@ export async function appendRefineBatchJobLedger(self: DaemonCommandRouter,
1395
1403
  result?: Record<string, unknown>,
1396
1404
  ): Promise<void> {
1397
1405
  try {
1398
- const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
1406
+ const { appendLedgerEntry, buildLedgerOriginatingCoordinatorStamp } = await import('../mesh/mesh-ledger.js');
1407
+ // B2a: stamp the originating coordinator on dispatch (see appendRefineJobLedger).
1408
+ const originatingStamp = kind === 'task_dispatched'
1409
+ ? buildLedgerOriginatingCoordinatorStamp({ coordinatorDaemonId: handle.targetCoordinatorDaemonId })
1410
+ : undefined;
1399
1411
  appendLedgerEntry(handle.meshId, {
1400
1412
  kind,
1401
1413
  nodeId: handle.batchLabel,
@@ -1415,6 +1427,7 @@ export async function appendRefineBatchJobLedger(self: DaemonCommandRouter,
1415
1427
  },
1416
1428
  async: true,
1417
1429
  batch: true,
1430
+ ...(originatingStamp ? { originatingCoordinator: originatingStamp } : {}),
1418
1431
  ...(result ? {
1419
1432
  success: result.success === true,
1420
1433
  result,
@@ -193,6 +193,15 @@ export interface PendingMeshCoordinatorEventV2 {
193
193
 
194
194
  // v2 additions
195
195
  readonly protocolVersion: MeshProtocolVersion;
196
+ /**
197
+ * Idempotency key. A UUID generated ONCE at emit time and preserved verbatim
198
+ * across every store (SQLite payload + column, JSONL) and the P2P relay
199
+ * boundary. The receiving drain uses eventId as the authoritative dedup key
200
+ * (B3): an event whose eventId was already drained is skipped even if its
201
+ * content fingerprint differs. Distinct from the content fingerprint, which
202
+ * stays the v1 dedup mechanism during rollout.
203
+ */
204
+ readonly eventId: string;
196
205
  readonly scope: MeshEventScope;
197
206
  readonly dispatchedBy: CoordinatorIdentity;
198
207
  /**
@@ -264,6 +273,9 @@ export function assertPendingMeshCoordinatorEventV2(raw: unknown, path = '$'): P
264
273
  if (!isSupportedMeshProtocolVersion(obj.protocolVersion)) {
265
274
  throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.protocolVersion`, `must be one of ${SUPPORTED_MESH_PROTOCOL_VERSIONS.join(', ')}`);
266
275
  }
276
+ if (!isNonEmptyString(obj.eventId)) {
277
+ throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.eventId`, 'must be a non-empty string');
278
+ }
267
279
  if (!isMeshEventScope(obj.scope)) {
268
280
  throw new MeshContractViolationError(MESH_PROTOCOL_VERSION_V2, `${path}.scope`, `must be one of ${MESH_EVENT_SCOPES.join(', ')}`);
269
281
  }
@@ -307,6 +319,7 @@ export function assertPendingMeshCoordinatorEventV2(raw: unknown, path = '$'): P
307
319
  coordinatorMessage: typeof obj.coordinatorMessage === 'string' ? obj.coordinatorMessage : undefined,
308
320
  queuedAt,
309
321
  protocolVersion: obj.protocolVersion,
322
+ eventId: obj.eventId,
310
323
  scope: obj.scope,
311
324
  dispatchedBy,
312
325
  ...(intendedFor ? { intendedFor } : {}),
@@ -336,3 +349,121 @@ export function shouldDeliverPendingEventToCoordinator(
336
349
  if (!event.intendedFor) return false;
337
350
  return coordinatorIdentityEquals(event.intendedFor, drainer);
338
351
  }
352
+
353
+ // ─── Emit-side scope defaulting (B2a) ────────────────────────────────────────
354
+
355
+ /**
356
+ * Terminal task events. In v2 these are unicast: a completion/failure/approval
357
+ * belongs to exactly the coordinator (session) that dispatched the task, so
358
+ * delivering it to a sibling coordinator on the same daemon is a routing bug.
359
+ * The names match the v1 producer event strings queued by the emit call sites
360
+ * (mesh-event-forwarding, mesh-events-stale, router-refine).
361
+ */
362
+ const TERMINAL_TASK_EVENTS: ReadonlySet<string> = new Set([
363
+ 'agent:generating_completed',
364
+ 'agent:stopped',
365
+ 'refine:completed',
366
+ 'refine:failed',
367
+ 'refine:accepted',
368
+ ]);
369
+
370
+ /**
371
+ * Infrastructure/system events that no coordinator should surface — ledger
372
+ * consistency and dispatch-plane signals. Delivered to the daemon-level handler
373
+ * only (scope 'system').
374
+ */
375
+ const SYSTEM_EVENTS: ReadonlySet<string> = new Set([
376
+ 'mesh:dispatch_blocked',
377
+ ]);
378
+
379
+ /**
380
+ * Default the v2 scope for an event by its producer event name (design decision
381
+ * §3). Terminal task events → unicast (routed to the originating coordinator).
382
+ * Ledger-consistency / dispatch-plane events → system. Everything else — node
383
+ * lifecycle and progress signals — → broadcast, which also matches v1's
384
+ * implicit "deliver to any coordinator" behaviour, so an unstamped v1 event and
385
+ * a v2-stamped-as-broadcast event route identically during rollout.
386
+ */
387
+ export function defaultScopeForEvent(eventName: string): MeshEventScope {
388
+ if (SYSTEM_EVENTS.has(eventName)) return 'system';
389
+ if (TERMINAL_TASK_EVENTS.has(eventName)) return 'unicast';
390
+ return 'broadcast';
391
+ }
392
+
393
+ /**
394
+ * The v2 envelope stamp applied to a pending coordinator event at emit time.
395
+ * Additive over the v1 shape: every field is consulted by v2-aware drainers
396
+ * only, so a v1 reader that ignores them is unaffected.
397
+ */
398
+ export interface PendingEventEmitStampV2 {
399
+ readonly protocolVersion: typeof MESH_PROTOCOL_VERSION_V2;
400
+ readonly eventId: string;
401
+ readonly scope: MeshEventScope;
402
+ readonly dispatchedBy: CoordinatorIdentity;
403
+ readonly intendedFor?: CoordinatorIdentity;
404
+ }
405
+
406
+ /**
407
+ * Build a coordinator identity from the loosely-typed fields the v1 emit call
408
+ * sites already carry (a daemonId, and — sometimes — a coordinator session id).
409
+ * Returns undefined when no usable daemonId is present; the caller then leaves
410
+ * the event unstamped (v1, broadcast-treated) rather than fabricating identity.
411
+ * coordinatorRunId is not known at every emit site yet (B2 wires the registry),
412
+ * so it falls back to the daemonId — a stable, non-empty value that keeps the
413
+ * identity well-formed for assertCoordinatorIdentity without inventing a UUID
414
+ * that would differ per emit and defeat equality.
415
+ */
416
+ export function coordinatorIdentityFromEmitFields(fields: {
417
+ daemonId?: string | null;
418
+ coordinatorRunId?: string | null;
419
+ sessionId?: string | null;
420
+ }): CoordinatorIdentity | undefined {
421
+ const daemonId = typeof fields.daemonId === 'string' && fields.daemonId.length > 0 ? fields.daemonId : undefined;
422
+ if (!daemonId) return undefined;
423
+ const coordinatorRunId = typeof fields.coordinatorRunId === 'string' && fields.coordinatorRunId.length > 0
424
+ ? fields.coordinatorRunId
425
+ : daemonId;
426
+ const sessionId = typeof fields.sessionId === 'string' && fields.sessionId.length > 0 ? fields.sessionId : undefined;
427
+ return sessionId !== undefined
428
+ ? { daemonId, coordinatorRunId, sessionId }
429
+ : { daemonId, coordinatorRunId };
430
+ }
431
+
432
+ /**
433
+ * Compute the v2 emit stamp for an event (B2a). `eventId` MUST be supplied by
434
+ * the caller (generated via crypto.randomUUID at the daemon-core boundary so
435
+ * this module stays dependency-free and deterministically testable). Scope
436
+ * defaults from the event name unless explicitly overridden.
437
+ *
438
+ * When `dispatchedBy` cannot be constructed (no coordinator daemon known),
439
+ * returns undefined: the event stays a v1 (unstamped) event and is broadcast-
440
+ * treated during rollout, exactly as before — no regression, no fabricated
441
+ * identity. When the resolved scope is 'unicast' but no `intendedFor` is
442
+ * available, the scope is downgraded to 'broadcast' so the stamp never violates
443
+ * the "unicast requires intendedFor" contract (a terminal event that cannot be
444
+ * addressed to its originator is safest delivered broadly, not dropped).
445
+ */
446
+ export function buildPendingEventEmitStamp(opts: {
447
+ eventName: string;
448
+ eventId: string;
449
+ dispatchedBy?: CoordinatorIdentity;
450
+ intendedFor?: CoordinatorIdentity;
451
+ scope?: MeshEventScope;
452
+ }): PendingEventEmitStampV2 | undefined {
453
+ if (!opts.dispatchedBy) return undefined;
454
+ let scope: MeshEventScope = opts.scope ?? defaultScopeForEvent(opts.eventName);
455
+ let intendedFor = opts.intendedFor;
456
+ if (scope === 'unicast' && !intendedFor) {
457
+ // No addressable target for a unicast event — fall back to broadcast so the
458
+ // stamp is contract-valid and the event is still delivered (never dropped).
459
+ scope = 'broadcast';
460
+ }
461
+ if (scope !== 'unicast') intendedFor = undefined;
462
+ return {
463
+ protocolVersion: MESH_PROTOCOL_VERSION_V2,
464
+ eventId: opts.eventId,
465
+ scope,
466
+ dispatchedBy: opts.dispatchedBy,
467
+ ...(intendedFor ? { intendedFor } : {}),
468
+ };
469
+ }