@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
package/dist/mesh/contracts.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
50
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
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,
|
package/src/mesh/contracts.ts
CHANGED
|
@@ -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
|
+
}
|